Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic

## [Unreleased]

## [2.0.8] - 2026-08-22

### Fixed
- Relay payloads are now delivered only to the listener whose subscription they name. `NostrRelayClient.dispatchMessage()` broadcast every inbound frame to every listener on the connection, and nothing downstream read the subscription id the frame carried — so on a connection with two concurrent REQs, one subscription's `EOSE` fired the other's EOSE handler and ended its query early with whatever had arrived so far. Observed in the field as a gift-wrap query returning 0, 2 or 6 events at random from a relay holding exactly 5. `EVENT`, `EOSE` and `CLOSED` are now routed by subscription id for listeners registered through `subscribe(ReqMessage, …)`; connection-scoped frames (`NOTICE`, `OK`, `AUTH`) and listeners registered with raw JSON still see everything, so no existing caller loses a frame it receives today.
- NIP-44 no longer depends on a JCE provider being registered. `EncryptedPayloads` asked for `Cipher.getInstance("ChaCha20")` with an `IvParameterSpec`, which only BouncyCastle's provider accepts — SunJCE requires a `ChaCha20ParameterSpec` and throws. The provider happened to be registered as a side effect of `Schnorr.generatePrivateKey()`, so encryption worked for callers who generated a key and failed for callers who loaded one, and could not work on Android at all (adding a provider named "BC" is a no-op there; the platform owns the name). Both cipher call sites now use BouncyCastle's lightweight `ChaCha7539Engine`, which needs no provider lookup. Closes #537.

### Changed
- `Schnorr.generatePrivateKey()` no longer calls `Security.addProvider(new BouncyCastleProvider())`. It uses BouncyCastle's lightweight `ECKeyPairGenerator` instead, so generating a key no longer mutates process-wide JCE state. Callers that relied on nostr-java registering the provider for them must now register it themselves.

### Added
- NIP-44 v2 is verified against the specification's own test vectors (`nip44.vectors.json` from the reference implementation), and the whole suite runs with the BouncyCastle provider de-registered so the defect above cannot come back unnoticed.

## [2.0.7] - 2026-05-27

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion nostr-java-client/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.0.7</version>
<version>2.0.8</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -92,7 +93,7 @@
private long maxIdleTimeoutMs;

@Value("${nostr.websocket.max-events-per-request:10000}")
private int maxEventsPerRequest = DEFAULT_MAX_EVENTS_PER_REQUEST;

Check warning on line 96 in nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Field can be local variable

Field can be converted to a local variable

Check warning on line 96 in nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Field may be 'final'

Field `maxEventsPerRequest` may be 'final'

/**
* Decorator buffer-size limit captured at construction. Exposed as a
Expand Down Expand Up @@ -584,7 +585,9 @@
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
Expand All @@ -594,6 +597,18 @@
Consumer<Throwable> 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<String> messageListener,
Consumer<Throwable> errorListener,
Runnable closeListener)
throws IOException {
Objects.requireNonNull(requestJson, "requestJson");
Objects.requireNonNull(messageListener, "messageListener");
Objects.requireNonNull(errorListener, "errorListener");
Expand All @@ -604,7 +619,7 @@
String listenerId = UUID.randomUUID().toString();
listeners.put(
listenerId,
new ListenerRegistration(messageListener, errorListener, closeListener));
new ListenerRegistration(subscriptionId, messageListener, errorListener, closeListener));

try {
sendFrameGated(requestJson);
Expand Down Expand Up @@ -819,7 +834,7 @@
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
log.warn("Invalid value for property {}: {}, using default: {}", key, value, defaultValue);

Check notice on line 837 in nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Non-distinguishable logging calls

Similar log messages
}
}
return defaultValue;
Expand All @@ -831,18 +846,66 @@
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
log.warn("Invalid value for property {}: {}, using default: {}", key, value, defaultValue);

Check notice on line 849 in nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Non-distinguishable logging calls

Similar log messages
}
}
return defaultValue;
}

/**
* Hands a relay payload to the listeners it belongs to.
*
* <p>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).
*
* <p>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<ListenerRegistration> 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.
*
* <p>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) {
Expand Down Expand Up @@ -906,7 +969,7 @@
long retryDelayMs = NostrRetryable.DELAY;
IOException lastFailure = null;

for (int attempt = 1; attempt <= NostrRetryable.MAX_ATTEMPTS; attempt++) {

Check warning on line 972 in nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant values

Condition `attempt <= NostrRetryable.MAX_ATTEMPTS` is always `true`
try {
return operation.get();
} catch (IOException ex) {
Expand All @@ -919,7 +982,7 @@
}
}

throw lastFailure == null

Check warning on line 985 in nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant values

Condition `lastFailure == null` is always `false`
? new IOException("Relay operation failed without exception details")
: lastFailure;
}
Expand All @@ -933,7 +996,13 @@
}
}

/**
* 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<String> messageListener,
Consumer<Throwable> errorListener,
Runnable closeListener) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String> seenByA = new CopyOnWriteArrayList<>();
List<String> seenByB = new CopyOnWriteArrayList<>();
List<String> 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");
}
}
}
2 changes: 1 addition & 1 deletion nostr-java-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.0.7</version>
<version>2.0.8</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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));
Expand Down Expand Up @@ -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.
*
* <p>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");
Expand Down Expand Up @@ -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;
Expand Down
39 changes: 23 additions & 16 deletions nostr-java-core/src/main/java/nostr/crypto/schnorr/Schnorr.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -140,20 +138,29 @@ public static boolean verify(byte[] msg, byte[] pubkey, byte[] sig) throws Schno
/**
* Generate a random private key suitable for secp256k1.
*
* <p>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);
}
}
Expand Down
Loading
Loading