diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a91b9103..01d57fb27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +## [2.0.8] - 2026-08-22 + +### Fixed +- Relay payloads are now delivered only to the listener whose subscription they name. `NostrRelayClient.dispatchMessage()` broadcast every inbound frame to every listener on the connection, and nothing downstream read the subscription id the frame carried — so on a connection with two concurrent REQs, one subscription's `EOSE` fired the other's EOSE handler and ended its query early with whatever had arrived so far. Observed in the field as a gift-wrap query returning 0, 2 or 6 events at random from a relay holding exactly 5. `EVENT`, `EOSE` and `CLOSED` are now routed by subscription id for listeners registered through `subscribe(ReqMessage, …)`; connection-scoped frames (`NOTICE`, `OK`, `AUTH`) and listeners registered with raw JSON still see everything, so no existing caller loses a frame it receives today. +- NIP-44 no longer depends on a JCE provider being registered. `EncryptedPayloads` asked for `Cipher.getInstance("ChaCha20")` with an `IvParameterSpec`, which only BouncyCastle's provider accepts — SunJCE requires a `ChaCha20ParameterSpec` and throws. The provider happened to be registered as a side effect of `Schnorr.generatePrivateKey()`, so encryption worked for callers who generated a key and failed for callers who loaded one, and could not work on Android at all (adding a provider named "BC" is a no-op there; the platform owns the name). Both cipher call sites now use BouncyCastle's lightweight `ChaCha7539Engine`, which needs no provider lookup. Closes #537. + +### Changed +- `Schnorr.generatePrivateKey()` no longer calls `Security.addProvider(new BouncyCastleProvider())`. It uses BouncyCastle's lightweight `ECKeyPairGenerator` instead, so generating a key no longer mutates process-wide JCE state. Callers that relied on nostr-java registering the provider for them must now register it themselves. + +### Added +- NIP-44 v2 is verified against the specification's own test vectors (`nip44.vectors.json` from the reference implementation), and the whole suite runs with the BouncyCastle provider de-registered so the defect above cannot come back unnoticed. + +## [2.0.7] - 2026-05-27 + +### Fixed +- Closed a TOCTOU window in the 2.0.6 `send()` `isOpen()` guard (PR #526 review). The check was performed at the top of `send()`, *before* `sendFrameGated()` acquired the `sessionGate` read lock — so a concurrent `close()` could acquire the write lock, close the session, and release it between the check and the actual `sendMessage()`, still letting the frame reach a closed session. Moved the `isOpen()` check inside `sendFrameGated()`, immediately after the read lock is taken, so the open-check and the write are now atomic with respect to `closeGated()` (which holds the write lock). 2.0.6 already handled the dominant case (the session already closed when `send()` is invoked); this closes the narrow in-flight-close window. + +## [2.0.6] - 2026-05-27 + +### Fixed +- `NostrRelayClient.send()` now guards on `clientSession.isOpen()` before writing, mirroring `subscribe()`. A per-subscription Nostr `CLOSE` issued while a relay connection was being torn down previously reached Tomcat's `sendText`, where `WsRemoteEndpointImplBase.sendMessageBlockInternal` invokes `doClose()` mid-write and emits a WS CLOSE frame while the text write is still pending on the async channel — throwing `IllegalStateException: Concurrent write operations are not permitted`, which surfaced via `handleTransportError` as a transport-error reconnect storm during subscription teardown of a breaking connection (spec-026). The application-level locks (the decorator, the `sessionGate`, and the upstream adapter `sendLock`) cannot prevent this because it is Tomcat closing the session *inside* a single in-progress send, not two application threads racing. `send()` now fails fast with `IOException("WebSocket session is closed")` on a closed session — the doomed write never enters Tomcat's close-mid-write path. +regression test `send_onClosedSession_failsFastWithoutDelegateWrite`. + +## [2.0.5] - 2026-05-26 + +### Fixed +- `NostrRelayClient.send()` no longer leaves the client stuck in the "request in flight" state when the write fails for a reason other than overflow. A plain transport `IOException` from the gated send previously bypassed `pendingRequest` cleanup, so the next `send()` (including an `@NostrRetryable` retry) hit `IllegalStateException: A request is already in flight` instead of retrying. `send()` now clears `pendingRequest` on any send failure, not only `SessionLimitExceededException`. (PR #525 review follow-up.) +- `closeQuietly()` now logs the swallowed throwable (with stack trace) and the relay URI instead of just the exception message, preserving diagnostics for best-effort closes. + +## [2.0.4] - 2026-05-26 + +### Fixed +- Close-vs-write race in `NostrRelayClient` (spec-026 US3). The client wrapped its session in Spring's `ConcurrentWebSocketSessionDecorator` but called the **no-arg** `clientSession.close()` (in `close()` and the `send()` timeout path). Spring 6.2.x's decorator overrides only `close(CloseStatus)` (guarded by `closeLock`); the no-arg `close()` falls through to `WebSocketSessionDecorator.close()` → `delegate.close()` with no coordination, sending a CLOSE frame straight to the Tomcat delegate while a `sendMessage` flush was in flight (Tomcat permits only one write in flight → `IllegalStateException: Concurrent write operations are not permitted`). Routing through `close(CloseStatus)` alone is insufficient because its `closeLock` is a separate lock from the `flushLock` guarding `delegate.sendMessage`. Introduced a `ReentrantReadWriteLock` session gate: every write (`send`/`subscribe`) holds the read side (sends stay concurrent — the decorator still serialises the delegate writes among them), every close holds the write side and always uses `close(CloseStatus)`, so a close waits for all in-flight sends to drain before sending the CLOSE frame. No lock nesting → no added deadlock risk. + ### Removed - Dead code cleanup — deleted unused classes: `IContent`, `JsonContent`, `Reaction` enum, `Response`, `Nip05Content`, `Nip05ContentDecoder`, `BaseAuthMessage`, `GenericMessage`, `IKey`, `GenericEventConverter`, `GenericEventTypeClassifier`, `GenericEventDecoder`, `FiltersDecoder`, `BaseTagDecoder`, `GenericEventValidator`, `GenericEventSerializer`, `GenericEventUpdater`, `GenericTagQuery`, `HttpClientProvider`, `DefaultHttpClientProvider`. - `testAuthMessage` test and `GenericEventSupportTest` removed (tested deleted classes). @@ -16,6 +49,28 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic - `BaseKey` now directly implements `Serializable` (previously implemented the now-deleted `IKey` interface). - `Nip05Validator` now creates `HttpClient` instances directly via a `Function` factory (previously used deleted `HttpClientProvider`/`DefaultHttpClientProvider` interface). +## [2.0.3] - 2026-05-08 + +### Fixed +- Explicit close on `OverflowStrategy.TERMINATE`; restores upstream `isOpen()==false` reconnect contract. Spring's `ConcurrentWebSocketSessionDecorator` under `OverflowStrategy.TERMINATE` only sets a private `limitExceeded` flag and throws `SessionLimitExceededException` from `limitExceeded()` — it does **not** close the delegate session. As a result, after 2.0.2 the upstream caller's `clientSession.isOpen()==false` → reconnect contract did not hold (the session stayed open after overflow). `NostrRelayClient.subscribe()` and `NostrRelayClient.send()` now detect a `SessionLimitExceededException` cause in their respective catch blocks and call `clientSession.close(CloseStatus.SESSION_NOT_RELIABLE)` explicitly before rewrapping the exception. Non-overflow `RuntimeException`s continue to flow through the existing wrap-as-`IOException` path unchanged. The §6.7d concurrency test now strictly asserts `clientSession.isOpen()==false` after overflow, matching the spec's original contract. + +## [2.0.2] - 2026-05-08 + +### Fixed +- `NostrRelayClient.subscribe()` was calling `clientSession.sendMessage(...)` without holding the existing `sendLock`, while the same class's `send()` path correctly held it. Two threads racing inside `subscribe()` (or one in `subscribe()` racing one in `send()`) could trigger the underlying writer's `IllegalStateException("The remote endpoint was in state [TEXT_FULL_WRITING]")` (Tomcat / Spring `StandardWebSocketSession`) or `Blocking message pending` (Jetty), which the `catch (RuntimeException)` block at the bottom of `subscribe()` rewrapped as `IOException("Failed to send subscription payload", e)`. Downstream consumer (`imani-gateway-core` `account-app`) observed ~70 `RelaySubscribeException` per 60 minutes once a circuit-breaker tuning fix unmasked the underlying race. Fixed by wrapping the underlying `WebSocketSession` returned by `connectSession(...)` in Spring's `ConcurrentWebSocketSessionDecorator(session, sendTimeLimit, bufferSizeLimit, OverflowStrategy.TERMINATE)` at construction time, so concurrent `sendMessage()` calls from any send-path are serialised at the session layer. + +### Added +- New canonical four-arg public constructor `NostrRelayClient(String relayUri, long awaitTimeoutMs, int sendBufferLimit, int sendTimeLimitMs)` annotated `@Autowired` for Spring constructor-injection. Spring binds against this overload via the new `@Value("${nostr.websocket.send-buffer-limit:262144}")` and `@Value("${nostr.websocket.send-time-limit-ms:10000}")` keys (also reachable via the `NOSTR_WEBSOCKET_SEND_BUFFER_LIMIT` / `NOSTR_WEBSOCKET_SEND_TIME_LIMIT_MS` env-vars under Spring relaxed binding). The previously-existing one-arg `(String)` and two-arg `(String, long)` public constructors are retained as delegating overloads (binary-compatible) and now also benefit from the decorator wrap by way of the canonical ctor. +- Test-only static factories `forTestWithRawSession(...)` and `forTestWithDecoratedSession(...)` (two overloads — defaults and explicit) plus a package-private four-arg test constructor, so the new `NostrRelayClientConcurrencyTest` can assert both the regression (raw-session reproduction of the `IllegalStateException` race) and the resolution (decorator-wrapped session serialises sends). + +### Changed +- `awaitTimeoutMs` is now a `final` field assigned once via constructor injection (previously `@Value`-annotated field). Spring's constructor-injection ordering guarantees the value reaches the constructor body before the decorator is constructed, which is required for the explicit overflow strategy to be applied. The system property / env-var key (`nostr.websocket.await-timeout-ms`) and its default (60 000 ms) are unchanged. + +## [2.0.1] - 2026-05-06 + +### Fixed +- `NostrRelayClient` log statements were emitting `Sending request to relay null: ...` (and similar) once the WebSocket session had closed, because the relay URI was being read via `clientSession.getUri()` which returns `null` after close. Captured the URI in a `private final String relayUri` field set in each constructor and replaced 8 `clientSession.getUri()` log call-sites. Resolves 188 occurrences per 2-hour window observed in a downstream consumer's staging logs ([#523](https://github.com/tcheeric/nostr-java/pull/523)). + ## [2.0.0] - 2026-02-24 This is a major release that implements the full design simplification described in `docs/developer/SIMPLIFICATION_PROPOSAL.md`, reducing the library from 9 modules with ~180 classes to 4 modules with ~40 classes. diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 143ef9ba1..85daf838d 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.0 + 2.0.8 ../pom.xml diff --git a/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java b/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java index e1d409c16..cb346895f 100644 --- a/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java +++ b/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java @@ -3,6 +3,7 @@ import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import nostr.event.BaseMessage; +import nostr.event.message.ReqMessage; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Scope; @@ -11,8 +12,11 @@ import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; +import org.springframework.web.socket.handler.SessionLimitExceededException; import org.springframework.web.socket.handler.TextWebSocketHandler; import jakarta.websocket.ContainerProvider; @@ -35,6 +39,7 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Consumer; /** @@ -53,6 +58,26 @@ public class NostrRelayClient extends TextWebSocketHandler implements AutoClosea private static final int DEFAULT_MAX_TEXT_MESSAGE_BUFFER_SIZE = 1048576; private static final int DEFAULT_MAX_BINARY_MESSAGE_BUFFER_SIZE = 1048576; private static final int DEFAULT_MAX_EVENTS_PER_REQUEST = 10_000; + /** + * Default {@code ConcurrentWebSocketSessionDecorator} send-buffer size — 256 KiB. + * + *

Sized at ~2.5× the largest expected single payload (a chunked kind-37375 + * EVENT can reach ~100 KiB) so that a large {@code send()} co-existing with + * a burst of subscribe REQs does not overflow the decorator's buffer. Tunable + * via {@code nostr.websocket.send-buffer-limit} ({@code @Value}) or + * {@code NOSTR_WEBSOCKET_SEND_BUFFER_LIMIT} (env-var, via Spring relaxed + * binding). + */ + private static final int DEFAULT_SEND_BUFFER_LIMIT = 256 * 1024; + /** + * Default {@code ConcurrentWebSocketSessionDecorator} per-send time limit — 10 s. + * + *

Above any p99.9 healthy-state subscribe REQ ({@literal <}100 ms) and well + * below {@link #DEFAULT_AWAIT_TIMEOUT_MS} so the writer-side timeout fires + * first if the underlying writer is genuinely stuck. Tunable via + * {@code nostr.websocket.send-time-limit-ms}. + */ + private static final int DEFAULT_SEND_TIME_LIMIT_MS = 10_000; private static final ThreadFactory RELAY_IO_THREAD_FACTORY = Thread.ofVirtual().name("nostr-relay-io-", 0).factory(); private static final ThreadFactory LISTENER_THREAD_FACTORY = @@ -62,8 +87,7 @@ public class NostrRelayClient extends TextWebSocketHandler implements AutoClosea private static final Executor LISTENER_EXECUTOR = command -> LISTENER_THREAD_FACTORY.newThread(command).start(); - @Value("${nostr.websocket.await-timeout-ms:60000}") - private long awaitTimeoutMs; + private final long awaitTimeoutMs; @Value("${nostr.websocket.max-idle-timeout-ms:3600000}") private long maxIdleTimeoutMs; @@ -71,8 +95,41 @@ public class NostrRelayClient extends TextWebSocketHandler implements AutoClosea @Value("${nostr.websocket.max-events-per-request:10000}") private int maxEventsPerRequest = DEFAULT_MAX_EVENTS_PER_REQUEST; + /** + * Decorator buffer-size limit captured at construction. Exposed as a + * package-private field so unit tests can assert that constructor arguments + * are reflected here rather than overridden by static defaults (the + * field-injection regression assertion described in the spec). + */ + private final int sendBufferLimit; + + /** + * Decorator per-send time-limit captured at construction. See + * {@link #sendBufferLimit} for the regression-assertion rationale. + */ + private final int sendTimeLimitMs; + private final WebSocketSession clientSession; + /** + * Relay URI captured at construction time. Used for logging so that + * messages remain meaningful even after the underlying WebSocket session + * has been closed (at which point {@link WebSocketSession#getUri()} may + * return {@code null} on some implementations). + */ + private final String relayUri; private final ReentrantLock sendLock = new ReentrantLock(); + /** + * Gates session writes against session close. Every {@code clientSession} + * write ({@code sendMessage}) holds the READ side; every close holds the + * WRITE side. This lets concurrent sends proceed in parallel — the + * {@link ConcurrentWebSocketSessionDecorator} still serialises the actual + * delegate writes among them — while a close waits for all in-flight sends + * to drain before sending a CLOSE frame. Needed because the decorator's + * {@code close(CloseStatus)} uses a separate lock from the flush lock, so it + * cannot by itself prevent a CLOSE frame from racing an in-flight write on + * the Tomcat delegate (which permits only one write in flight). + */ + private final ReentrantReadWriteLock sessionGate = new ReentrantReadWriteLock(); private PendingRequest pendingRequest; private final Map listeners = new ConcurrentHashMap<>(); private final AtomicReference connectionState = @@ -117,35 +174,199 @@ int eventCount() { } } + /** + * Back-compat constructor — delegates to the canonical four-arg constructor + * with default await-timeout / send-buffer-limit / send-time-limit. Retained + * for direct {@code new}-callers (e.g. the {@code connectAsync(String)} + * factory). Spring will not select this overload because the + * canonical four-arg constructor is annotated {@link Autowired}. + */ public NostrRelayClient(@Value("${nostr.relay.uri}") String relayUri) throws java.util.concurrent.ExecutionException, InterruptedException { - this.clientSession = connectSession(relayUri); - connectionState.set(ConnectionState.CONNECTED); + this(relayUri, DEFAULT_AWAIT_TIMEOUT_MS, DEFAULT_SEND_BUFFER_LIMIT, DEFAULT_SEND_TIME_LIMIT_MS); } + /** + * Back-compat constructor — delegates to the canonical four-arg constructor + * with default send-buffer-limit / send-time-limit. Retained for direct + * {@code new}-callers (e.g. the {@code connectAsync(String, long)} factory + * and the {@link NostrRelayClient(WebSocketSession, long)} test factory). + */ public NostrRelayClient(String relayUri, long awaitTimeoutMs) throws java.util.concurrent.ExecutionException, InterruptedException { + this(relayUri, awaitTimeoutMs, DEFAULT_SEND_BUFFER_LIMIT, DEFAULT_SEND_TIME_LIMIT_MS); + } + + /** + * Canonical constructor — the one Spring autowires against. Wraps the + * underlying {@link WebSocketSession} returned by {@link #connectSession} + * in a {@link ConcurrentWebSocketSessionDecorator} so concurrent + * {@code sendMessage()} calls from {@code subscribe()} and {@code send()} + * are serialised. Without the wrap, two threads racing inside + * {@code subscribe()} can trigger a Tomcat + * {@code IllegalStateException("The remote endpoint was in state + * [TEXT_FULL_WRITING]")} (or the Jetty / Undertow equivalent) which is + * caught by the existing {@code catch (RuntimeException)} block at the + * bottom of {@code subscribe()} and rewrapped as + * {@code IOException("Failed to send subscription payload", …)}. + * + *

The decorator is constructed with + * {@link ConcurrentWebSocketSessionDecorator.OverflowStrategy#TERMINATE} + * (made explicit rather than relying on the default): if the buffer fills, + * the underlying session is closed, the calling thread sees a + * {@code SessionLimitExceededException}, and the next call lands on the + * reconnect path in the upstream {@code NostrJavaRelayClient} caller. + * {@code OverflowStrategy.DROP} is explicitly avoided because dropping + * outbound REQ / EVENT frames would silently make callers believe a + * subscribe / publish succeeded when the relay never received it. + */ + @Autowired + public NostrRelayClient( + @Value("${nostr.relay.uri}") String relayUri, + @Value("${nostr.websocket.await-timeout-ms:60000}") long awaitTimeoutMs, + @Value("${nostr.websocket.send-buffer-limit:262144}") int sendBufferLimit, + @Value("${nostr.websocket.send-time-limit-ms:10000}") int sendTimeLimitMs) + throws java.util.concurrent.ExecutionException, InterruptedException { if (awaitTimeoutMs <= 0) { throw new IllegalArgumentException("awaitTimeoutMs must be positive"); } + if (sendBufferLimit <= 0) { + throw new IllegalArgumentException("sendBufferLimit must be positive"); + } + if (sendTimeLimitMs <= 0) { + throw new IllegalArgumentException("sendTimeLimitMs must be positive"); + } + this.relayUri = relayUri; this.awaitTimeoutMs = awaitTimeoutMs; - log.info("NostrRelayClient created for {} with awaitTimeoutMs={}", relayUri, awaitTimeoutMs); - this.clientSession = connectSession(relayUri); + this.sendBufferLimit = sendBufferLimit; + this.sendTimeLimitMs = sendTimeLimitMs; + log.info( + "NostrRelayClient created for {} with awaitTimeoutMs={} sendBufferLimit={} sendTimeLimitMs={}", + relayUri, awaitTimeoutMs, sendBufferLimit, sendTimeLimitMs); + WebSocketSession raw = connectSession(relayUri); + this.clientSession = + new ConcurrentWebSocketSessionDecorator( + raw, sendTimeLimitMs, sendBufferLimit, + ConcurrentWebSocketSessionDecorator.OverflowStrategy.TERMINATE); connectionState.set(ConnectionState.CONNECTED); } + /** + * Test-only constructor — the supplied {@link WebSocketSession} is stored + * as-is; no decorator wrap is applied. Production code paths must + * not reach this constructor — they go through the public + * {@code (String, …)} constructors that perform the wrap. + * + *

The §6.7a reproduction step (regression test for the concurrent-send + * race) needs a raw, unwrapped session to deterministically reproduce the + * {@code IllegalStateException}. Use + * {@link #forTestWithRawSession(WebSocketSession, long)} for that path. The + * §6.7b–d resolution steps wrap the session via + * {@link #forTestWithDecoratedSession(WebSocketSession, long)} and then + * reach this constructor with the wrapped session as the argument. + */ NostrRelayClient(WebSocketSession clientSession, long awaitTimeoutMs) { + this(clientSession, awaitTimeoutMs, DEFAULT_SEND_BUFFER_LIMIT, DEFAULT_SEND_TIME_LIMIT_MS); + } + + /** + * Test-only constructor — four-arg form taking explicit decorator parameters + * so unit tests can assert that the constructor arguments are reflected in + * {@link #sendBufferLimit} and {@link #sendTimeLimitMs} (the field-injection + * regression assertion described in spec §4.1). The supplied session is + * stored as-is — wrapping is the caller's responsibility, see + * {@link #forTestWithDecoratedSession(WebSocketSession, long, int, int)}. + */ + NostrRelayClient( + WebSocketSession clientSession, + long awaitTimeoutMs, + int sendBufferLimit, + int sendTimeLimitMs) { if (clientSession == null) { throw new NullPointerException("clientSession must not be null"); } if (awaitTimeoutMs <= 0) { throw new IllegalArgumentException("awaitTimeoutMs must be positive"); } + if (sendBufferLimit <= 0) { + throw new IllegalArgumentException("sendBufferLimit must be positive"); + } + if (sendTimeLimitMs <= 0) { + throw new IllegalArgumentException("sendTimeLimitMs must be positive"); + } this.clientSession = clientSession; this.awaitTimeoutMs = awaitTimeoutMs; + this.sendBufferLimit = sendBufferLimit; + this.sendTimeLimitMs = sendTimeLimitMs; + URI sessionUri = null; + try { + sessionUri = clientSession.getUri(); + } catch (Exception ignored) { + // Some WebSocketSession implementations may throw before the session + // is fully initialised; fall through and store null. + } + this.relayUri = sessionUri == null ? null : sessionUri.toString(); connectionState.set(ConnectionState.CONNECTED); } + /** + * Test-only factory — REGRESSION reproduction path. Bypasses the decorator + * so the §6.7a reproduction can deterministically reproduce the + * concurrent-send {@code IllegalStateException} against a raw session. + * Not used by production code. + */ + static NostrRelayClient forTestWithRawSession(WebSocketSession session, long awaitTimeoutMs) { + return new NostrRelayClient( + session, awaitTimeoutMs, DEFAULT_SEND_BUFFER_LIMIT, DEFAULT_SEND_TIME_LIMIT_MS); + } + + /** + * Test-only factory — production path with default buffer / time-limit. + * Wraps the supplied session in a {@link ConcurrentWebSocketSessionDecorator} + * (using static defaults) and forwards through the package-private four-arg + * test ctor. + */ + static NostrRelayClient forTestWithDecoratedSession( + WebSocketSession session, long awaitTimeoutMs) { + return forTestWithDecoratedSession( + session, awaitTimeoutMs, DEFAULT_SEND_BUFFER_LIMIT, DEFAULT_SEND_TIME_LIMIT_MS); + } + + /** + * Test-only factory — production path with custom buffer / time-limit. Used + * by the §6.7c (mixed-workload, default buffer) and §6.7d (overflow-and-close, + * small buffer) sub-cases. + */ + static NostrRelayClient forTestWithDecoratedSession( + WebSocketSession session, + long awaitTimeoutMs, + int sendBufferLimit, + int sendTimeLimitMs) { + WebSocketSession wrapped = + (session instanceof ConcurrentWebSocketSessionDecorator) + ? session + : new ConcurrentWebSocketSessionDecorator( + session, sendTimeLimitMs, sendBufferLimit, + ConcurrentWebSocketSessionDecorator.OverflowStrategy.TERMINATE); + return new NostrRelayClient(wrapped, awaitTimeoutMs, sendBufferLimit, sendTimeLimitMs); + } + + /** + * Package-private accessor — exposes {@link #sendBufferLimit} for the + * field-injection regression unit test (§6.7b assertion (iv)). + */ + int sendBufferLimitForTest() { + return sendBufferLimit; + } + + /** + * Package-private accessor — exposes {@link #sendTimeLimitMs} for the + * field-injection regression unit test (§6.7b assertion (iv)). + */ + int sendTimeLimitMsForTest() { + return sendTimeLimitMs; + } + /** * Connect to a relay asynchronously on a Virtual Thread. * @@ -253,7 +474,7 @@ public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull Cl public List send(T eventMessage) throws IOException { String json = eventMessage.encode(); log.debug("Sending {} to relay {} (size={} bytes)", - eventMessage.getCommand(), clientSession.getUri(), json.length()); + eventMessage.getCommand(), relayUri, json.length()); return send(json); } @@ -269,18 +490,35 @@ public List send(String json) throws IOException { } request = new PendingRequest(maxEventsPerRequest); pendingRequest = request; - log.info("Sending request to relay {}: {}", clientSession.getUri(), json); - clientSession.sendMessage(new TextMessage(json)); } finally { sendLock.unlock(); } + log.info("Sending request to relay {}: {}", relayUri, json); + try { + sendFrameGated(json); + } catch (SessionLimitExceededException e) { + // OverflowStrategy.TERMINATE only sets the limitExceeded flag and throws; + // it does NOT close the delegate session. Close it explicitly (under the + // write gate, outside the read gate just released) so upstream callers' + // isOpen()==false reconnect contract holds. + clearPendingRequest(request); + closeQuietly(CloseStatus.SESSION_NOT_RELIABLE, "after overflow"); + throw new IOException("Failed to send relay payload", e); + } catch (IOException | RuntimeException e) { + // Any other send failure must also release the in-flight slot, or the next + // send() — including @NostrRetryable's own retry — hits the "request already + // in flight" guard instead of retrying. + clearPendingRequest(request); + throw e; + } + long timeout = awaitTimeoutMs > 0 ? awaitTimeoutMs : DEFAULT_AWAIT_TIMEOUT_MS; log.debug("Waiting for relay response with timeout={}ms", timeout); try { List result = request.getFuture().get(timeout, TimeUnit.MILLISECONDS); - log.info("Received {} relay events via {}", result.size(), clientSession.getUri()); + log.info("Received {} relay events via {}", result.size(), relayUri); return result; } catch (TimeoutException e) { log.error("Timed out waiting for relay response after {}ms", timeout); @@ -292,11 +530,7 @@ public List send(String json) throws IOException { } finally { sendLock.unlock(); } - try { - clientSession.close(); - } catch (IOException closeEx) { - log.warn("Error closing session after timeout", closeEx); - } + closeQuietly(CloseStatus.SESSION_NOT_RELIABLE, "after timeout"); throw new RelayTimeoutException(timeout); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -350,8 +584,10 @@ public AutoCloseable subscribe( throws IOException { String json = requestMessage.encode(); log.debug("Subscribing with {} on relay {} (size={} bytes)", - requestMessage.getCommand(), clientSession.getUri(), json.length()); - return subscribe(json, messageListener, errorListener, closeListener); + requestMessage.getCommand(), relayUri, json.length()); + String subscriptionId = + requestMessage instanceof ReqMessage req ? req.getSubscriptionId() : null; + return subscribe(json, subscriptionId, messageListener, errorListener, closeListener); } @NostrRetryable @@ -361,6 +597,18 @@ public AutoCloseable subscribe( Consumer errorListener, Runnable closeListener) throws IOException { + // No subscription id to route on: the raw JSON is not parsed here, so this + // listener keeps receiving every payload on the connection, as before. + return subscribe(requestJson, null, messageListener, errorListener, closeListener); + } + + private AutoCloseable subscribe( + String requestJson, + String subscriptionId, + Consumer messageListener, + Consumer errorListener, + Runnable closeListener) + throws IOException { Objects.requireNonNull(requestJson, "requestJson"); Objects.requireNonNull(messageListener, "messageListener"); Objects.requireNonNull(errorListener, "errorListener"); @@ -371,15 +619,21 @@ public AutoCloseable subscribe( String listenerId = UUID.randomUUID().toString(); listeners.put( listenerId, - new ListenerRegistration(messageListener, errorListener, closeListener)); + new ListenerRegistration(subscriptionId, messageListener, errorListener, closeListener)); try { - clientSession.sendMessage(new TextMessage(requestJson)); + sendFrameGated(requestJson); } catch (IOException e) { listeners.remove(listenerId); throw e; } catch (RuntimeException e) { listeners.remove(listenerId); + // OverflowStrategy.TERMINATE only sets the limitExceeded flag and throws; + // it does NOT close the delegate session. Close it explicitly here so + // upstream callers' isOpen()==false reconnect contract holds. + if (e instanceof SessionLimitExceededException) { + closeQuietly(CloseStatus.SESSION_NOT_RELIABLE, "after overflow"); + } throw new IOException("Failed to send subscription payload", e); } @@ -425,7 +679,7 @@ public CompletableFuture subscribeAsync( @Recover public List recover(IOException ex, String json) throws IOException { log.error("Failed to send message to relay {} after retries (size={} bytes)", - clientSession.getUri(), json.length(), ex); + relayUri, json.length(), ex); throw ex; } @@ -433,7 +687,7 @@ public List recover(IOException ex, String json) throws IOException { public List recover(IOException ex, BaseMessage eventMessage) throws IOException { String json = eventMessage.encode(); log.error("Failed to send {} to relay {} after retries (size={} bytes)", - eventMessage.getCommand(), clientSession.getUri(), json.length(), ex); + eventMessage.getCommand(), relayUri, json.length(), ex); throw ex; } @@ -446,7 +700,7 @@ public AutoCloseable recoverSubscription( Runnable closeListener) throws IOException { log.error("Failed to subscribe on relay {} after retries (size={} bytes)", - clientSession.getUri(), json.length(), ex); + relayUri, json.length(), ex); throw ex; } @@ -460,13 +714,56 @@ public AutoCloseable recoverSubscription( throws IOException { String json = requestMessage.encode(); log.error("Failed to subscribe with {} on relay {} after retries (size={} bytes)", - requestMessage.getCommand(), clientSession.getUri(), json.length(), ex); + requestMessage.getCommand(), relayUri, json.length(), ex); throw ex; } @Override public void close() throws IOException { - if (clientSession != null) { + closeGated(CloseStatus.NORMAL); + } + + /** + * Write one frame on the delegate while holding the READ side of the session + * gate, so it can run concurrently with other sends (the + * {@link ConcurrentWebSocketSessionDecorator} serialises the delegate writes + * among them) but never overlaps a close (which takes the WRITE side). + */ + private void sendFrameGated(String json) throws IOException { + sessionGate.readLock().lock(); + try { + // The isOpen() check MUST live inside the read lock so it is mutually + // exclusive with closeGated() (which holds the write lock). A check + // outside the lock leaves a TOCTOU window: a concurrent close() could + // close + release the session between the check and this write, letting + // the frame reach an already-closed session and trip Tomcat's + // close-mid-write race ("Concurrent write operations are not permitted"). + // Per-subscription CLOSEs issued while a relay connection is being torn + // down are the dominant trigger. (spec-026) + if (!clientSession.isOpen()) { + throw new IOException("WebSocket session is closed"); + } + clientSession.sendMessage(new TextMessage(json)); + } finally { + sessionGate.readLock().unlock(); + } + } + + /** + * Close the underlying session through {@code close(CloseStatus)} while + * holding the WRITE side of the session gate. This excludes every in-flight + * {@link #sendFrameGated} (which holds the read side), so the delegate never + * sees a CLOSE frame and a data frame at the same time. The no-arg + * {@code close()} is deliberately never used: it is not overridden by + * {@link ConcurrentWebSocketSessionDecorator} and would bypass its + * close/flush coordination entirely. + */ + private void closeGated(CloseStatus status) throws IOException { + if (clientSession == null) { + return; + } + sessionGate.writeLock().lock(); + try { boolean open = false; try { open = clientSession.isOpen(); @@ -474,8 +771,38 @@ public void close() throws IOException { log.warn("Exception while checking if clientSession is open during close()", e); } if (open) { - clientSession.close(); + clientSession.close(status); + } + } finally { + sessionGate.writeLock().unlock(); + } + } + + /** + * Best-effort {@link #closeGated} that logs rather than propagates failures — + * including the {@code SessionLimitExceededException} the decorator may raise + * while closing, so a best-effort close never masks the original timeout or + * overflow signal the caller is about to throw. + */ + private void closeQuietly(CloseStatus status, String reason) { + try { + closeGated(status); + } catch (Exception e) { + // Swallow but preserve diagnostics — pass the throwable so the stack trace + // and root cause are logged, not just the message. + log.warn("Error closing session {} on relay {}", reason, relayUri, e); + } + } + + /** Clear {@link #pendingRequest} if it still points at {@code request}. */ + private void clearPendingRequest(PendingRequest request) { + sendLock.lock(); + try { + if (pendingRequest == request) { + pendingRequest = null; } + } finally { + sendLock.unlock(); } } @@ -525,12 +852,60 @@ private static int getIntProperty(String key, int defaultValue) { return defaultValue; } + /** + * Hands a relay payload to the listeners it belongs to. + * + *

EVENT, EOSE and CLOSED carry a subscription id (NIP-01); they go only to + * the listener that opened that subscription. Everything else — NOTICE, OK, + * AUTH, and any payload whose id cannot be read — goes to every listener, as + * do listeners registered without an id (the raw-JSON + * {@link #subscribe(String, Consumer, Consumer, Runnable)} overload). + * + *

Without this routing a second REQ on the same connection sees the first + * one's events and, worse, is released by the first one's EOSE — so a query + * returns a partial result set that varies from call to call. + */ private void dispatchMessage(String payload) { + String targetSubscriptionId = subscriptionIdOf(payload); List activeListeners = List.copyOf(listeners.values()); activeListeners.forEach( - listener -> - LISTENER_EXECUTOR.execute( - () -> safelyInvoke(listener.messageListener(), payload, listener))); + listener -> { + if (targetSubscriptionId != null + && listener.subscriptionId() != null + && !targetSubscriptionId.equals(listener.subscriptionId())) { + return; + } + LISTENER_EXECUTOR.execute( + () -> safelyInvoke(listener.messageListener(), payload, listener)); + }); + } + + /** + * Reads the subscription id out of an addressed payload, or returns + * {@code null} for anything else. + * + *

Deliberately a scan rather than a JSON parse: this runs on every inbound + * frame, and the payload is parsed again by whichever listener consumes it. A + * subscription id containing a backslash escape reads as unknown and the + * payload is broadcast, which is the pre-routing behaviour — relays and + * clients use plain identifiers in practice. + */ + private static String subscriptionIdOf(String payload) { + if (payload == null + || !(payload.startsWith("[\"EVENT\"") + || payload.startsWith("[\"EOSE\"") + || payload.startsWith("[\"CLOSED\""))) { + return null; + } + int open = payload.indexOf('"', payload.indexOf(',') + 1); + if (open < 0) { + return null; + } + int close = payload.indexOf('"', open + 1); + if (close < 0 || payload.lastIndexOf('\\', close) > open) { + return null; + } + return payload.substring(open + 1, close); } private void notifyError(Throwable throwable) { @@ -621,7 +996,13 @@ private void sleepForRetry(long retryDelayMs) throws IOException { } } + /** + * A registered listener. {@code subscriptionId} is the id of the REQ this + * listener opened, or {@code null} when the caller subscribed with raw JSON — + * in which case the listener receives every payload on the connection. + */ private record ListenerRegistration( + String subscriptionId, Consumer messageListener, Consumer errorListener, Runnable closeListener) {} diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java new file mode 100644 index 000000000..eb0d61f40 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java @@ -0,0 +1,241 @@ +package nostr.client.springwebsocket; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Regression tests for the close-vs-write race (spec-026 US3). + * + *

Spring's {@link org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator} + * (Spring 6.2.x) overrides only {@code close(CloseStatus)} — guarded by a + * {@code closeLock} that is separate from the {@code flushLock} guarding + * {@code delegate.sendMessage}. It does not override the no-arg + * {@code close()}, which falls through to {@code WebSocketSessionDecorator.close()} + * → {@code delegate.close()} with no coordination at all. So a no-arg close sends a + * CLOSE frame straight to the Tomcat delegate while a {@code sendMessage} flush is in + * flight, tripping Tomcat's single-in-flight-write guard + * ({@code IllegalStateException: Concurrent write operations are not permitted}). + * + *

The fix has two parts, both asserted here: + *

    + *
  1. Routing — every close goes through {@code close(CloseStatus)}, never + * the no-arg {@code close()} (tests {@link #close_routesThroughCloseStatus_neverNoArgClose} + * and {@link #sendTimeout_routesCloseThroughCloseStatus_neverNoArgClose}).
  2. + *
  3. Serialisation — {@code subscribe()} / {@code send()} writes hold the + * READ side of the client's {@code sessionGate} ({@code ReentrantReadWriteLock}) + * and {@code close()} holds the WRITE side, so a close waits for all in-flight + * writes to drain and the delegate never sees a write and a close at once + * (test {@link #concurrentSubscribeAndClose_neverOverlapOnDelegate}).
  4. + *
+ */ +class NostrRelayClientCloseWriteRaceTest { + + private static final long TEST_AWAIT_TIMEOUT_MS = 5_000L; + private static final String REQ = "[\"REQ\",\"sub-1\",{}]"; + + // ---- Routing: close() must use close(CloseStatus), never the no-arg close() ---- + @Test + void close_routesThroughCloseStatus_neverNoArgClose() throws Exception { + AtomicBoolean isOpen = new AtomicBoolean(true); + WebSocketSession raw = Mockito.mock(WebSocketSession.class); + Mockito.when(raw.isOpen()).thenAnswer(inv -> isOpen.get()); + Mockito.doAnswer(inv -> { isOpen.set(false); return null; }) + .when(raw).close(any(CloseStatus.class)); + + NostrRelayClient client = + NostrRelayClient.forTestWithDecoratedSession(raw, TEST_AWAIT_TIMEOUT_MS); + + client.close(); + + // The decorator's close(CloseStatus) calls super.close(status) → delegate.close(status). + verify(raw, times(1)).close(any(CloseStatus.class)); + verify(raw, never()).close(); + } + + // ---- Routing: the send() timeout path must close via CloseStatus, not no-arg ---- + @Test + void sendTimeout_routesCloseThroughCloseStatus_neverNoArgClose() throws Exception { + AtomicBoolean isOpen = new AtomicBoolean(true); + WebSocketSession raw = Mockito.mock(WebSocketSession.class); + Mockito.when(raw.isOpen()).thenAnswer(inv -> isOpen.get()); + // sendMessage succeeds but no response is ever dispatched back, so send() times out. + Mockito.doNothing().when(raw).sendMessage(any(TextMessage.class)); + Mockito.doAnswer(inv -> { isOpen.set(false); return null; }) + .when(raw).close(any(CloseStatus.class)); + + // Short await timeout so the test does not block for the 5 s default. + NostrRelayClient client = + NostrRelayClient.forTestWithDecoratedSession(raw, 200L); + + assertThrows(RelayTimeoutException.class, () -> client.send(REQ)); + + verify(raw, times(1)).close(any(CloseStatus.class)); + verify(raw, never()).close(); + } + + // ---- Guard: send() on an already-closed session must fail fast without + // reaching the delegate, so it never enters Tomcat's sendText → + // doClose-mid-write → "Concurrent write operations are not permitted" + // path (the per-subscription CLOSE-on-a-dying-connection trigger). ---- + @Test + void send_onClosedSession_failsFastWithoutDelegateWrite() throws Exception { + WebSocketSession raw = Mockito.mock(WebSocketSession.class); + Mockito.when(raw.isOpen()).thenReturn(false); // session already closed/closing + + NostrRelayClient client = + NostrRelayClient.forTestWithDecoratedSession(raw, TEST_AWAIT_TIMEOUT_MS); + + IOException ex = assertThrows(IOException.class, () -> client.send(REQ)); + assertTrue(ex.getMessage().contains("closed"), + "expected a closed-session IOException, was: " + ex.getMessage()); + verify(raw, never()).sendMessage(any(TextMessage.class)); + } + + // ---- Serialisation: an in-flight subscribe write and a concurrent close + // must never overlap on the delegate. ---- + @Test + void concurrentSubscribeAndClose_neverOverlapOnDelegate() throws Exception { + AtomicBoolean isOpen = new AtomicBoolean(true); + AtomicInteger delegateOps = new AtomicInteger(); // writes + closes in flight on the delegate + AtomicInteger maxDelegateOps = new AtomicInteger(); + AtomicBoolean raceDetected = new AtomicBoolean(false); + + CountDownLatch sendInFlight = new CountDownLatch(1); // signalled while the send is parked + CountDownLatch releaseSend = new CountDownLatch(1); // test releases the parked send + + WebSocketSession raw = Mockito.mock(WebSocketSession.class); + Mockito.when(raw.isOpen()).thenAnswer(inv -> isOpen.get()); + + // First sendMessage parks in-flight (holding the decorator flushLock and this + // client's sessionGate read lock) until the test releases it; later sends do + // not park. + AtomicBoolean parkedOnce = new AtomicBoolean(false); + Answer sendAnswer = inv -> { + enter(delegateOps, maxDelegateOps, raceDetected); + try { + if (parkedOnce.compareAndSet(false, true)) { + sendInFlight.countDown(); + if (!releaseSend.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("parked send was never released"); + } + } + } finally { + delegateOps.decrementAndGet(); + } + return null; + }; + Answer closeAnswer = inv -> { + enter(delegateOps, maxDelegateOps, raceDetected); + try { + isOpen.set(false); + } finally { + delegateOps.decrementAndGet(); + } + return null; + }; + Mockito.doAnswer(sendAnswer).when(raw).sendMessage(any(TextMessage.class)); + Mockito.doAnswer(closeAnswer).when(raw).close(); + Mockito.doAnswer(closeAnswer).when(raw).close(any(CloseStatus.class)); + + NostrRelayClient client = + NostrRelayClient.forTestWithDecoratedSession(raw, TEST_AWAIT_TIMEOUT_MS); + + AtomicReference subscribeFailure = new AtomicReference<>(); + Thread subscriber = new Thread(() -> { + try { + client.subscribe(REQ, ignored -> {}, ignored -> {}, null); + } catch (Throwable t) { + subscribeFailure.set(t); + } + }, "subscriber"); + subscriber.setDaemon(true); + subscriber.start(); + + // Wait until the subscribe write is parked in flight on the delegate. + assertTrue(sendInFlight.await(5, TimeUnit.SECONDS), + "subscribe() never reached the delegate sendMessage"); + + AtomicReference closeFailure = new AtomicReference<>(); + Thread closer = new Thread(() -> { + try { + client.close(); + } catch (Throwable t) { + closeFailure.set(t); + } + }, "closer"); + closer.setDaemon(true); + closer.start(); + + // With the fix, close() blocks on the sessionGate WRITE lock, waiting for the + // READ lock held by the parked subscribe to be released, so the delegate close + // has NOT run yet — exactly one op (the write) in flight. + awaitThreadParked(closer, 2_000); + assertEquals(1, delegateOps.get(), + "close() must not reach the delegate while a send is in flight on it"); + + // Release the parked send; close can now proceed in serial order. + releaseSend.countDown(); + subscriber.join(5_000); + closer.join(5_000); + + assertFalse(raceDetected.get(), + "delegate saw a write and a close concurrently — close-vs-write race"); + assertEquals(1, maxDelegateOps.get(), + "at most one delegate write/close may be in flight at any time, was " + + maxDelegateOps.get()); + assertTrue(subscribeFailure.get() == null + || !(subscribeFailure.get() instanceof IllegalStateException), + "subscribe() raised a concurrent-write IllegalStateException: " + subscribeFailure.get()); + assertEquals(null, closeFailure.get(), + "close() failed: " + closeFailure.get()); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private static void enter( + AtomicInteger delegateOps, AtomicInteger maxDelegateOps, AtomicBoolean raceDetected) { + int now = delegateOps.incrementAndGet(); + maxDelegateOps.updateAndGet(prev -> Math.max(prev, now)); + if (now > 1) { + raceDetected.set(true); + } + } + + /** + * Poll until {@code thread} is parked (BLOCKED / WAITING / TIMED_WAITING) — i.e. + * blocked acquiring the sessionGate write lock — or the timeout elapses. + */ + private static void awaitThreadParked(Thread thread, long timeoutMs) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs); + while (System.nanoTime() < deadline) { + Thread.State s = thread.getState(); + if (s == Thread.State.BLOCKED || s == Thread.State.WAITING + || s == Thread.State.TIMED_WAITING) { + return; + } + Thread.sleep(10); + } + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientConcurrencyTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientConcurrencyTest.java new file mode 100644 index 000000000..566045ae7 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientConcurrencyTest.java @@ -0,0 +1,474 @@ +package nostr.client.springwebsocket; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; + +/** + * Concurrency regression tests for the {@code subscribe()} send-side race + * fixed by wrapping {@code clientSession} in a + * {@code ConcurrentWebSocketSessionDecorator}. + * + *

Structured as fail-first / pass-after per the spec + * acceptance criteria (§6.7): + * + *

    + *
  • (7a) Reproduction — a raw, non-decorated session demonstrates the + * {@code IllegalStateException} race that the decorator fixes. + *
  • (7b) Resolution — a decorated session passes the same harness without + * any exception, with an in-flight invariant of at most 1. + *
  • (7c) Mixed-workload — a 100 KiB {@code send()} co-existing with a + * burst of {@code subscribe()} REQs, validating the 256 KiB default + * buffer sizing. + *
  • (7d) Overflow-and-close — a tiny buffer is forced to overflow under + * indefinite-block sends, validating the {@code TERMINATE} strategy + * closes the underlying session. + *
+ * + *

Constructor-validation tests cover positive-value enforcement on the + * package-private four-arg test ctor. + */ +class NostrRelayClientConcurrencyTest { + + private static final long TEST_AWAIT_TIMEOUT_MS = 5_000L; + private static final int CONCURRENCY = 32; + private static final int CONCURRENCY_LIGHT = 20; + private static final int BLOCK_MS = 50; + private static final int RESOLUTION_DEADLINE_MS = 5_000; + private static final int MIXED_WORKLOAD_DEADLINE_MS = 10_000; + + // ---- §6.7a: reproduction step against a raw (non-decorated) session ---- + @Test + void rawSession_concurrentSubscribe_reproducesTextFullWritingRace() throws Exception { + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger maxInFlight = new AtomicInteger(); + AtomicBoolean isOpen = new AtomicBoolean(true); + WebSocketSession raw = newBlockingMockSession(inFlight, maxInFlight, isOpen, BLOCK_MS, + /* throwOnConcurrent= */ true); + + NostrRelayClient client = NostrRelayClient.forTestWithRawSession(raw, TEST_AWAIT_TIMEOUT_MS); + + List failures = runConcurrentSubscribes(client, CONCURRENCY); + + long ioWithIllegalStateCause = failures.stream() + .filter(t -> t instanceof IOException) + .filter(t -> "Failed to send subscription payload".equals(t.getMessage())) + .filter(t -> t.getCause() instanceof IllegalStateException) + .count(); + assertTrue( + ioWithIllegalStateCause >= 1, + "Expected at least one IOException(\"Failed to send subscription payload\") with cause " + + "IllegalStateException to reproduce the concurrent-send race against a raw session " + + "(observed " + ioWithIllegalStateCause + " out of " + failures.size() + " failures)"); + } + + // ---- §6.7b: resolution step against a decorated session ---- + @Test + void decoratedSession_concurrentSubscribe_serialisesAndSucceeds() throws Exception { + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger maxInFlight = new AtomicInteger(); + AtomicBoolean isOpen = new AtomicBoolean(true); + WebSocketSession raw = newBlockingMockSession(inFlight, maxInFlight, isOpen, BLOCK_MS, + /* throwOnConcurrent= */ true); + + NostrRelayClient client = + NostrRelayClient.forTestWithDecoratedSession(raw, TEST_AWAIT_TIMEOUT_MS); + + long start = System.nanoTime(); + List failures = runConcurrentSubscribes(client, CONCURRENCY); + long elapsedMs = (System.nanoTime() - start) / 1_000_000L; + + assertTrue(failures.isEmpty(), + "Expected zero failures with the decorator, observed " + failures.size() + + ": first=" + (failures.isEmpty() ? "" : failures.get(0))); + assertEquals(1, maxInFlight.get(), + "Decorator must serialise sends — maxInFlight should be exactly 1, was " + + maxInFlight.get()); + assertTrue(elapsedMs < RESOLUTION_DEADLINE_MS * 2L, + "Resolution harness exceeded " + (RESOLUTION_DEADLINE_MS * 2L) + + "ms wall-clock (was " + elapsedMs + "ms)"); + + // (iv) field-injection regression — constructor args reflected, not static defaults. + int defaultsBuffer = NostrRelayClient.forTestWithDecoratedSession( + Mockito.mock(WebSocketSession.class), TEST_AWAIT_TIMEOUT_MS).sendBufferLimitForTest(); + assertEquals(256 * 1024, defaultsBuffer, + "Two-arg test factory must use the 256 KiB default buffer"); + int customBuffer = NostrRelayClient.forTestWithDecoratedSession( + Mockito.mock(WebSocketSession.class), TEST_AWAIT_TIMEOUT_MS, 8 * 1024, 7_500) + .sendBufferLimitForTest(); + assertEquals(8 * 1024, customBuffer, + "Four-arg test factory must reflect the constructor argument, not the static default"); + int customTimeLimit = NostrRelayClient.forTestWithDecoratedSession( + Mockito.mock(WebSocketSession.class), TEST_AWAIT_TIMEOUT_MS, 8 * 1024, 7_500) + .sendTimeLimitMsForTest(); + assertEquals(7_500, customTimeLimit, + "Four-arg test factory must reflect the sendTimeLimitMs constructor argument"); + } + + // ---- §6.7c: mixed-workload (100 KiB send racing with subscribes) ---- + @Test + void decoratedSession_mixedWorkload_largeSendWithSubscribesUnderDefaultBuffer() throws Exception { + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger maxInFlight = new AtomicInteger(); + AtomicBoolean isOpen = new AtomicBoolean(true); + // Light blocking on subscribes; the large send is simulated by a separate + // 200 ms blocking branch keyed on payload size. + WebSocketSession raw = newSizeAwareMockSession(inFlight, maxInFlight, isOpen, + /* normalBlockMs= */ BLOCK_MS, /* largeBlockMs= */ 200, + /* largeThresholdBytes= */ 64 * 1024); + + NostrRelayClient client = NostrRelayClient.forTestWithDecoratedSession( + raw, TEST_AWAIT_TIMEOUT_MS, + /* sendBufferLimit= */ 256 * 1024, /* sendTimeLimitMs= */ 10_000); + + String largePayload = makePayload(100 * 1024); + int totalCalls = CONCURRENCY_LIGHT + 1; + List failures = new ArrayList<>(); + ExecutorService pool = Executors.newFixedThreadPool(totalCalls); + CountDownLatch startingGun = new CountDownLatch(1); + + long start = System.nanoTime(); + for (int i = 0; i < CONCURRENCY_LIGHT; i++) { + final int idx = i; + pool.submit(() -> { + try { + startingGun.await(); + client.subscribe("[\"REQ\",\"sub-" + idx + "\"]", + ignored -> {}, ignored -> {}, null); + } catch (Throwable t) { + synchronized (failures) { failures.add(t); } + } + }); + } + pool.submit(() -> { + try { + startingGun.await(); + // The size-aware mock branches to a 200 ms slow-flush path on this + // payload — simulating a chunked-EVENT publish racing the subscribes. + client.subscribe(largePayload, ignored -> {}, ignored -> {}, null); + } catch (Throwable t) { + synchronized (failures) { failures.add(t); } + } + }); + startingGun.countDown(); + pool.shutdown(); + boolean done = pool.awaitTermination(MIXED_WORKLOAD_DEADLINE_MS + 5_000, TimeUnit.MILLISECONDS); + long elapsedMs = (System.nanoTime() - start) / 1_000_000L; + + assertTrue(done, "Mixed-workload pool did not terminate within " + + (MIXED_WORKLOAD_DEADLINE_MS + 5_000) + "ms"); + assertTrue(failures.isEmpty(), + "Mixed workload failures: " + failures.size() + + " — first=" + (failures.isEmpty() ? "" : failures.get(0))); + assertEquals(1, maxInFlight.get(), + "Decorator must serialise the large send + 20 subscribes — maxInFlight was " + + maxInFlight.get()); + assertTrue(elapsedMs < MIXED_WORKLOAD_DEADLINE_MS, + "Mixed workload exceeded " + MIXED_WORKLOAD_DEADLINE_MS + "ms wall-clock (was " + + elapsedMs + "ms)"); + } + + // ---- §6.7d: overflow-and-close ---- + @Test + void decoratedSession_overflow_closesUnderlyingSession() throws Exception { + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger maxInFlight = new AtomicInteger(); + AtomicBoolean isOpen = new AtomicBoolean(true); + AtomicBoolean unblock = new AtomicBoolean(false); + WebSocketSession raw = newIndefinitelyBlockingMockSession(inFlight, maxInFlight, isOpen, + unblock); + + // Tiny 256-byte buffer with 1 KiB-sized subscribes — overflow guaranteed. + NostrRelayClient client = NostrRelayClient.forTestWithDecoratedSession( + raw, TEST_AWAIT_TIMEOUT_MS, + /* sendBufferLimit= */ 256, /* sendTimeLimitMs= */ 10_000); + + String oneKib = makePayload(1024); + int n = 50; + List failures = new ArrayList<>(); + ExecutorService pool = Executors.newFixedThreadPool(n); + CountDownLatch startingGun = new CountDownLatch(1); + for (int i = 0; i < n; i++) { + pool.submit(() -> { + try { + startingGun.await(); + client.subscribe(oneKib, ignored -> {}, ignored -> {}, null); + } catch (Throwable t) { + synchronized (failures) { failures.add(t); } + } + }); + } + startingGun.countDown(); + + // Wait for the decorator to overflow and close the session. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline && isOpen.get()) { + Thread.sleep(50); + } + // Release the indefinite-blockers so the pool can drain. + unblock.set(true); + synchronized (raw) { raw.notifyAll(); } + pool.shutdown(); + pool.awaitTermination(10, TimeUnit.SECONDS); + + assertTrue(failures.size() >= 1, + "Expected at least one overflow exception under TERMINATE (observed " + + failures.size() + ")"); + + // (i) Overflow exception must propagate to the caller (either as the raw + // SessionLimitExceededException or NostrRelayClient.subscribe()'s + // IOException rewrap). + boolean overflowExceptionPropagated = failures.stream().anyMatch(t -> { + String msg = t.getMessage(); + if (msg != null && msg.contains("SessionLimitExceeded")) return true; + // The wrapped form: NostrRelayClient.subscribe() catches RuntimeException + // (which SessionLimitExceededException extends) and rewraps as + // IOException("Failed to send subscription payload", e). + if (t instanceof IOException + && "Failed to send subscription payload".equals(t.getMessage()) + && t.getCause() != null + && t.getCause().getClass().getName().endsWith("SessionLimitExceededException")) { + return true; + } + return false; + }); + assertTrue(overflowExceptionPropagated, + "Expected at least one SessionLimitExceededException (or its rewrap) to propagate " + + "to the caller; observed failures: " + failures); + + // (ii) After overflow, clientSession.isOpen() must return false. Spring's + // ConcurrentWebSocketSessionDecorator under TERMINATE only sets a + // private flag and throws SessionLimitExceededException — it does NOT + // auto-close the delegate. NostrRelayClient.subscribe() compensates + // by calling clientSession.close(CloseStatus.SESSION_NOT_RELIABLE) + // explicitly when the underlying cause is SessionLimitExceededException, + // so the upstream NostrJavaRelayClient reconnect contract (isOpen()==false + // → reconnect on next call) holds. This restores the §6.7d ii contract. + assertTrue(!isOpen.get(), + "After overflow, clientSession.isOpen() must return false so the upstream " + + "reconnect contract holds (subscribe()'s catch block must explicitly " + + "close the session on SessionLimitExceededException)."); + } + + // ---- Constructor validation ---- + @Test + void packagePrivateFourArgCtor_rejectsNonPositiveBufferLimit() { + WebSocketSession s = Mockito.mock(WebSocketSession.class); + assertThrows(IllegalArgumentException.class, + () -> new NostrRelayClient(s, TEST_AWAIT_TIMEOUT_MS, 0, 1_000)); + assertThrows(IllegalArgumentException.class, + () -> new NostrRelayClient(s, TEST_AWAIT_TIMEOUT_MS, -1, 1_000)); + } + + @Test + void packagePrivateFourArgCtor_rejectsNonPositiveTimeLimit() { + WebSocketSession s = Mockito.mock(WebSocketSession.class); + assertThrows(IllegalArgumentException.class, + () -> new NostrRelayClient(s, TEST_AWAIT_TIMEOUT_MS, 1_024, 0)); + assertThrows(IllegalArgumentException.class, + () -> new NostrRelayClient(s, TEST_AWAIT_TIMEOUT_MS, 1_024, -5)); + } + + @Test + void packagePrivateFourArgCtor_rejectsNonPositiveAwaitTimeout() { + WebSocketSession s = Mockito.mock(WebSocketSession.class); + assertThrows(IllegalArgumentException.class, + () -> new NostrRelayClient(s, 0, 1_024, 1_000)); + assertThrows(IllegalArgumentException.class, + () -> new NostrRelayClient(s, -1, 1_024, 1_000)); + } + + @Test + void packagePrivateFourArgCtor_rejectsNullSession() { + assertThrows(NullPointerException.class, + () -> new NostrRelayClient((WebSocketSession) null, TEST_AWAIT_TIMEOUT_MS, 1_024, 1_000)); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Build a mock session whose {@code sendMessage} blocks for {@code blockMs} + * milliseconds while incrementing an in-flight counter. If + * {@code throwOnConcurrent} is set, on entry to {@code sendMessage} when the + * counter rises above 1 the mock throws + * {@code IllegalStateException("simulated TEXT_FULL_WRITING")} — emulating + * Tomcat / Jetty's real-world thread-safety violation. + */ + private static WebSocketSession newBlockingMockSession( + AtomicInteger inFlight, + AtomicInteger maxInFlight, + AtomicBoolean isOpen, + int blockMs, + boolean throwOnConcurrent) { + WebSocketSession session = Mockito.mock(WebSocketSession.class); + Mockito.when(session.isOpen()).thenAnswer(inv -> isOpen.get()); + Answer sendAnswer = invocation -> { + int now = inFlight.incrementAndGet(); + try { + maxInFlight.updateAndGet(prev -> Math.max(prev, now)); + if (throwOnConcurrent && now > 1) { + throw new IllegalStateException("simulated TEXT_FULL_WRITING"); + } + if (blockMs > 0) { + Thread.sleep(blockMs); + } + } finally { + inFlight.decrementAndGet(); + } + return null; + }; + try { + Mockito.doAnswer(sendAnswer).when(session).sendMessage(any(TextMessage.class)); + } catch (IOException impossible) { + throw new AssertionError(impossible); + } + return session; + } + + /** + * Build a mock whose {@code sendMessage} blocks indefinitely (for the + * §6.7d overflow test). The {@code unblock} flag releases pending sends. + * Crucially, when the test calls + * {@link WebSocketSession#close(CloseStatus)} (which the decorator does on + * TERMINATE), {@code isOpen()} pivots to false. + */ + private static WebSocketSession newIndefinitelyBlockingMockSession( + AtomicInteger inFlight, + AtomicInteger maxInFlight, + AtomicBoolean isOpen, + AtomicBoolean unblock) { + WebSocketSession session = Mockito.mock(WebSocketSession.class); + Mockito.when(session.isOpen()).thenAnswer(inv -> isOpen.get()); + Answer closeAnswer = invocation -> { + isOpen.set(false); + synchronized (session) { session.notifyAll(); } + return null; + }; + Answer sendAnswer = invocation -> { + int now = inFlight.incrementAndGet(); + try { + maxInFlight.updateAndGet(prev -> Math.max(prev, now)); + synchronized (session) { + while (!unblock.get() && isOpen.get()) { + session.wait(60_000); + // Loop guard against spurious wakeups; one iteration is enough — we + // either get notified by close()/notifyAll() or by the test's + // unblock-pivot. + break; + } + } + if (!isOpen.get()) { + throw new IOException("simulated session-closed mid-send"); + } + } finally { + inFlight.decrementAndGet(); + } + return null; + }; + try { + Mockito.doAnswer(sendAnswer).when(session).sendMessage(any(TextMessage.class)); + Mockito.doAnswer(closeAnswer).when(session).close(); + Mockito.doAnswer(closeAnswer).when(session).close(any(CloseStatus.class)); + } catch (IOException impossible) { + throw new AssertionError(impossible); + } + return session; + } + + /** + * Build a mock whose {@code sendMessage} blocks for {@code largeBlockMs} + * if the payload exceeds {@code largeThresholdBytes}, otherwise for + * {@code normalBlockMs}. Used by the §6.7c mixed-workload test. + */ + private static WebSocketSession newSizeAwareMockSession( + AtomicInteger inFlight, + AtomicInteger maxInFlight, + AtomicBoolean isOpen, + int normalBlockMs, + int largeBlockMs, + int largeThresholdBytes) { + WebSocketSession session = Mockito.mock(WebSocketSession.class); + Mockito.when(session.isOpen()).thenAnswer(inv -> isOpen.get()); + Answer sendAnswer = (InvocationOnMock invocation) -> { + int now = inFlight.incrementAndGet(); + try { + maxInFlight.updateAndGet(prev -> Math.max(prev, now)); + TextMessage msg = invocation.getArgument(0); + int sleepMs = msg.getPayloadLength() >= largeThresholdBytes + ? largeBlockMs : normalBlockMs; + Thread.sleep(sleepMs); + } finally { + inFlight.decrementAndGet(); + } + return null; + }; + try { + Mockito.doAnswer(sendAnswer).when(session).sendMessage(any(TextMessage.class)); + } catch (IOException impossible) { + throw new AssertionError(impossible); + } + return session; + } + + /** + * Run {@code n} concurrent {@code subscribe()} calls against the supplied + * client, all released by a single starting-gun, and return the list of + * exceptions raised. Caller must inspect the list — the worker pool is + * always awaited (15 s deadline) before this method returns. + */ + private static List runConcurrentSubscribes( + NostrRelayClient client, int n) throws InterruptedException { + List failures = new ArrayList<>(); + ExecutorService pool = Executors.newFixedThreadPool(n); + CountDownLatch startingGun = new CountDownLatch(1); + for (int i = 0; i < n; i++) { + final int idx = i; + pool.submit(() -> { + try { + startingGun.await(); + client.subscribe("[\"REQ\",\"sub-" + idx + "\"]", + ignored -> {}, ignored -> {}, null); + } catch (Throwable t) { + synchronized (failures) { failures.add(t); } + } + }); + } + startingGun.countDown(); + pool.shutdown(); + boolean done = pool.awaitTermination(15, TimeUnit.SECONDS); + assertTrue(done, "Worker pool did not terminate within 15 s " + + "(failures so far: " + failures.size() + ")"); + return failures; + } + + private static String makePayload(int sizeBytes) { + StringBuilder sb = new StringBuilder(sizeBytes + 32); + sb.append("[\"REQ\",\"x\",\""); + while (sb.length() < sizeBytes - 2) { + sb.append('a'); + } + sb.append("\"]"); + return sb.toString(); + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientSubscriptionRoutingTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientSubscriptionRoutingTest.java new file mode 100644 index 000000000..f71e71e39 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientSubscriptionRoutingTest.java @@ -0,0 +1,76 @@ +package nostr.client.springwebsocket; + +import nostr.event.message.ReqMessage; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A payload addressed to one subscription must not reach another. + * + *

Listeners are registered per connection, so before routing every REQ saw + * every other REQ's EVENT and EOSE frames. A caller that ends its query on EOSE + * — which is every caller — was therefore released by somebody else's EOSE and + * returned however many events had happened to arrive by then. + */ +class NostrRelayClientSubscriptionRoutingTest { + + private static final String EVENT_A = + "[\"EVENT\",\"sub-a\",{\"id\":\"a1\",\"kind\":1,\"content\":\"for-a\"}]"; + private static final String EVENT_B = + "[\"EVENT\",\"sub-b\",{\"id\":\"b1\",\"kind\":1,\"content\":\"for-b\"}]"; + private static final String EOSE_B = "[\"EOSE\",\"sub-b\"]"; + private static final String NOTICE = "[\"NOTICE\",\"relay is restarting\"]"; + + @Test + void payloadsGoOnlyToTheSubscriptionTheyAddress() throws Exception { + WebSocketSession session = Mockito.mock(WebSocketSession.class); + Mockito.when(session.isOpen()).thenReturn(true); + + try (NostrRelayClient client = new NostrRelayClient(session, 1_000)) { + List seenByA = new CopyOnWriteArrayList<>(); + List seenByB = new CopyOnWriteArrayList<>(); + List seenByRaw = new CopyOnWriteArrayList<>(); + AtomicBoolean errored = new AtomicBoolean(false); + + client.subscribe(new ReqMessage("sub-a"), seenByA::add, t -> errored.set(true), null); + client.subscribe(new ReqMessage("sub-b"), seenByB::add, t -> errored.set(true), null); + // Subscribed with raw JSON: no id was parsed, so this one still sees the + // whole connection — the pre-routing contract. + client.subscribe("[\"REQ\",\"sub-raw\"]", seenByRaw::add, t -> errored.set(true), null); + + client.handleTextMessage(session, new TextMessage(EVENT_B)); + client.handleTextMessage(session, new TextMessage(EOSE_B)); + client.handleTextMessage(session, new TextMessage(EVENT_A)); + client.handleTextMessage(session, new TextMessage(NOTICE)); + + // A gets its own EVENT and the connection-wide NOTICE, and nothing of B's. + await().atMost(2, TimeUnit.SECONDS).until(() -> seenByA.size() >= 2); + await().atMost(2, TimeUnit.SECONDS).until(() -> seenByB.size() >= 3); + await().atMost(2, TimeUnit.SECONDS).until(() -> seenByRaw.size() >= 4); + + // Compared as sets: each payload is dispatched on its own executor task, + // so delivery order across payloads is not part of the contract. + assertEquals(Set.of(EVENT_A, NOTICE), Set.copyOf(seenByA), + "sub-a was handed sub-b's traffic — a foreign EOSE ends this caller's query early"); + assertEquals(2, seenByA.size()); + assertEquals(Set.of(EVENT_B, EOSE_B, NOTICE), Set.copyOf(seenByB)); + assertEquals(3, seenByB.size()); + assertEquals(Set.of(EVENT_B, EOSE_B, EVENT_A, NOTICE), Set.copyOf(seenByRaw), + "a listener registered without a subscription id must still see everything"); + assertEquals(4, seenByRaw.size()); + assertTrue(!errored.get(), "no listener should have errored"); + } + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientTimeoutTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientTimeoutTest.java index 12f85d724..21778e9f0 100644 --- a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientTimeoutTest.java +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientTimeoutTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; @@ -12,10 +13,17 @@ public class NostrRelayClientTimeoutTest { @Test public void testTimeoutThrowsRelayTimeoutExceptionAndClosesSession() throws Exception { WebSocketSession session = Mockito.mock(WebSocketSession.class); + // The session is still open when the response times out, so the timeout + // path proceeds to close it. + Mockito.when(session.isOpen()).thenReturn(true); try (NostrRelayClient client = new NostrRelayClient(session, 100)) { assertThrows(RelayTimeoutException.class, () -> client.send("test")); } Mockito.verify(session).sendMessage(Mockito.any(TextMessage.class)); - Mockito.verify(session).close(); + // The fix routes every close through close(CloseStatus) — never the no-arg + // close(), which the ConcurrentWebSocketSessionDecorator does not override + // and which would bypass its close/flush coordination (spec-026 US3). + Mockito.verify(session, Mockito.atLeastOnce()).close(Mockito.any(CloseStatus.class)); + Mockito.verify(session, Mockito.never()).close(); } } diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientTest.java index 4fe48ea1c..7281af303 100644 --- a/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientTest.java +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; @@ -64,7 +65,11 @@ void sendThrowsRelayTimeoutExceptionOnTimeout() throws Exception { assertThrows(RelayTimeoutException.class, () -> client.send("test")); } Mockito.verify(session).sendMessage(any(TextMessage.class)); - Mockito.verify(session, Mockito.atLeastOnce()).close(); + // The fix routes every close through close(CloseStatus) — never the no-arg + // close(), which the ConcurrentWebSocketSessionDecorator does not override + // and which would bypass its close/flush coordination (spec-026 US3). + Mockito.verify(session, Mockito.atLeastOnce()).close(any(CloseStatus.class)); + Mockito.verify(session, Mockito.never()).close(); } // Verifies subscription callbacks are delivered asynchronously and stop after unsubscribe. diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index 2305d2fd6..0b2d45db8 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.0 + 2.0.8 ../pom.xml diff --git a/nostr-java-core/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java b/nostr-java-core/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java index 833391ec3..4ba70e21d 100644 --- a/nostr-java-core/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java +++ b/nostr-java-core/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java @@ -1,17 +1,18 @@ package nostr.crypto.nip44; -import javax.crypto.Cipher; import javax.crypto.Mac; -import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.engines.ChaCha7539Engine; import org.bouncycastle.crypto.generators.HKDFBytesGenerator; import org.bouncycastle.crypto.params.ECDomainParameters; import org.bouncycastle.crypto.params.ECPrivateKeyParameters; import org.bouncycastle.crypto.params.ECPublicKeyParameters; import org.bouncycastle.crypto.params.HKDFParameters; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.ParametersWithIV; import org.bouncycastle.jce.ECNamedCurveTable; import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; import org.bouncycastle.math.ec.ECPoint; @@ -38,12 +39,7 @@ public static String encrypt(String plaintext, byte[] conversationKey, byte[] no byte[] padded = EncryptedPayloads.pad(plaintext); - Cipher cipher = Cipher.getInstance(Constants.ENCRYPTION_ALGORITHM); - cipher.init( - Cipher.ENCRYPT_MODE, - new SecretKeySpec(chachaKey, Constants.ENCRYPTION_ALGORITHM), - new IvParameterSpec(chachaNonce)); - byte[] ciphertext = cipher.doFinal(padded); + byte[] ciphertext = chacha20(chachaKey, chachaNonce, padded); Mac mac = Mac.getInstance(Constants.HMAC_ALGORITHM); mac.init(new SecretKeySpec(hmacKey, Constants.HMAC_ALGORITHM)); @@ -85,16 +81,33 @@ public static String decrypt(String payload, byte[] conversationKey) throws Exce throw new Exception("Invalid MAC"); } - Cipher cipher = Cipher.getInstance(Constants.ENCRYPTION_ALGORITHM); - cipher.init( - Cipher.DECRYPT_MODE, - new SecretKeySpec(chachaKey, Constants.ENCRYPTION_ALGORITHM), - new IvParameterSpec(chachaNonce)); - byte[] paddedPlaintext = cipher.doFinal(ciphertext); + byte[] paddedPlaintext = chacha20(chachaKey, chachaNonce, ciphertext); return EncryptedPayloads.unpad(paddedPlaintext); } + /** + * ChaCha20 (RFC 7539, 12-byte nonce, counter starting at 0) over BouncyCastle's + * lightweight API. A stream cipher, so this both encrypts and decrypts. + * + *

Deliberately not {@code Cipher.getInstance("ChaCha20")}: that resolves to + * whichever JCE provider happens to be registered. SunJCE's implementation + * rejects an {@code IvParameterSpec} and demands a {@code ChaCha20ParameterSpec}, + * so NIP-44 worked only in a JVM where BouncyCastle had already been registered + * as a provider — which used to happen as a side effect of + * {@code Schnorr.generatePrivateKey()}. On Android it could not work at all: + * {@code Security.addProvider} for the name "BC" is a no-op there, because the + * platform ships its own repackaged BouncyCastle under that name. The + * lightweight API needs no provider lookup and behaves identically everywhere. + */ + private static byte[] chacha20(byte[] key, byte[] nonce, byte[] input) { + ChaCha7539Engine engine = new ChaCha7539Engine(); + engine.init(true, new ParametersWithIV(new KeyParameter(key), nonce)); + byte[] output = new byte[input.length]; + engine.processBytes(input, 0, input.length, output, 0); + return output; + } + public static byte[] getConversationKey(String privkeyA, String pubkeyB) { // Get the ECNamedCurveParameterSpec for the secp256k1 curve ECNamedCurveParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1"); @@ -273,7 +286,6 @@ private static byte[] concat(byte[]... arrays) { private static class Constants { public static final int MIN_PLAINTEXT_SIZE = 1; public static final int MAX_PLAINTEXT_SIZE = 65535; - private static final String ENCRYPTION_ALGORITHM = "ChaCha20"; private static final String HMAC_ALGORITHM = "HmacSHA256"; private static final int CONVERSATION_KEY_LENGTH = 32; private static final int NONCE_LENGTH = 32; diff --git a/nostr-java-core/src/main/java/nostr/crypto/schnorr/Schnorr.java b/nostr-java-core/src/main/java/nostr/crypto/schnorr/Schnorr.java index 8e1ebbc71..e94879aef 100644 --- a/nostr-java-core/src/main/java/nostr/crypto/schnorr/Schnorr.java +++ b/nostr-java-core/src/main/java/nostr/crypto/schnorr/Schnorr.java @@ -2,18 +2,16 @@ import nostr.crypto.Point; import nostr.util.NostrUtil; -import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.crypto.generators.ECKeyPairGenerator; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.crypto.params.ECKeyGenerationParameters; +import org.bouncycastle.crypto.params.ECPrivateKeyParameters; +import org.bouncycastle.jce.ECNamedCurveTable; +import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; import java.math.BigInteger; -import java.security.InvalidAlgorithmParameterException; -import java.security.KeyPair; -import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; import java.security.SecureRandom; -import java.security.Security; -import java.security.interfaces.ECPrivateKey; -import java.security.spec.ECGenParameterSpec; import java.util.Arrays; /** @@ -140,20 +138,29 @@ public static boolean verify(byte[] msg, byte[] pubkey, byte[] sig) throws Schno /** * Generate a random private key suitable for secp256k1. * + *

Uses BouncyCastle's lightweight API rather than a JCE provider lookup. The + * old implementation called {@code Security.addProvider(new BouncyCastleProvider())} + * here and then asked for {@code KeyPairGenerator.getInstance("ECDSA", "BC")} — + * which made every other BouncyCastle-dependent code path in the library work only + * once a key had been generated first. NIP-44 was the visible casualty; see + * {@link nostr.crypto.nip44.EncryptedPayloads}. Registering a provider is a global, + * process-wide side effect and is the caller's business, not a key generator's. + * * @return a 32-byte private key */ public static byte[] generatePrivateKey() { try { - Security.addProvider(new BouncyCastleProvider()); - KeyPairGenerator kpg = KeyPairGenerator.getInstance("ECDSA", "BC"); - kpg.initialize(new ECGenParameterSpec("secp256k1"), SecureRandom.getInstanceStrong()); - KeyPair processorKeyPair = kpg.genKeyPair(); + ECNamedCurveParameterSpec curve = ECNamedCurveTable.getParameterSpec("secp256k1"); + ECDomainParameters domain = + new ECDomainParameters(curve.getCurve(), curve.getG(), curve.getN(), curve.getH()); + ECKeyPairGenerator generator = new ECKeyPairGenerator(); + generator.init(new ECKeyGenerationParameters(domain, SecureRandom.getInstanceStrong())); + ECPrivateKeyParameters privateKey = + (ECPrivateKeyParameters) generator.generateKeyPair().getPrivate(); - return NostrUtil.bytesFromBigInteger(((ECPrivateKey) processorKeyPair.getPrivate()).getS()); + return NostrUtil.bytesFromBigInteger(privateKey.getD()); - } catch (InvalidAlgorithmParameterException - | NoSuchAlgorithmException - | NoSuchProviderException e) { + } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } diff --git a/nostr-java-core/src/test/java/nostr/crypto/nip44/EncryptedPayloadsTest.java b/nostr-java-core/src/test/java/nostr/crypto/nip44/EncryptedPayloadsTest.java new file mode 100644 index 000000000..cdda16389 --- /dev/null +++ b/nostr-java-core/src/test/java/nostr/crypto/nip44/EncryptedPayloadsTest.java @@ -0,0 +1,141 @@ +package nostr.crypto.nip44; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import java.security.Provider; +import java.security.Security; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * NIP-44 v2 against the specification's own vectors. + * + *

The fixture is {@code nip44.vectors.json}, taken verbatim from the reference + * implementation at https://github.com/paulmillr/nip44 — the same file every other + * NIP-44 library is checked against. + * + *

The whole class runs with the BouncyCastle JCE provider de-registered, and + * that is the point of it. NIP-44 used to reach for {@code Cipher.getInstance("ChaCha20")} + * with an {@code IvParameterSpec}, which only BouncyCastle's provider accepts; SunJCE + * demands a {@code ChaCha20ParameterSpec} and throws. So encryption worked only in a JVM + * where something had already called {@code Security.addProvider(new BouncyCastleProvider())} + * — in practice {@code Schnorr.generatePrivateKey()}, as a side effect. Any test that + * generated a key first passed while the library was broken for everyone who loaded a key + * from storage, and broken on Android outright (there, adding a provider named "BC" is a + * no-op, because the platform already owns that name). Removing the provider here is what + * keeps that from coming back. + */ +class EncryptedPayloadsTest { + + private static JsonNode vectors; + private static Provider removedBouncyCastle; + + @BeforeAll + static void loadVectorsAndRemoveBouncyCastleProvider() throws Exception { + try (InputStream in = + EncryptedPayloadsTest.class.getResourceAsStream("/nip44.vectors.json")) { + vectors = new ObjectMapper().readTree(in).get("v2").get("valid"); + } + removedBouncyCastle = Security.getProvider("BC"); + Security.removeProvider("BC"); + } + + @AfterAll + static void restoreBouncyCastleProvider() { + if (removedBouncyCastle != null) { + Security.addProvider(removedBouncyCastle); + } + } + + /** + * The vectors carry {@code pub2} as a nostr x-only public key; {@code getConversationKey} + * wants a compressed SEC1 point, so the "02" prefix goes on here exactly as + * {@code MessageCipher44} puts it on. + */ + @ParameterizedTest(name = "conversation key [{index}]") + @MethodSource("conversationKeyVectors") + void getConversationKeyMatchesTheSpecVectors(String sec1, String pub2, String expected) { + assertArrayEquals( + EncryptedPayloads.hexStringToByteArray(expected), + EncryptedPayloads.getConversationKey(sec1, "02" + pub2)); + } + + @ParameterizedTest(name = "encrypt [{index}]") + @MethodSource("encryptDecryptVectors") + void encryptMatchesTheSpecVectors( + String conversationKey, String nonce, String plaintext, String payload) throws Exception { + assertEquals( + payload, + EncryptedPayloads.encrypt( + plaintext, + EncryptedPayloads.hexStringToByteArray(conversationKey), + EncryptedPayloads.hexStringToByteArray(nonce))); + } + + @ParameterizedTest(name = "decrypt [{index}]") + @MethodSource("encryptDecryptVectors") + void decryptMatchesTheSpecVectors( + String conversationKey, String nonce, String plaintext, String payload) throws Exception { + assertEquals( + plaintext, + EncryptedPayloads.decrypt( + payload, EncryptedPayloads.hexStringToByteArray(conversationKey))); + } + + /** + * The end-to-end shape a caller actually uses: derive a conversation key from a keypair + * and round-trip a message. Distinct from the vector tests above in that it starts from + * keys rather than from a conversation key — the path that used to need a private key to + * have been generated in this JVM first. + */ + @Test + void roundTripsWithoutTheBouncyCastleProviderRegistered() throws Exception { + JsonNode vector = vectors.get("encrypt_decrypt").get(0); + byte[] conversationKey = + EncryptedPayloads.hexStringToByteArray(vector.get("conversation_key").asText()); + byte[] nonce = EncryptedPayloads.hexStringToByteArray(vector.get("nonce").asText()); + + String payload = EncryptedPayloads.encrypt("hello nip-44", conversationKey, nonce); + + assertEquals("hello nip-44", EncryptedPayloads.decrypt(payload, conversationKey)); + } + + static List conversationKeyVectors() { + List arguments = new ArrayList<>(); + vectors + .get("get_conversation_key") + .forEach( + vector -> + arguments.add( + Arguments.of( + vector.get("sec1").asText(), + vector.get("pub2").asText(), + vector.get("conversation_key").asText()))); + return arguments; + } + + static List encryptDecryptVectors() { + List arguments = new ArrayList<>(); + vectors + .get("encrypt_decrypt") + .forEach( + vector -> + arguments.add( + Arguments.of( + vector.get("conversation_key").asText(), + vector.get("nonce").asText(), + vector.get("plaintext").asText(), + vector.get("payload").asText()))); + return arguments; + } +} diff --git a/nostr-java-core/src/test/resources/nip44.vectors.json b/nostr-java-core/src/test/resources/nip44.vectors.json new file mode 100644 index 000000000..8be2b7b55 --- /dev/null +++ b/nostr-java-core/src/test/resources/nip44.vectors.json @@ -0,0 +1,648 @@ +{ + "v2": { + "valid": { + "get_conversation_key": [ + { + "sec1": "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268", + "pub2": "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133", + "conversation_key": "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1" + }, + { + "sec1": "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e", + "pub2": "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800", + "conversation_key": "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b" + }, + { + "sec1": "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311", + "pub2": "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1", + "conversation_key": "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7" + }, + { + "sec1": "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f", + "pub2": "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585", + "conversation_key": "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba" + }, + { + "sec1": "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34", + "pub2": "f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60", + "conversation_key": "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442" + }, + { + "sec1": "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2", + "pub2": "74d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65", + "conversation_key": "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d" + }, + { + "sec1": "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082", + "pub2": "bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b", + "conversation_key": "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db" + }, + { + "sec1": "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1", + "pub2": "1ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de", + "conversation_key": "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf" + }, + { + "sec1": "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6", + "pub2": "31990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0", + "conversation_key": "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e" + }, + { + "sec1": "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64", + "pub2": "24383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065", + "conversation_key": "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1" + }, + { + "sec1": "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af", + "pub2": "eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e", + "conversation_key": "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6" + }, + { + "sec1": "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9", + "pub2": "eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92", + "conversation_key": "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795" + }, + { + "sec1": "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a", + "pub2": "50e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4", + "conversation_key": "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946" + }, + { + "sec1": "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a", + "pub2": "b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b", + "conversation_key": "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d" + }, + { + "sec1": "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358", + "pub2": "2a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c", + "conversation_key": "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92" + }, + { + "sec1": "6636e8a389f75fe068a03b3edb3ea4a785e2768e3f73f48ffb1fc5e7cb7289dc", + "pub2": "514eb2064224b6a5829ea21b6e8f7d3ea15ff8e70e8555010f649eb6e09aec70", + "conversation_key": "ff7afacd4d1a6856d37ca5b546890e46e922b508639214991cf8048ddbe9745c" + }, + { + "sec1": "94b212f02a3cfb8ad147d52941d3f1dbe1753804458e6645af92c7b2ea791caa", + "pub2": "f0cac333231367a04b652a77ab4f8d658b94e86b5a8a0c472c5c7b0d4c6a40cc", + "conversation_key": "e292eaf873addfed0a457c6bd16c8effde33d6664265697f69f420ab16f6669b" + }, + { + "sec1": "aa61f9734e69ae88e5d4ced5aae881c96f0d7f16cca603d3bed9eec391136da6", + "pub2": "4303e5360a884c360221de8606b72dd316da49a37fe51e17ada4f35f671620a6", + "conversation_key": "8e7d44fd4767456df1fb61f134092a52fcd6836ebab3b00766e16732683ed848" + }, + { + "sec1": "5e914bdac54f3f8e2cba94ee898b33240019297b69e96e70c8a495943a72fc98", + "pub2": "5bd097924f606695c59f18ff8fd53c174adbafaaa71b3c0b4144a3e0a474b198", + "conversation_key": "f5a0aecf2984bf923c8cd5e7bb8be262d1a8353cb93959434b943a07cf5644bc" + }, + { + "sec1": "8b275067add6312ddee064bcdbeb9d17e88aa1df36f430b2cea5cc0413d8278a", + "pub2": "65bbbfca819c90c7579f7a82b750a18c858db1afbec8f35b3c1e0e7b5588e9b8", + "conversation_key": "2c565e7027eb46038c2263563d7af681697107e975e9914b799d425effd248d6" + }, + { + "sec1": "1ac848de312285f85e0f7ec208aac20142a1f453402af9b34ec2ec7a1f9c96fc", + "pub2": "45f7318fe96034d23ee3ddc25b77f275cc1dd329664dd51b89f89c4963868e41", + "conversation_key": "b56e970e5057a8fd929f8aad9248176b9af87819a708d9ddd56e41d1aec74088" + }, + { + "sec1": "295a1cf621de401783d29d0e89036aa1c62d13d9ad307161b4ceb535ba1b40e6", + "pub2": "840115ddc7f1034d3b21d8e2103f6cb5ab0b63cf613f4ea6e61ae3d016715cdd", + "conversation_key": "b4ee9c0b9b9fef88975773394f0a6f981ca016076143a1bb575b9ff46e804753" + }, + { + "sec1": "a28eed0fe977893856ab9667e06ace39f03abbcdb845c329a1981be438ba565d", + "pub2": "b0f38b950a5013eba5ab4237f9ed29204a59f3625c71b7e210fec565edfa288c", + "conversation_key": "9d3a802b45bc5aeeb3b303e8e18a92ddd353375710a31600d7f5fff8f3a7285b" + }, + { + "sec1": "7ab65af72a478c05f5c651bdc4876c74b63d20d04cdbf71741e46978797cd5a4", + "pub2": "f1112159161b568a9cb8c9dd6430b526c4204bcc8ce07464b0845b04c041beda", + "conversation_key": "943884cddaca5a3fef355e9e7f08a3019b0b66aa63ec90278b0f9fdb64821e79" + }, + { + "sec1": "95c79a7b75ba40f2229e85756884c138916f9d103fc8f18acc0877a7cceac9fe", + "pub2": "cad76bcbd31ca7bbda184d20cc42f725ed0bb105b13580c41330e03023f0ffb3", + "conversation_key": "81c0832a669eea13b4247c40be51ccfd15bb63fcd1bba5b4530ce0e2632f301b" + }, + { + "sec1": "baf55cc2febd4d980b4b393972dfc1acf49541e336b56d33d429bce44fa12ec9", + "pub2": "0c31cf87fe565766089b64b39460ebbfdedd4a2bc8379be73ad3c0718c912e18", + "conversation_key": "37e2344da9ecdf60ae2205d81e89d34b280b0a3f111171af7e4391ded93b8ea6" + }, + { + "sec1": "6eeec45acd2ed31693c5256026abf9f072f01c4abb61f51cf64e6956b6dc8907", + "pub2": "e501b34ed11f13d816748c0369b0c728e540df3755bab59ed3327339e16ff828", + "conversation_key": "afaa141b522ddb27bb880d768903a7f618bb8b6357728cae7fb03af639b946e6" + }, + { + "sec1": "261a076a9702af1647fb343c55b3f9a4f1096273002287df0015ba81ce5294df", + "pub2": "b2777c863878893ae100fb740c8fab4bebd2bf7be78c761a75593670380a6112", + "conversation_key": "76f8d2853de0734e51189ced523c09427c3e46338b9522cd6f74ef5e5b475c74" + }, + { + "sec1": "ed3ec71ca406552ea41faec53e19f44b8f90575eda4b7e96380f9cc73c26d6f3", + "pub2": "86425951e61f94b62e20cae24184b42e8e17afcf55bafa58645efd0172624fae", + "conversation_key": "f7ffc520a3a0e9e9b3c0967325c9bf12707f8e7a03f28b6cd69ae92cf33f7036" + }, + { + "sec1": "5a788fc43378d1303ac78639c59a58cb88b08b3859df33193e63a5a3801c722e", + "pub2": "a8cba2f87657d229db69bee07850fd6f7a2ed070171a06d006ec3a8ac562cf70", + "conversation_key": "7d705a27feeedf78b5c07283362f8e361760d3e9f78adab83e3ae5ce7aeb6409" + }, + { + "sec1": "63bffa986e382b0ac8ccc1aa93d18a7aa445116478be6f2453bad1f2d3af2344", + "pub2": "b895c70a83e782c1cf84af558d1038e6b211c6f84ede60408f519a293201031d", + "conversation_key": "3a3b8f00d4987fc6711d9be64d9c59cf9a709c6c6481c2cde404bcc7a28f174e" + }, + { + "sec1": "e4a8bcacbf445fd3721792b939ff58e691cdcba6a8ba67ac3467b45567a03e5c", + "pub2": "b54053189e8c9252c6950059c783edb10675d06d20c7b342f73ec9fa6ed39c9d", + "conversation_key": "7b3933b4ef8189d347169c7955589fc1cfc01da5239591a08a183ff6694c44ad" + }, + { + "sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139", + "pub2": "0000000000000000000000000000000000000000000000000000000000000002", + "conversation_key": "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba", + "note": "sec1 = n-2, pub2: random, 0x02" + }, + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000002", + "pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb", + "conversation_key": "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43", + "note": "sec1 = 2, pub2: rand" + }, + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000001", + "pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "conversation_key": "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e", + "note": "sec1 == pub2" + } + ], + "get_message_keys": { + "conversation_key": "a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54", + "keys": [ + { + "nonce": "e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72", + "chacha_key": "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76", + "chacha_nonce": "c4ad129bb01180c0933a160c", + "hmac_key": "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4" + }, + { + "nonce": "e1d6d28c46de60168b43d79dacc519698512ec35e8ccb12640fc8e9f26121101", + "chacha_key": "e35b88f8d4a8f1606c5082f7a64b100e5d85fcdb2e62aeafbec03fb9e860ad92", + "chacha_nonce": "22925e920cee4a50a478be90", + "hmac_key": "46a7c55d4283cb0df1d5e29540be67abfe709e3b2e14b7bf9976e6df994ded30" + }, + { + "nonce": "cfc13bef512ac9c15951ab00030dfaf2626fdca638dedb35f2993a9eeb85d650", + "chacha_key": "020783eb35fdf5b80ef8c75377f4e937efb26bcbad0e61b4190e39939860c4bf", + "chacha_nonce": "d3594987af769a52904656ac", + "hmac_key": "237ec0ccb6ebd53d179fa8fd319e092acff599ef174c1fdafd499ef2b8dee745" + }, + { + "nonce": "ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967", + "chacha_key": "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be", + "chacha_nonce": "50bb859aa2dde938cc49ec7a", + "hmac_key": "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6" + }, + { + "nonce": "8c2e1dd3792802f1f9f7842e0323e5d52ad7472daf360f26e15f97290173605d", + "chacha_key": "2f9daeda8683fdeede81adac247c63cc7671fa817a1fd47352e95d9487989d8b", + "chacha_nonce": "400224ba67fc2f1b76736916", + "hmac_key": "465c05302aeeb514e41c13ed6405297e261048cfb75a6f851ffa5b445b746e4b" + }, + { + "nonce": "05c28bf3d834fa4af8143bf5201a856fa5fac1a3aee58f4c93a764fc2f722367", + "chacha_key": "1e3d45777025a035be566d80fd580def73ed6f7c043faec2c8c1c690ad31c110", + "chacha_nonce": "021905b1ea3afc17cb9bf96f", + "hmac_key": "74a6e481a89dcd130aaeb21060d7ec97ad30f0007d2cae7b1b11256cc70dfb81" + }, + { + "nonce": "5e043fb153227866e75a06d60185851bc90273bfb93342f6632a728e18a07a17", + "chacha_key": "1ea72c9293841e7737c71567d8120145a58991aaa1c436ef77bf7adb83f882f1", + "chacha_nonce": "72f69a5a5f795465cee59da8", + "hmac_key": "e9daa1a1e9a266ecaa14e970a84bce3fbbf329079bbccda626582b4e66a0d4c9" + }, + { + "nonce": "7be7338eaf06a87e274244847fe7a97f5c6a91f44adc18fcc3e411ad6f786dbf", + "chacha_key": "881e7968a1f0c2c80742ee03cd49ea587e13f22699730f1075ade01931582bf6", + "chacha_nonce": "6e69be92d61c04a276021565", + "hmac_key": "901afe79e74b19967c8829af23617d7d0ffbf1b57190c096855c6a03523a971b" + }, + { + "nonce": "94571c8d590905bad7becd892832b472f2aa5212894b6ce96e5ba719c178d976", + "chacha_key": "f80873dd48466cb12d46364a97b8705c01b9b4230cb3ec3415a6b9551dc42eef", + "chacha_nonce": "3dda53569cfcb7fac1805c35", + "hmac_key": "e9fc264345e2839a181affebc27d2f528756e66a5f87b04bf6c5f1997047051e" + }, + { + "nonce": "13a6ee974b1fd759135a2c2010e3cdda47081c78e771125e4f0c382f0284a8cb", + "chacha_key": "bc5fb403b0bed0d84cf1db872b6522072aece00363178c98ad52178d805fca85", + "chacha_nonce": "65064239186e50304cc0f156", + "hmac_key": "e872d320dde4ed3487958a8e43b48aabd3ced92bc24bb8ff1ccb57b590d9701a" + }, + { + "nonce": "082fecdb85f358367b049b08be0e82627ae1d8edb0f27327ccb593aa2613b814", + "chacha_key": "1fbdb1cf6f6ea816349baf697932b36107803de98fcd805ebe9849b8ad0e6a45", + "chacha_nonce": "2e605e1d825a3eaeb613db9c", + "hmac_key": "fae910f591cf3c7eb538c598583abad33bc0a03085a96ca4ea3a08baf17c0eec" + }, + { + "nonce": "4c19020c74932c30ec6b2d8cd0d5bb80bd0fc87da3d8b4859d2fb003810afd03", + "chacha_key": "1ab9905a0189e01cda82f843d226a82a03c4f5b6dbea9b22eb9bc953ba1370d4", + "chacha_nonce": "cbb2530ea653766e5a37a83a", + "hmac_key": "267f68acac01ac7b34b675e36c2cef5e7b7a6b697214add62a491bedd6efc178" + }, + { + "nonce": "67723a3381497b149ce24814eddd10c4c41a1e37e75af161930e6b9601afd0ff", + "chacha_key": "9ecbd25e7e2e6c97b8c27d376dcc8c5679da96578557e4e21dba3a7ef4e4ac07", + "chacha_nonce": "ef649fcf335583e8d45e3c2e", + "hmac_key": "04dbbd812fa8226fdb45924c521a62e3d40a9e2b5806c1501efdeba75b006bf1" + }, + { + "nonce": "42063fe80b093e8619b1610972b4c3ab9e76c14fd908e642cd4997cafb30f36c", + "chacha_key": "211c66531bbcc0efcdd0130f9f1ebc12a769105eb39608994bcb188fa6a73a4a", + "chacha_nonce": "67803605a7e5010d0f63f8c8", + "hmac_key": "e840e4e8921b57647369d121c5a19310648105dbdd008200ebf0d3b668704ff8" + }, + { + "nonce": "b5ac382a4be7ac03b554fe5f3043577b47ea2cd7cfc7e9ca010b1ffbb5cf1a58", + "chacha_key": "b3b5f14f10074244ee42a3837a54309f33981c7232a8b16921e815e1f7d1bb77", + "chacha_nonce": "4e62a0073087ed808be62469", + "hmac_key": "c8efa10230b5ea11633816c1230ca05fa602ace80a7598916d83bae3d3d2ccd7" + }, + { + "nonce": "e9d1eba47dd7e6c1532dc782ff63125db83042bb32841db7eeafd528f3ea7af9", + "chacha_key": "54241f68dc2e50e1db79e892c7c7a471856beeb8d51b7f4d16f16ab0645d2f1a", + "chacha_nonce": "a963ed7dc29b7b1046820a1d", + "hmac_key": "aba215c8634530dc21c70ddb3b3ee4291e0fa5fa79be0f85863747bde281c8b2" + }, + { + "nonce": "a94ecf8efeee9d7068de730fad8daf96694acb70901d762de39fa8a5039c3c49", + "chacha_key": "c0565e9e201d2381a2368d7ffe60f555223874610d3d91fbbdf3076f7b1374dd", + "chacha_nonce": "329bb3024461e84b2e1c489b", + "hmac_key": "ac42445491f092481ce4fa33b1f2274700032db64e3a15014fbe8c28550f2fec" + }, + { + "nonce": "533605ea214e70c25e9a22f792f4b78b9f83a18ab2103687c8a0075919eaaa53", + "chacha_key": "ab35a5e1e54d693ff023db8500d8d4e79ad8878c744e0eaec691e96e141d2325", + "chacha_nonce": "653d759042b85194d4d8c0a7", + "hmac_key": "b43628e37ba3c31ce80576f0a1f26d3a7c9361d29bb227433b66f49d44f167ba" + }, + { + "nonce": "7f38df30ceea1577cb60b355b4f5567ff4130c49e84fed34d779b764a9cc184c", + "chacha_key": "a37d7f211b84a551a127ff40908974eb78415395d4f6f40324428e850e8c42a3", + "chacha_nonce": "b822e2c959df32b3cb772a7c", + "hmac_key": "1ba31764f01f69b5c89ded2d7c95828e8052c55f5d36f1cd535510d61ba77420" + }, + { + "nonce": "11b37f9dbc4d0185d1c26d5f4ed98637d7c9701fffa65a65839fa4126573a4e5", + "chacha_key": "964f38d3a31158a5bfd28481247b18dd6e44d69f30ba2a40f6120c6d21d8a6ba", + "chacha_nonce": "5f72c5b87c590bcd0f93b305", + "hmac_key": "2fc4553e7cedc47f29690439890f9f19c1077ef3e9eaeef473d0711e04448918" + }, + { + "nonce": "8be790aa483d4cdd843189f71f135b3ec7e31f381312c8fe9f177aab2a48eafa", + "chacha_key": "95c8c74d633721a131316309cf6daf0804d59eaa90ea998fc35bac3d2fbb7a94", + "chacha_nonce": "409a7654c0e4bf8c2c6489be", + "hmac_key": "21bb0b06eb2b460f8ab075f497efa9a01c9cf9146f1e3986c3bf9da5689b6dc4" + }, + { + "nonce": "19fd2a718ea084827d6bd73f509229ddf856732108b59fc01819f611419fd140", + "chacha_key": "cc6714b9f5616c66143424e1413d520dae03b1a4bd202b82b0a89b0727f5cdc8", + "chacha_nonce": "1b7fd2534f015a8f795d8f32", + "hmac_key": "2bef39c4ce5c3c59b817e86351373d1554c98bc131c7e461ed19d96cfd6399a0" + }, + { + "nonce": "3c2acd893952b2f6d07d8aea76f545ca45961a93fe5757f6a5a80811d5e0255d", + "chacha_key": "c8de6c878cb469278d0af894bc181deb6194053f73da5014c2b5d2c8db6f2056", + "chacha_nonce": "6ffe4f1971b904a1b1a81b99", + "hmac_key": "df1cd69dd3646fca15594284744d4211d70e7d8472e545d276421fbb79559fd4" + }, + { + "nonce": "7dbea4cead9ac91d4137f1c0a6eebb6ba0d1fb2cc46d829fbc75f8d86aca6301", + "chacha_key": "c8e030f6aa680c3d0b597da9c92bb77c21c4285dd620c5889f9beba7446446b0", + "chacha_nonce": "a9b5a67d081d3b42e737d16f", + "hmac_key": "355a85f551bc3cce9a14461aa60994742c9bbb1c81a59ca102dc64e61726ab8e" + }, + { + "nonce": "45422e676cdae5f1071d3647d7a5f1f5adafb832668a578228aa1155a491f2f3", + "chacha_key": "758437245f03a88e2c6a32807edfabff51a91c81ca2f389b0b46f2c97119ea90", + "chacha_nonce": "263830a065af33d9c6c5aa1f", + "hmac_key": "7c581cf3489e2de203a95106bfc0de3d4032e9d5b92b2b61fb444acd99037e17" + }, + { + "nonce": "babc0c03fad24107ad60678751f5db2678041ff0d28671ede8d65bdf7aa407e9", + "chacha_key": "bd68a28bd48d9ffa3602db72c75662ac2848a0047a313d2ae2d6bc1ac153d7e9", + "chacha_nonce": "d0f9d2a1ace6c758f594ffdd", + "hmac_key": "eb435e3a642adfc9d59813051606fc21f81641afd58ea6641e2f5a9f123bb50a" + }, + { + "nonce": "7a1b8aac37d0d20b160291fad124ab697cfca53f82e326d78fef89b4b0ea8f83", + "chacha_key": "9e97875b651a1d30d17d086d1e846778b7faad6fcbc12e08b3365d700f62e4fe", + "chacha_nonce": "ccdaad5b3b7645be430992eb", + "hmac_key": "6f2f55cf35174d75752f63c06cc7cbc8441759b142999ed2d5a6d09d263e1fc4" + }, + { + "nonce": "8370e4e32d7e680a83862cab0da6136ef607014d043e64cdf5ecc0c4e20b3d9a", + "chacha_key": "1472bed5d19db9c546106de946e0649cd83cc9d4a66b087a65906e348dcf92e2", + "chacha_nonce": "ed02dece5fc3a186f123420b", + "hmac_key": "7b3f7739f49d30c6205a46b174f984bb6a9fc38e5ccfacef2dac04fcbd3b184e" + }, + { + "nonce": "9f1c5e8a29cd5677513c2e3a816551d6833ee54991eb3f00d5b68096fc8f0183", + "chacha_key": "5e1a7544e4d4dafe55941fcbdf326f19b0ca37fc49c4d47e9eec7fb68cde4975", + "chacha_nonce": "7d9acb0fdc174e3c220f40de", + "hmac_key": "e265ab116fbbb86b2aefc089a0986a0f5b77eda50c7410404ad3b4f3f385c7a7" + }, + { + "nonce": "c385aa1c37c2bfd5cc35fcdbdf601034d39195e1cabff664ceb2b787c15d0225", + "chacha_key": "06bf4e60677a13e54c4a38ab824d2ef79da22b690da2b82d0aa3e39a14ca7bdd", + "chacha_nonce": "26b450612ca5e905b937e147", + "hmac_key": "22208152be2b1f5f75e6bfcc1f87763d48bb7a74da1be3d102096f257207f8b3" + }, + { + "nonce": "3ff73528f88a50f9d35c0ddba4560bacee5b0462d0f4cb6e91caf41847040ce4", + "chacha_key": "850c8a17a23aa761d279d9901015b2bbdfdff00adbf6bc5cf22bd44d24ecabc9", + "chacha_nonce": "4a296a1fb0048e5020d3b129", + "hmac_key": "b1bf49a533c4da9b1d629b7ff30882e12d37d49c19abd7b01b7807d75ee13806" + }, + { + "nonce": "2dcf39b9d4c52f1cb9db2d516c43a7c6c3b8c401f6a4ac8f131a9e1059957036", + "chacha_key": "17f8057e6156ba7cc5310d01eda8c40f9aa388f9fd1712deb9511f13ecc37d27", + "chacha_nonce": "a8188daff807a1182200b39d", + "hmac_key": "47b89da97f68d389867b5d8a2d7ba55715a30e3d88a3cc11f3646bc2af5580ef" + } + ] + }, + "calc_padded_len": [ + [16, 32], + [32, 32], + [33, 64], + [37, 64], + [45, 64], + [49, 64], + [64, 64], + [65, 96], + [100, 128], + [111, 128], + [200, 224], + [250, 256], + [320, 320], + [383, 384], + [384, 384], + [400, 448], + [500, 512], + [512, 512], + [515, 640], + [700, 768], + [800, 896], + [900, 1024], + [1020, 1024], + [65536, 65536] + ], + "encrypt_decrypt": [ + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000001", + "sec2": "0000000000000000000000000000000000000000000000000000000000000002", + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "plaintext": "a", + "payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb" + }, + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000002", + "sec2": "0000000000000000000000000000000000000000000000000000000000000001", + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "f00000000000000000000000000000f00000000000000000000000000000000f", + "plaintext": "🍕🫃", + "payload": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj" + }, + { + "sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a", + "sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d", + "conversation_key": "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45", + "nonce": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b", + "plaintext": "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀", + "payload": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs=" + }, + { + "sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c", + "sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba", + "conversation_key": "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b", + "nonce": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf", + "plaintext": "ability🤝的 ȺȾ", + "payload": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD" + }, + { + "sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c", + "sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae", + "conversation_key": "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8", + "nonce": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1", + "plaintext": "pepper👀їжак", + "payload": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+" + }, + { + "sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f", + "sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3", + "conversation_key": "4f1538411098cf11c8af216836444787c462d47f97287f46cf7edb2c4915b8a5", + "nonce": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407", + "plaintext": "( ͡° ͜ʖ ͡°)", + "payload": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv" + }, + { + "sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e", + "sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214", + "conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd", + "nonce": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68", + "plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،", + "payload": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItoIBsWu1CB+sStla2M4VeANASHxM78i1CfHQQH1YbBy24Tng7emYW44ol6QkFD6D8Zq7QPl+8L1c47lx8RoODEQMvNCbOk5ffUV3/AhONHBXnffrI+0025c+uRGzfqpYki4lBqm9iYU+k3Tvjczq9wU0mkVDEaM34WiQi30MfkJdRbeeYaq6kNvGPunLb3xdjjs5DL720d61Flc5ZfoZm+CBhADy9D9XiVZYLKAlkijALJur9dATYKci6OBOoc2SJS2Clai5hOVzR0yVeyHRgRfH9aLSlWW5dXcUxTo7qqRjNf8W5+J4jF4gNQp5f5d0YA4vPAzjBwSP/5bGzNDslKfcAH" + }, + { + "sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e", + "sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214", + "conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd", + "nonce": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023", + "plaintext": "الكل في المجمو عة (5)", + "payload": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjbOTrlfrxak5Lki42v2jMPpLSicy8eHjsWkkMtF0i925vOaKG/ZkMHh9ccQBdfTvgEGKzztedqDCAWb5TP1YwU1PsWaiiqG3+WgVvJiO4lUdMHXL7+zKKx8bgDtowzz4QAwI=" + }, + { + "sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e", + "sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214", + "conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd", + "nonce": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0", + "plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所", + "payload": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLgh/TsxPLFSANcT67EC1t/qxjru5ZoADjKVEt2ejdx+xGvH49mcdfbc+l+L7gJtkH7GLKpE9pQNQWNHMAmj043PAXJZ++fiJObMRR2mye5VHEANzZWkZXMrXF7YjuG10S1pOU=" + }, + { + "sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e", + "sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214", + "conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd", + "nonce": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54", + "plaintext": "🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗", + "payload": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg==" + } + ], + "encrypt_decrypt_long_msg": [ + { + "conversation_key": "8fc262099ce0d0bb9b89bac05bb9e04f9bc0090acc181fef6840ccee470371ed", + "nonce": "326bcb2c943cd6bb717588c9e5a7e738edf6ed14ec5f5344caa6ef56f0b9cff7", + "pattern": "x", + "repeat": 65535, + "plaintext_sha256": "09ab7495d3e61a76f0deb12cb0306f0696cbb17ffc12131368c7a939f12f56d3", + "payload_sha256": "90714492225faba06310bff2f249ebdc2a5e609d65a629f1c87f2d4ffc55330a" + }, + { + "conversation_key": "56adbe3720339363ab9c3b8526ffce9fd77600927488bfc4b59f7a68ffe5eae0", + "nonce": "ad68da81833c2a8ff609c3d2c0335fd44fe5954f85bb580c6a8d467aa9fc5dd0", + "pattern": "!", + "repeat": 65535, + "plaintext_sha256": "6af297793b72ae092c422e552c3bb3cbc310da274bd1cf9e31023a7fe4a2d75e", + "payload_sha256": "8013e45a109fad3362133132b460a2d5bce235fe71c8b8f4014793fb52a49844" + }, + { + "conversation_key": "7fc540779979e472bb8d12480b443d1e5eb1098eae546ef2390bee499bbf46be", + "nonce": "34905e82105c20de9a2f6cd385a0d541e6bcc10601d12481ff3a7575dc622033", + "pattern": "🦄", + "repeat": 16383, + "plaintext_sha256": "a249558d161b77297bc0cb311dde7d77190f6571b25c7e4429cd19044634a61f", + "payload_sha256": "b3348422471da1f3c59d79acfe2fe103f3cd24488109e5b18734cdb5953afd15" + } + ] + }, + "invalid": { + "encrypt_msg_lengths": [0, 65536, 100000, 10000000], + "get_conversation_key": [ + { + "sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "note": "sec1 higher than curve.n" + }, + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000000", + "pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "note": "sec1 is 0" + }, + { + "sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139", + "pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "note": "pub2 is invalid, no sqrt, all-ff" + }, + { + "sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", + "pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "note": "sec1 == curve.n" + }, + { + "sec1": "0000000000000000000000000000000000000000000000000000000000000002", + "pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "note": "pub2 is invalid, no sqrt" + }, + { + "sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "pub2": "0000000000000000000000000000000000000000000000000000000000000000", + "note": "pub2 is point of order 3 on twist" + }, + { + "sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "pub2": "eb1f7200aecaa86682376fb1c13cd12b732221e774f553b0a0857f88fa20f86d", + "note": "pub2 is point of order 13 on twist" + }, + { + "sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "pub2": "709858a4c121e4a84eb59c0ded0261093c71e8ca29efeef21a6161c447bcaf9f", + "note": "pub2 is point of order 3319 on twist" + } + ], + "decrypt": [ + { + "conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642", + "nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db", + "plaintext": "n o b l e", + "payload": "#Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJdU0MIDf06CUvEvdnr1cp1fiMtlM/GrE92xAc1K5odTpCzUB+mjXgbaqtntBUbTToSUoT0ovrlPwzGjyp", + "note": "unknown encryption version" + }, + { + "conversation_key": "36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481", + "nonce": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781", + "plaintext": "⚠️", + "payload": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBZFaha6EAIRueE9D1B1RuoiuFScC0Q94yjIuxZD3JStQtE8JMNacWFs9rlYP+ZydtHhRucp+lxfdvFlaGV/sQlqZz", + "note": "unknown encryption version 0" + }, + { + "conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642", + "nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db", + "plaintext": "n o s t r", + "payload": "Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq", + "note": "invalid base64" + }, + { + "conversation_key": "cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c", + "nonce": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25", + "plaintext": "¯\\_(ツ)_/¯", + "payload": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "note": "invalid MAC" + }, + { + "conversation_key": "cfcc9cf682dfb00b11357f65bdc45e29156b69db424d20b3596919074f5bf957", + "nonce": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4", + "plaintext": "🥎", + "payload": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0oRWQ8avl4OLqWZiTJ10vIgKrNqjoaX+fNhE9RqmR5g0f6BtUg1ijFMz71MO1D4lQLQfW7+UHva8PGYgQ1QpHlKgR", + "note": "invalid MAC" + }, + { + "conversation_key": "5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214", + "nonce": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1", + "plaintext": "elliptic-curve cryptography", + "payload": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHB573MI7R7rrTYftpqmvUpahmBC2sngmI14/L0HjOZ7lWGJlzdh6luiOnGPc46cGxf08MRC4CIuxx3i2Lm0KqgJ7vA", + "note": "invalid padding" + }, + { + "conversation_key": "fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496", + "nonce": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330", + "plaintext": "noble", + "payload": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16", + "note": "invalid padding" + }, + { + "conversation_key": "0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f", + "nonce": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b", + "plaintext": "censorship-resistant and global social network", + "payload": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6b2NZDaNm/TPkZGr75kbB6tCSoq7YRcbPiNfJXNch3Tf+o9+zZTMxwjgX/nm3yDKR2kHQMBhVleCB9uPuljl40AJ8kXRD0gjw+aYRJFUMK9gCETZAjjmrsCM+nGRZ1FfNsHr6Z", + "note": "invalid padding" + }, + { + "conversation_key": "5cd2d13b9e355aeb2452afbd3786870dbeecb9d355b12cb0a3b6e9da5744cd35", + "nonce": "b60036976a1ada277b948fd4caa065304b96964742b89d26f26a25263a5060bd", + "plaintext": "0", + "payload": "", + "note": "invalid payload length: 0" + }, + { + "conversation_key": "d61d3f09c7dfe1c0be91af7109b60a7d9d498920c90cbba1e137320fdd938853", + "nonce": "1a29d02c8b4527745a2ccb38bfa45655deb37bc338ab9289d756354cea1fd07c", + "plaintext": "1", + "payload": "Ag==", + "note": "invalid payload length: 4" + }, + { + "conversation_key": "873bb0fc665eb950a8e7d5971965539f6ebd645c83c08cd6a85aafbad0f0bc47", + "nonce": "c826d3c38e765ab8cc42060116cd1464b2a6ce01d33deba5dedfb48615306d4a", + "plaintext": "2", + "payload": "AqxgToSh3H7iLYRJjoWAM+vSv/Y1mgNlm6OWWjOYUClrFF8=", + "note": "invalid payload length: 48" + }, + { + "conversation_key": "9f2fef8f5401ac33f74641b568a7a30bb19409c76ffdc5eae2db6b39d2617fbe", + "nonce": "9ff6484642545221624eaac7b9ea27133a4cc2356682a6033aceeef043549861", + "plaintext": "3", + "payload": "Ap/2SEZCVFIhYk6qx7nqJxM6TMI1ZoKmAzrO7vBDVJhhuZXWiM20i/tIsbjT0KxkJs2MZjh1oXNYMO9ggfk7i47WQA==", + "note": "invalid payload length: 92" + } + ] + } + } +} diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 0fc4b5f0f..23f646183 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.0 + 2.0.8 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index 097c56418..06945cd16 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.0 + 2.0.8 ../pom.xml diff --git a/pom.xml b/pom.xml index 92a0b6e09..7725cccd3 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.0 + 2.0.8 pom nostr-java