From 4cdd556a3c3aac53b8abd3e95ecbe769b2fd7f5f Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 6 May 2026 20:22:34 +0100 Subject: [PATCH 01/19] fix(ws): capture relayUri in final field to avoid null in log lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NostrRelayClient previously logged via clientSession.getUri(), which can return null after the WebSocket session has been closed. In staging, this surfaced as 188 occurrences in 2 hours of: Sending request to relay null: ["CLOSE","sub-..."] Capture the relay URI at construction time and store it in a final field. Use that field for all logging instead of querying the session, so log messages remain meaningful regardless of session state. The package-private (WebSocketSession, long) constructor used by tests still attempts to derive the URI from the session. No behaviour change beyond logging; all 13 nostr-java-client tests still pass. Refs: docs/analysis-gateway-core-relay-client-circuitbreaker-2026-05-06.md §4 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../springwebsocket/NostrRelayClient.java | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) 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 e1d409c1..4c893991 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 @@ -72,6 +72,13 @@ public class NostrRelayClient extends TextWebSocketHandler implements AutoClosea private int maxEventsPerRequest = DEFAULT_MAX_EVENTS_PER_REQUEST; 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(); private PendingRequest pendingRequest; private final Map listeners = new ConcurrentHashMap<>(); @@ -119,6 +126,7 @@ int eventCount() { public NostrRelayClient(@Value("${nostr.relay.uri}") String relayUri) throws java.util.concurrent.ExecutionException, InterruptedException { + this.relayUri = relayUri; this.clientSession = connectSession(relayUri); connectionState.set(ConnectionState.CONNECTED); } @@ -129,6 +137,7 @@ public NostrRelayClient(String relayUri, long awaitTimeoutMs) throw new IllegalArgumentException("awaitTimeoutMs must be positive"); } this.awaitTimeoutMs = awaitTimeoutMs; + this.relayUri = relayUri; log.info("NostrRelayClient created for {} with awaitTimeoutMs={}", relayUri, awaitTimeoutMs); this.clientSession = connectSession(relayUri); connectionState.set(ConnectionState.CONNECTED); @@ -143,6 +152,14 @@ public NostrRelayClient(String relayUri, long awaitTimeoutMs) } this.clientSession = clientSession; this.awaitTimeoutMs = awaitTimeoutMs; + 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); } @@ -253,7 +270,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,7 +286,7 @@ public List send(String json) throws IOException { } request = new PendingRequest(maxEventsPerRequest); pendingRequest = request; - log.info("Sending request to relay {}: {}", clientSession.getUri(), json); + log.info("Sending request to relay {}: {}", relayUri, json); clientSession.sendMessage(new TextMessage(json)); } finally { sendLock.unlock(); @@ -280,7 +297,7 @@ public List send(String json) throws IOException { 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); @@ -350,7 +367,7 @@ public AutoCloseable subscribe( throws IOException { String json = requestMessage.encode(); log.debug("Subscribing with {} on relay {} (size={} bytes)", - requestMessage.getCommand(), clientSession.getUri(), json.length()); + requestMessage.getCommand(), relayUri, json.length()); return subscribe(json, messageListener, errorListener, closeListener); } @@ -425,7 +442,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 +450,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 +463,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,7 +477,7 @@ 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; } From ae14d8deabd22a72ff52054bcc6c5014781524d4 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 6 May 2026 20:35:15 +0100 Subject: [PATCH 02/19] chore(release): bump version to 2.0.1 Includes the relayUri null-safety fix from 4cdd556a. --- nostr-java-client/pom.xml | 2 +- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- pom.xml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 143ef9ba..07aab89b 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.1 ../pom.xml diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index 2305d2fd..028f7cc7 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.1 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 0fc4b5f0..63139968 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.1 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index 097c5641..68f41a56 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.1 ../pom.xml diff --git a/pom.xml b/pom.xml index 92a0b6e0..2e2d494e 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.0 + 2.0.1 pom nostr-java From b5b1e91a1acd52905b4a97dcf9dc0276f510198d Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 6 May 2026 20:42:17 +0100 Subject: [PATCH 03/19] docs(changelog): add 2.0.1 entry for relayUri null-safety fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex review on PR #523 — repository's AGENTS.md guideline requires CHANGELOG.md updates for every version bump. --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a91b910..9710cca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ 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.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. From 04b59cb468a993aad2fe37dec768e70b1f785fcd Mon Sep 17 00:00:00 2001 From: tcheeric Date: Fri, 8 May 2026 00:26:27 +0100 Subject: [PATCH 04/19] fix(ws): wrap clientSession in ConcurrentWebSocketSessionDecorator to serialise concurrent sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NostrRelayClient.subscribe() called clientSession.sendMessage(...) without holding the existing sendLock, while send() held it correctly. Two threads racing inside subscribe() (or one in subscribe() racing one in send()) could trigger StandardWebSocketSession's IllegalStateException("The remote endpoint was in state [TEXT_FULL_WRITING]") (Tomcat) or "Blocking message pending" (Jetty). The catch (RuntimeException) block at the bottom of subscribe() rewrapped the exception as IOException("Failed to send subscription payload", e), which the imani-gateway-core consumer then surfaced as ~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. The decorator is idiomatic Spring and is future-proof against new send-paths added later (e.g. heartbeat/ping) that would otherwise have to remember to acquire sendLock. Sized: 256 KiB buffer (~2.5x the largest expected payload, a chunked kind-37375 EVENT at ~100 KiB), 10 s send time limit (above any p99.9 healthy-state subscribe REQ, well below the existing awaitTimeoutMs=60 s). Both tunable via @Value-injected constructor parameters (nostr.websocket.send-buffer-limit, nostr.websocket.send-time-limit-ms) or matching env-vars (NOSTR_WEBSOCKET_SEND_BUFFER_LIMIT, NOSTR_WEBSOCKET_SEND_TIME_LIMIT_MS) under Spring relaxed binding. OverflowStrategy is explicitly TERMINATE, never DROP — dropping outbound REQ/EVENT frames silently would make callers believe a subscribe/publish succeeded when the relay never received it. Constructor binding: - The new four-arg public constructor (relayUri, awaitTimeoutMs, sendBufferLimit, sendTimeLimitMs) is annotated @Autowired so Spring's prototype-scoped @Component selection is deterministic. - All existing public constructors (one-arg and two-arg) are retained as delegating overloads (binary-compat) and now also benefit from the decorator wrap by way of the canonical ctor. - A new package-private four-arg test ctor + two named static factories (forTestWithRawSession, forTestWithDecoratedSession) make the wrap-or-not decision explicit at the call site, so the new NostrRelayClientConcurrencyTest can deterministically reproduce the race against a raw session and verify the fix on a decorated one. - awaitTimeoutMs is now a final field assigned via constructor injection (previously @Value-annotated field). Constructor-injection ordering guarantees the value reaches the constructor body before the decorator is constructed. New test class NostrRelayClientConcurrencyTest covers: - (7a) Reproduction: 32 concurrent subscribe() calls against a raw session reproduce the IllegalStateException race rewrap. - (7b) Resolution: same harness against a decorated session sees zero failures and maxInFlight==1; field-injection regression assertions on the new ctor parameters. - (7c) Mixed-workload: a 100 KiB send racing with 20 subscribes under the 256 KiB default buffer completes without overflow. - (7d) Overflow-and-close: a tiny 256-byte buffer is forced to overflow; SessionLimitExceededException (rewrapped as IOException("Failed to send subscription payload", …)) propagates to the caller. Note: the spec's strict "session must be closed after overflow" assertion is relaxed here because Spring's ConcurrentWebSocketSessionDecorator does not close the delegate from limitExceeded() — the close-on-overflow chain is the upstream imani-wallet-lib's responsibility (§6.7e in the spec). - Constructor validation tests for non-positive sendBufferLimit / sendTimeLimitMs / awaitTimeoutMs and null session. Spec: imani-apps/docs/analysis-gateway-core-relay-subscribe-instability-2026-05-07.md Phase: 1 of 6 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../springwebsocket/NostrRelayClient.java | 205 +++++++- .../NostrRelayClientConcurrencyTest.java | 469 ++++++++++++++++++ 2 files changed, 666 insertions(+), 8 deletions(-) create mode 100644 nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientConcurrencyTest.java 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 4c893991..f2e84364 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 @@ -11,8 +11,10 @@ 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.TextWebSocketHandler; import jakarta.websocket.ContainerProvider; @@ -53,6 +55,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 +84,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,6 +92,20 @@ 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 @@ -124,34 +159,130 @@ 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.relayUri = relayUri; - 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"); } - this.awaitTimeoutMs = awaitTimeoutMs; + if (sendBufferLimit <= 0) { + throw new IllegalArgumentException("sendBufferLimit must be positive"); + } + if (sendTimeLimitMs <= 0) { + throw new IllegalArgumentException("sendTimeLimitMs must be positive"); + } this.relayUri = relayUri; - log.info("NostrRelayClient created for {} with awaitTimeoutMs={}", relayUri, awaitTimeoutMs); - this.clientSession = connectSession(relayUri); + this.awaitTimeoutMs = awaitTimeoutMs; + 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(); @@ -163,6 +294,64 @@ public NostrRelayClient(String relayUri, long awaitTimeoutMs) 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. * 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 00000000..5b6f2a7d --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientConcurrencyTest.java @@ -0,0 +1,469 @@ +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() + ")"); + + // Asserting (i) above; (ii) — spec §6.7d ii says the underlying session + // must be closed — is intentionally relaxed here. Spring's + // ConcurrentWebSocketSessionDecorator under TERMINATE does NOT close the + // delegate from limitExceeded() (it sets a private `limitExceeded` flag + // and throws SessionLimitExceededException; the delegate is closed only + // when somebody subsequently invokes the decorator's close()). The spec's + // assumption that overflow alone closes the session is incorrect for + // bare Spring; the closure happens upstream when NostrJavaRelayClient (or + // the application's MessageBrokerWebSocketHandler) handles the exception. + // We therefore assert the propagated overflow exception only, and leave + // the close-on-overflow chain to the upstream §6.7e wallet-lib test. + 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); + } + + // ---- 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(); + } +} From 030877f748b1587931b34c660c7d84f9f672c6f6 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Fri, 8 May 2026 00:26:33 +0100 Subject: [PATCH 05/19] chore(release): bump version to 2.0.2 Includes the concurrent-subscribe-send fix from the previous commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- nostr-java-client/pom.xml | 2 +- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- pom.xml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 07aab89b..b339d77d 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.1 + 2.0.2 ../pom.xml diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index 028f7cc7..0ab6627c 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.1 + 2.0.2 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 63139968..921fcca6 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.1 + 2.0.2 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index 68f41a56..b31aec36 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.1 + 2.0.2 ../pom.xml diff --git a/pom.xml b/pom.xml index 2e2d494e..d91854ff 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.1 + 2.0.2 pom nostr-java From e10ac66fc14f5740240114f6a91daeb6c1578548 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Fri, 8 May 2026 00:26:36 +0100 Subject: [PATCH 06/19] docs(changelog): add 2.0.2 entry for concurrent-subscribe-send fix Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9710cca3..6f575a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ 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.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 From b4b6d8355176a6407e988b4290d02a77c71f0b21 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Fri, 8 May 2026 05:59:22 +0100 Subject: [PATCH 07/19] fix(ws): explicitly close session on SessionLimitExceededException to restore isOpen()==false reconnect contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 2.0.2's wrap-only fix left the session open after overflow, breaking the upstream caller's clientSession.isOpen()==false → reconnect contract that the spec's §6.7d ii originally required. 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 RuntimeExceptions continue to flow through the existing wrap-as-IOException path unchanged — only overflow terminates the session, matching Spring's OverflowStrategy.TERMINATE semantics that the framework only half-implements. The §6.7d concurrency test now strictly asserts clientSession.isOpen()==false after overflow, restoring the spec's original contract. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../springwebsocket/NostrRelayClient.java | 28 ++++++++++++++++++- .../NostrRelayClientConcurrencyTest.java | 27 ++++++++++-------- 2 files changed, 43 insertions(+), 12 deletions(-) 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 f2e84364..0fda1289 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 @@ -15,6 +15,7 @@ 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; @@ -476,7 +477,21 @@ public List send(String json) throws IOException { request = new PendingRequest(maxEventsPerRequest); pendingRequest = request; log.info("Sending request to relay {}: {}", relayUri, json); - clientSession.sendMessage(new TextMessage(json)); + try { + clientSession.sendMessage(new TextMessage(json)); + } catch (SessionLimitExceededException e) { + // 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. + pendingRequest = null; + try { + clientSession.close(CloseStatus.SESSION_NOT_RELIABLE); + } catch (IOException closeEx) { + // Logged but not propagated — the original cause is the signal. + log.warn("Failed to close session after overflow: {}", closeEx.getMessage()); + } + throw new IOException("Failed to send relay payload", e); + } } finally { sendLock.unlock(); } @@ -586,6 +601,17 @@ public AutoCloseable subscribe( 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) { + try { + clientSession.close(CloseStatus.SESSION_NOT_RELIABLE); + } catch (IOException closeEx) { + // Logged but not propagated — the original cause is the signal. + log.warn("Failed to close session after overflow: {}", closeEx.getMessage()); + } + } throw new IOException("Failed to send subscription payload", e); } 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 index 5b6f2a7d..566045ae 100644 --- a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientConcurrencyTest.java +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientConcurrencyTest.java @@ -234,17 +234,9 @@ void decoratedSession_overflow_closesUnderlyingSession() throws Exception { "Expected at least one overflow exception under TERMINATE (observed " + failures.size() + ")"); - // Asserting (i) above; (ii) — spec §6.7d ii says the underlying session - // must be closed — is intentionally relaxed here. Spring's - // ConcurrentWebSocketSessionDecorator under TERMINATE does NOT close the - // delegate from limitExceeded() (it sets a private `limitExceeded` flag - // and throws SessionLimitExceededException; the delegate is closed only - // when somebody subsequently invokes the decorator's close()). The spec's - // assumption that overflow alone closes the session is incorrect for - // bare Spring; the closure happens upstream when NostrJavaRelayClient (or - // the application's MessageBrokerWebSocketHandler) handles the exception. - // We therefore assert the propagated overflow exception only, and leave - // the close-on-overflow chain to the upstream §6.7e wallet-lib test. + // (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; @@ -262,6 +254,19 @@ void decoratedSession_overflow_closesUnderlyingSession() throws Exception { 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 ---- From 51f97ed1c76d0a12d4df853d3a28531922fe0443 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Fri, 8 May 2026 05:59:25 +0100 Subject: [PATCH 08/19] chore(release): bump version to 2.0.3 Co-Authored-By: Claude Opus 4.6 (1M context) --- nostr-java-client/pom.xml | 2 +- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- pom.xml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index b339d77d..fc9eb0b2 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.2 + 2.0.3 ../pom.xml diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index 0ab6627c..aa188cba 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.2 + 2.0.3 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 921fcca6..8e1e5371 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.2 + 2.0.3 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index b31aec36..103a60e6 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.2 + 2.0.3 ../pom.xml diff --git a/pom.xml b/pom.xml index d91854ff..eadff199 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.2 + 2.0.3 pom nostr-java From 5cd1c0774f7c40a09c40928ac890e816d1d8dcb7 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Fri, 8 May 2026 05:59:28 +0100 Subject: [PATCH 09/19] docs(changelog): add 2.0.3 entry for close-on-overflow follow-up Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f575a7b..021b930d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ 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 From 21bbb58edd76f778be7bc990f3cc6853af969a24 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Tue, 26 May 2026 02:13:24 +0100 Subject: [PATCH 10/19] fix(ws): serialise session close against in-flight writes (spec-026 US3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NostrRelayClient called the no-arg clientSession.close() in close() and the send() timeout path. Spring 6.2.x's ConcurrentWebSocketSessionDecorator overrides only close(CloseStatus) (guarded by closeLock); the no-arg close() falls through to delegate.close() with no coordination, racing an in-flight sendMessage flush on the Tomcat delegate (single-in-flight-write → IllegalStateException: Concurrent write operations are not permitted). This was the residual concurrent-write leakage observed during the spec-026 soak, a layer below wallet-lib's adapter sendLock. Routing through close(CloseStatus) alone is insufficient: its closeLock is a separate lock from the flushLock guarding delegate.sendMessage. Introduce a ReentrantReadWriteLock session gate — every write (send/subscribe via sendFrameGated) takes the read side (sends stay concurrent; the decorator still serialises the delegate writes among them), every close (closeGated/ closeQuietly, always close(CloseStatus)) takes the write side and waits for in-flight sends to drain before sending the CLOSE frame. No lock nesting, so no added deadlock risk. Tests: new NostrRelayClientCloseWriteRaceTest (routing through CloseStatus + never no-arg close; timeout-path routing; latch-based serialisation proof). Updated NostrRelayClientTimeoutTest and SpringWebSocketClientTest, which asserted the old no-arg close(), to assert close(any(CloseStatus)) + never().close(). 24/24 nostr-java-client tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 + .../springwebsocket/NostrRelayClient.java | 121 +++++++--- .../NostrRelayClientCloseWriteRaceTest.java | 220 ++++++++++++++++++ .../NostrRelayClientTimeoutTest.java | 10 +- .../SpringWebSocketClientTest.java | 7 +- 5 files changed, 329 insertions(+), 32 deletions(-) create mode 100644 nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 021b930d..4baeaa53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +### 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). 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 0fda1289..7e775841 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 @@ -38,6 +38,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; /** @@ -116,6 +117,18 @@ public class NostrRelayClient extends TextWebSocketHandler implements AutoClosea */ 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 = @@ -476,26 +489,23 @@ public List send(String json) throws IOException { } request = new PendingRequest(maxEventsPerRequest); pendingRequest = request; - log.info("Sending request to relay {}: {}", relayUri, json); - try { - clientSession.sendMessage(new TextMessage(json)); - } catch (SessionLimitExceededException e) { - // 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. - pendingRequest = null; - try { - clientSession.close(CloseStatus.SESSION_NOT_RELIABLE); - } catch (IOException closeEx) { - // Logged but not propagated — the original cause is the signal. - log.warn("Failed to close session after overflow: {}", closeEx.getMessage()); - } - throw new IOException("Failed to send relay payload", e); - } } 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); + } + long timeout = awaitTimeoutMs > 0 ? awaitTimeoutMs : DEFAULT_AWAIT_TIMEOUT_MS; log.debug("Waiting for relay response with timeout={}ms", timeout); @@ -513,11 +523,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(); @@ -595,7 +601,7 @@ public AutoCloseable subscribe( new ListenerRegistration(messageListener, errorListener, closeListener)); try { - clientSession.sendMessage(new TextMessage(requestJson)); + sendFrameGated(requestJson); } catch (IOException e) { listeners.remove(listenerId); throw e; @@ -605,12 +611,7 @@ public AutoCloseable subscribe( // it does NOT close the delegate session. Close it explicitly here so // upstream callers' isOpen()==false reconnect contract holds. if (e instanceof SessionLimitExceededException) { - try { - clientSession.close(CloseStatus.SESSION_NOT_RELIABLE); - } catch (IOException closeEx) { - // Logged but not propagated — the original cause is the signal. - log.warn("Failed to close session after overflow: {}", closeEx.getMessage()); - } + closeQuietly(CloseStatus.SESSION_NOT_RELIABLE, "after overflow"); } throw new IOException("Failed to send subscription payload", e); } @@ -698,7 +699,39 @@ public AutoCloseable recoverSubscription( @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 { + 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(); @@ -706,8 +739,36 @@ 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) { + log.warn("Error closing session {}: {}", reason, e.getMessage()); + } + } + + /** 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(); } } 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 00000000..af769e4a --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java @@ -0,0 +1,220 @@ +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 and + * {@code close()} are mutually excluded by this client's own {@code sendLock}, + * so 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(Exception.class, () -> client.send(REQ)); + + verify(raw, times(1)).close(any(CloseStatus.class)); + verify(raw, never()).close(); + } + + // ---- 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 sendLock) 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 sendLock (held by the parked subscribe), 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 sendLock — 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/NostrRelayClientTimeoutTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientTimeoutTest.java index 12f85d72..21778e9f 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 4fe48ea1..7281af30 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. From e2c20a46a7c42149d349b3e65493bc3a7cf5efc5 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Tue, 26 May 2026 02:21:16 +0100 Subject: [PATCH 11/19] chore(release): bump version to 2.0.4 Cuts 2.0.4 with the spec-026 US3 close-vs-write fix. Moves [Unreleased] changelog content into the 2.0.4 section. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 ++ nostr-java-client/pom.xml | 2 +- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- pom.xml | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4baeaa53..42622f96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +## [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. diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index fc9eb0b2..448bafc9 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.3 + 2.0.4 ../pom.xml diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index aa188cba..35276029 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.3 + 2.0.4 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 8e1e5371..514eb3fc 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.3 + 2.0.4 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index 103a60e6..5673d737 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.3 + 2.0.4 ../pom.xml diff --git a/pom.xml b/pom.xml index eadff199..35647274 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.3 + 2.0.4 pom nostr-java From 8a01f14819b478fb02b580685a563ba284b0e34b Mon Sep 17 00:00:00 2001 From: tcheeric Date: Tue, 26 May 2026 02:40:26 +0100 Subject: [PATCH 12/19] fix(ws): clear pendingRequest on all send failures + review nits Address Copilot review on PR #525: - send(): clear pendingRequest on ANY sendFrameGated failure (not only SessionLimitExceededException). A plain IOException previously left the client stuck "in flight", breaking the next send()/@NostrRetryable retry. - closeQuietly(): log the swallowed throwable (+ relayUri) instead of just e.getMessage(), preserving the stack trace. - tests: correct stale sendLock references to the sessionGate RW-lock; narrow the timeout assertThrows from Exception to RelayTimeoutException. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../springwebsocket/NostrRelayClient.java | 10 +++++++++- .../NostrRelayClientCloseWriteRaceTest.java | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) 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 7e775841..858b962a 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 @@ -504,6 +504,12 @@ public List send(String json) throws IOException { 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; @@ -756,7 +762,9 @@ private void closeQuietly(CloseStatus status, String reason) { try { closeGated(status); } catch (Exception e) { - log.warn("Error closing session {}: {}", reason, e.getMessage()); + // 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); } } 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 index af769e4a..7ddc6b93 100644 --- a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java @@ -41,9 +41,10 @@ *
  • Routing — every close goes through {@code close(CloseStatus)}, never * the no-arg {@code close()} (tests {@link #close_routesThroughCloseStatus_neverNoArgClose} * and {@link #sendTimeout_routesCloseThroughCloseStatus_neverNoArgClose}).
  • - *
  • Serialisation — {@code subscribe()} / {@code send()} writes and - * {@code close()} are mutually excluded by this client's own {@code sendLock}, - * so the delegate never sees a write and a close at once + *
  • 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}).
  • * */ @@ -86,7 +87,7 @@ void sendTimeout_routesCloseThroughCloseStatus_neverNoArgClose() throws Exceptio NostrRelayClient client = NostrRelayClient.forTestWithDecoratedSession(raw, 200L); - assertThrows(Exception.class, () -> client.send(REQ)); + assertThrows(RelayTimeoutException.class, () -> client.send(REQ)); verify(raw, times(1)).close(any(CloseStatus.class)); verify(raw, never()).close(); @@ -108,7 +109,8 @@ void concurrentSubscribeAndClose_neverOverlapOnDelegate() throws Exception { Mockito.when(raw.isOpen()).thenAnswer(inv -> isOpen.get()); // First sendMessage parks in-flight (holding the decorator flushLock and this - // client's sendLock) until the test releases it; later sends do not park. + // 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); @@ -166,8 +168,9 @@ void concurrentSubscribeAndClose_neverOverlapOnDelegate() throws Exception { closer.setDaemon(true); closer.start(); - // With the fix, close() blocks on sendLock (held by the parked subscribe), so - // the delegate close has NOT run yet — exactly one op (the write) in flight. + // 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"); @@ -204,7 +207,7 @@ private static void enter( /** * Poll until {@code thread} is parked (BLOCKED / WAITING / TIMED_WAITING) — i.e. - * blocked acquiring sendLock — or the timeout elapses. + * 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); From ad65cd4d4cf0057ca8b9214d4dd596d49718faab Mon Sep 17 00:00:00 2001 From: tcheeric Date: Tue, 26 May 2026 02:47:19 +0100 Subject: [PATCH 13/19] chore(release): bump version to 2.0.5 Cuts 2.0.5 with the PR #525 review fixes: send() clears pendingRequest on any send failure (not just overflow) so a failed write can't wedge the in-flight slot, and closeQuietly() logs the throwable + relay URI. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 ++++++ nostr-java-client/pom.xml | 2 +- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- pom.xml | 2 +- 6 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42622f96..efd207dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +## [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 diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 448bafc9..2f1f6a46 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.4 + 2.0.5 ../pom.xml diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index 35276029..d38bc5e9 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.4 + 2.0.5 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 514eb3fc..ba383a77 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.4 + 2.0.5 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index 5673d737..7001e999 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.4 + 2.0.5 ../pom.xml diff --git a/pom.xml b/pom.xml index 35647274..4b0f4314 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.4 + 2.0.5 pom nostr-java From d742534dac621d3d5888e03b135d656b2556c8b6 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 27 May 2026 07:34:56 +0100 Subject: [PATCH 14/19] fix(ws): guard send() with isOpen() to stop CLOSE-on-dying-connection race (spec-026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send() lacked the isOpen() guard subscribe() has. A per-subscription Nostr CLOSE issued while a relay connection is breaking reached Tomcat's sendText, where doClose() fires mid-write and emits a WS CLOSE frame while the text write is still pending on the async channel — throwing "Concurrent write operations are not permitted", surfacing as a transport-error reconnect storm. Fail fast on a closed session instead. +regression test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../springwebsocket/NostrRelayClient.java | 11 +++++++++++ .../NostrRelayClientCloseWriteRaceTest.java | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) 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 858b962a..876dc2aa 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 @@ -479,6 +479,17 @@ public List send(T eventMessage) throws IOExcept @NostrRetryable public List send(String json) throws IOException { + // Fail fast on a closed session instead of entering Tomcat's write path. + // A send on an already-closed/closing session (e.g. a per-subscription + // CLOSE issued while a relay connection is being torn down) otherwise + // reaches WsRemoteEndpointImplBase.sendText, where Tomcat may invoke + // doClose() mid-write and emit a CLOSE frame while the text write is still + // pending on the async channel — throwing "Concurrent write operations are + // not permitted" and surfacing as a transport-error reconnect storm. The + // subscribe() path already guards this way; mirror it here. (spec-026) + if (!clientSession.isOpen()) { + throw new IOException("WebSocket session is closed"); + } PendingRequest request; sendLock.lock(); 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 index 7ddc6b93..eb0d61f4 100644 --- a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java @@ -93,6 +93,24 @@ void sendTimeout_routesCloseThroughCloseStatus_neverNoArgClose() throws Exceptio 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 From a1beaf88e5931b7d921607444dc2b1a468757df6 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 27 May 2026 07:39:14 +0100 Subject: [PATCH 15/19] chore(release): nostr-java 2.0.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send() isOpen() guard (spec-026) — eliminates the CLOSE-on-dying-connection concurrent-write transport-error storm. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 +++++ nostr-java-client/pom.xml | 2 +- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- pom.xml | 2 +- 6 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efd207dc..c88b138d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +## [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 diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 2f1f6a46..221ad7cb 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.5 + 2.0.6 ../pom.xml diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index d38bc5e9..403bac44 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.5 + 2.0.6 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index ba383a77..2120c7b5 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.5 + 2.0.6 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index 7001e999..3a7c43b0 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.5 + 2.0.6 ../pom.xml diff --git a/pom.xml b/pom.xml index 4b0f4314..1c4987e1 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.5 + 2.0.6 pom nostr-java From fa7b95fa562ad03c1c3a5b8e80470789c016c779 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 27 May 2026 15:50:53 +0100 Subject: [PATCH 16/19] =?UTF-8?q?fix(ws):=20close=20TOCTOU=20in=20send()?= =?UTF-8?q?=20isOpen=20guard=20=E2=80=94=20check=20inside=20read=20lock=20?= =?UTF-8?q?(2.0.7,=20PR=20#526=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2.0.6 guard checked clientSession.isOpen() at the top of send(), before sendFrameGated() took the sessionGate read lock — so a concurrent close() could close+release the session between the check and sendMessage(). Moved the check inside sendFrameGated() after the read lock, making open-check + write atomic w.r.t. closeGated() (write lock). Bump 2.0.6 -> 2.0.7. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 +++++ nostr-java-client/pom.xml | 2 +- .../springwebsocket/NostrRelayClient.java | 22 +++++++++---------- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- pom.xml | 2 +- 7 files changed, 21 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c88b138d..0a243709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +## [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 diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 221ad7cb..5c07b6fc 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.6 + 2.0.7 ../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 876dc2aa..15853f49 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 @@ -479,17 +479,6 @@ public List send(T eventMessage) throws IOExcept @NostrRetryable public List send(String json) throws IOException { - // Fail fast on a closed session instead of entering Tomcat's write path. - // A send on an already-closed/closing session (e.g. a per-subscription - // CLOSE issued while a relay connection is being torn down) otherwise - // reaches WsRemoteEndpointImplBase.sendText, where Tomcat may invoke - // doClose() mid-write and emit a CLOSE frame while the text write is still - // pending on the async channel — throwing "Concurrent write operations are - // not permitted" and surfacing as a transport-error reconnect storm. The - // subscribe() path already guards this way; mirror it here. (spec-026) - if (!clientSession.isOpen()) { - throw new IOException("WebSocket session is closed"); - } PendingRequest request; sendLock.lock(); @@ -728,6 +717,17 @@ public void close() throws IOException { 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(); diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index 403bac44..d05aec6b 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.6 + 2.0.7 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 2120c7b5..6ea8bd83 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.6 + 2.0.7 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index 3a7c43b0..acfaced8 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.6 + 2.0.7 ../pom.xml diff --git a/pom.xml b/pom.xml index 1c4987e1..e5c21e39 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.6 + 2.0.7 pom nostr-java From 74834538eaa68e81270c46056d22248a3a3089fb Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sat, 22 Aug 2026 20:55:04 +0100 Subject: [PATCH 17/19] fix(client): route relay payloads to the subscription that asked for them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Listeners were registered per connection and every inbound payload went to all of them, so a second REQ saw the first one's EVENT frames and, worse, was released by the first one's EOSE. Callers that end a query on EOSE — which is every caller — returned a partial result set that varied from call to call. - subscribe(ReqMessage, ...) now records the subscription id and dispatchMessage delivers EVENT, EOSE and CLOSED only to the listener that opened that subscription. - Everything else is unchanged: NOTICE, OK, AUTH and any payload whose id cannot be read still go to every listener, as do listeners registered through the raw-JSON subscribe overload, where no id is parsed. No caller loses a frame it receives today. - The id is read with a scan rather than a JSON parse: this runs on every frame, and the payload is parsed again by whichever listener consumes it. - Found on a live relay: gift-wrap queries returned 0, 2 or 6 of the 5 events the relay holds, differing call to call, while a direct REQ returned all 5 every time. Co-Authored-By: Claude Opus 5 --- .../springwebsocket/NostrRelayClient.java | 79 +++++++++++++++++-- ...strRelayClientSubscriptionRoutingTest.java | 76 ++++++++++++++++++ 2 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientSubscriptionRoutingTest.java 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 15853f49..cb346895 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; @@ -584,7 +585,9 @@ public AutoCloseable subscribe( String json = requestMessage.encode(); log.debug("Subscribing with {} on relay {} (size={} bytes)", requestMessage.getCommand(), relayUri, json.length()); - return subscribe(json, messageListener, errorListener, closeListener); + String subscriptionId = + requestMessage instanceof ReqMessage req ? req.getSubscriptionId() : null; + return subscribe(json, subscriptionId, messageListener, errorListener, closeListener); } @NostrRetryable @@ -594,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"); @@ -604,7 +619,7 @@ public AutoCloseable subscribe( String listenerId = UUID.randomUUID().toString(); listeners.put( listenerId, - new ListenerRegistration(messageListener, errorListener, closeListener)); + new ListenerRegistration(subscriptionId, messageListener, errorListener, closeListener)); try { sendFrameGated(requestJson); @@ -837,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) { @@ -933,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/NostrRelayClientSubscriptionRoutingTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientSubscriptionRoutingTest.java new file mode 100644 index 00000000..f71e71e3 --- /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"); + } + } +} From f9b695834fb441505218e0586473363aaffdd69a Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sat, 22 Aug 2026 21:02:31 +0100 Subject: [PATCH 18/19] fix(nip44): stop depending on a globally registered bouncycastle provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EncryptedPayloads asked for Cipher.getInstance("ChaCha20") and passed an IvParameterSpec. Only BouncyCastle's JCE provider accepts that; SunJCE requires a ChaCha20ParameterSpec and throws InvalidAlgorithmParameterException. The provider was registered nowhere except as a side effect of Schnorr.generatePrivateKey(), so NIP-44 encryption worked for a caller that had generated a key in this JVM and failed for one that loaded a key from storage. On Android it could not work at all: Security.addProvider for the name "BC" is a no-op there, because the platform ships its own repackaged BouncyCastle under that name. Both cipher call sites now use BouncyCastle's lightweight ChaCha7539Engine, which needs no provider lookup and behaves identically on the JVM and on Android. Schnorr.generatePrivateKey() drops the Security.addProvider call with it and uses the lightweight ECKeyPairGenerator, so generating a key no longer mutates process-wide JCE state. Callers that need the provider registered must register it themselves; every known consumer already does. NIP-44 is now checked against the specification's own vectors, and the whole test class runs with the provider de-registered — which is what makes the fixture a regression test rather than a restatement. The existing MessageCipherTest passed throughout, because both of its cases generate a key first. Closes #537 --- CHANGELOG.md | 10 + .../nostr/crypto/nip44/EncryptedPayloads.java | 42 +- .../java/nostr/crypto/schnorr/Schnorr.java | 39 +- .../crypto/nip44/EncryptedPayloadsTest.java | 141 ++++ .../src/test/resources/nip44.vectors.json | 648 ++++++++++++++++++ 5 files changed, 849 insertions(+), 31 deletions(-) create mode 100644 nostr-java-core/src/test/java/nostr/crypto/nip44/EncryptedPayloadsTest.java create mode 100644 nostr-java-core/src/test/resources/nip44.vectors.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a243709..d80e15a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +### 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 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 833391ec..4ba70e21 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 8e1ebbc7..e94879ae 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 00000000..cdda1638 --- /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 00000000..8be2b7b5 --- /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" + } + ] + } + } +} From 537c46452d68e06f5aec1c721e90cc604378f7f2 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sat, 22 Aug 2026 21:09:34 +0100 Subject: [PATCH 19/19] chore(release): nostr-java 2.0.8 --- CHANGELOG.md | 2 ++ nostr-java-client/pom.xml | 2 +- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- pom.xml | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d80e15a3..01d57fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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. diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 5c07b6fc..85daf838 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.7 + 2.0.8 ../pom.xml diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index d05aec6b..0b2d45db 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.7 + 2.0.8 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 6ea8bd83..23f64618 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.7 + 2.0.8 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index acfaced8..06945cd1 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.7 + 2.0.8 ../pom.xml diff --git a/pom.xml b/pom.xml index e5c21e39..7725cccd 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.7 + 2.0.8 pom nostr-java