From 91fcf7ca37ab746b7bc46dc9751282ce3647cdfd Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 7 Sep 2026 23:20:38 +0200 Subject: [PATCH 1/6] feat: let a negentropy reconciliation say which identity it may use --- doc/concepts/nip42-auth.md | 2 +- doc/usecases/negentropy.md | 46 +++ .../domain_layer/entities/nip77_state.dart | 26 +- .../domain_layer/usecases/nip77/nip77.dart | 72 ++++- .../usecases/nip77/nip77_internal.dart | 304 +++++++++++++----- .../domain_layer/usecases/relay_manager.dart | 31 +- packages/ndk/lib/ndk.dart | 4 +- packages/ndk/lib/presentation_layer/init.dart | 1 + packages/ndk/test/mocks/mock_relay.dart | 106 ++++++ .../test/usecases/nip77/nip77_auth_test.dart | 239 ++++++++++++++ 10 files changed, 735 insertions(+), 96 deletions(-) create mode 100644 packages/ndk/test/usecases/nip77/nip77_auth_test.dart diff --git a/doc/concepts/nip42-auth.md b/doc/concepts/nip42-auth.md index dc883aa58..884bb4ef3 100644 --- a/doc/concepts/nip42-auth.md +++ b/doc/concepts/nip42-auth.md @@ -4,4 +4,4 @@ NDK handles NIP-42 relay authentication automatically. When a relay requires aut A connection carries at most one identity, chosen when it is opened and immutable for its whole lifetime, so a request that authenticates moves to its own connection. -Which identity a request may be attributed to is the `auth` parameter, see [requests](/usecases/requests.md#relay-authentication-nip-42). +Which identity a request may be attributed to is the `auth` parameter, see [requests](/usecases/requests.md#relay-authentication-nip-42). The same parameter says which identity a negentropy reconciliation may use, see [negentropy](/usecases/negentropy.md#relay-authentication-nip-42). diff --git a/doc/usecases/negentropy.md b/doc/usecases/negentropy.md index ffcabb25a..b3620271a 100644 --- a/doc/usecases/negentropy.md +++ b/doc/usecases/negentropy.md @@ -21,6 +21,36 @@ final result = await response.future; print('Sync complete: ${result.needIds.length} events to fetch, ${result.haveIds.length} events to broadcast'); ``` +## Relay authentication (NIP-42) + +Some relays only reconcile with a client that authenticated, the same way they +only serve a request to one. The `auth` parameter says which identity the +reconciliation may be attributed to, exactly like on +[requests](/usecases/requests.md#relay-authentication-nip-42): + +```dart +final response = ndk.nip77.reconcile( + relayUrl: 'wss://relay.example.com', + filter: Filter(authors: [myPubkey]), + auth: RelayAuth.require(account), +); +``` + +| policy | connection | what a relay learns | +| --- | --- | --- | +| `RelayAuth.never()` | anonymous, always | nothing. A relay that refuses the negotiation without an identity simply does not reconcile | +| `RelayAuth.allow(a)` | anonymous, moves to one bound to `a` once the relay refuses | who you are, but only after that relay asked | +| `RelayAuth.require(a)` | bound to `a` from the start | who you are, as soon as it sends a challenge | + +Without `auth`, a refused negotiation authenticates as the currently logged-in +account, so the relay decides when your identity is revealed. Pass `auth` +explicitly whenever that matters. + +If `require` names an account that cannot sign, no connection can carry the +reconciliation. Rather than fall back to the anonymous one, which is what +`require` rules out, nothing is sent and the future fails right away with +`Nip77AuthUnavailableException`. + ## Error handling ### Relay doesn't support NIP-77 @@ -41,6 +71,22 @@ try { } ``` +### Relay requires an identity you did not give it + +```dart +try { + await ndk.nip77.reconcile( + relayUrl: 'wss://relay.example.com', + filter: filter, + auth: const RelayAuth.never(), + ).future; +} on Nip77AuthRequiredException catch (e) { + print('Relay wants an identity: ${e.message}'); +} on Nip77AuthUnavailableException catch (e) { + print('${e.pubkey} cannot sign, nothing was sent'); +} +``` + ## When to use ✅ **Good for:** diff --git a/packages/ndk/lib/domain_layer/entities/nip77_state.dart b/packages/ndk/lib/domain_layer/entities/nip77_state.dart index db4308025..faa62a45b 100644 --- a/packages/ndk/lib/domain_layer/entities/nip77_state.dart +++ b/packages/ndk/lib/domain_layer/entities/nip77_state.dart @@ -4,14 +4,34 @@ import 'dart:typed_data'; import 'package:rxdart/rxdart.dart'; import '../../shared/nips/nip77/negentropy.dart'; +import 'filter.dart'; +import 'relay_auth.dart'; +import 'relay_connection_key.dart'; /// State of a NIP-77 negentropy reconciliation session class Nip77State { /// Unique subscription ID for this session final String subscriptionId; + /// Connection this session runs on. It moves to a bound connection when a + /// relay refuses the negotiation without an identity. + RelayConnectionKey connectionKey; + + /// Filter the negotiation was opened with, replayed on an auth retry + final Filter filter; + + /// Which identity this session may be attributed to (NIP-42) + final RelayAuth? auth; + + /// whether the negotiation already moved from the anonymous connection to a + /// bound one + bool movedToBoundConnection = false; + + /// whether AUTH was already sent for the bound connection after a refusal + bool authenticatedAfterRefusal = false; + /// Relay URL this session is connected to - final String relayUrl; + String get relayUrl => connectionKey.url; /// Local items for reconciliation final List localItems; @@ -39,8 +59,10 @@ class Nip77State { Nip77State({ required this.subscriptionId, - required this.relayUrl, + required this.connectionKey, + required this.filter, required this.localItems, + this.auth, }); /// Stream of IDs we need from the relay diff --git a/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart b/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart index 093c2390f..bce816661 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart @@ -8,6 +8,7 @@ import '../../../shared/nips/nip77/negentropy.dart' as neg; import '../../entities/connection_source.dart'; import '../../entities/global_state.dart'; import '../../entities/nip77_state.dart'; +import '../../entities/relay_connectivity.dart'; import '../relay_manager.dart'; part 'nip77_internal.dart'; @@ -24,6 +25,43 @@ class Nip77NotSupportedException implements Exception { 'Nip77NotSupportedException: Relay $relayUrl does not support NIP-77${message != null ? ': $message' : ''}'; } +/// Exception thrown when no connection can carry the reconciliation, because +/// the identity it requires cannot sign. Falling back to the anonymous +/// connection is exactly what [RelayAuth.require] ruled out. +class Nip77AuthUnavailableException implements Exception { + /// relay the reconciliation was meant for + final String relayUrl; + + /// identity that was required + final String pubkey; + + /// no connection can carry the reconciliation + Nip77AuthUnavailableException(this.relayUrl, this.pubkey); + + @override + String toString() => + 'Nip77AuthUnavailableException: $pubkey cannot sign, so no connection to ' + '$relayUrl can carry this reconciliation'; +} + +/// Exception thrown when a relay refuses the negotiation without an identity +/// and the auth policy leaves nobody to authenticate as. +class Nip77AuthRequiredException implements Exception { + /// relay that refused + final String relayUrl; + + /// raw refusal from the relay + final String message; + + /// the relay asked for an identity this reconciliation may not reveal + Nip77AuthRequiredException(this.relayUrl, this.message); + + @override + String toString() => + 'Nip77AuthRequiredException: $relayUrl requires an identity this ' + 'reconciliation may not authenticate as: $message'; +} + /// Exception thrown when NIP-77 reconciliation times out class Nip77TimeoutException implements Exception { final String relayUrl; @@ -76,13 +114,31 @@ class Nip77 { static const Duration defaultTimeout = Duration(seconds: 30); /// Process incoming NEG-MSG from a relay - void processNegMsg(String subscriptionId, String relayUrl, String payload) { - _internal.processNegMsg(subscriptionId, relayUrl, payload); + void processNegMsg( + String subscriptionId, + RelayConnectionKey key, + String payload, + ) { + _internal.processNegMsg(subscriptionId, key, payload); } /// Process incoming NEG-ERR from a relay - void processNegErr(String subscriptionId, String relayUrl, String errorMsg) { - _internal.processNegErr(subscriptionId, relayUrl, errorMsg); + void processNegErr( + String subscriptionId, + RelayConnectionKey key, + String errorMsg, + ) { + _internal.processNegErr(subscriptionId, key, errorMsg); + } + + /// Process a CLOSED that ends a negotiation, which is how some relays refuse + /// a NEG-OPEN instead of answering NEG-ERR + void processNegClosed( + String subscriptionId, + RelayConnectionKey key, + String? message, + ) { + _internal.processNegClosed(subscriptionId, key, message); } /// Close a specific NIP-77 negotiation @@ -102,23 +158,31 @@ class Nip77 { /// [timeout] - How long to wait before timing out (default: 30s) /// [localIds] - Optional pre-computed list of local event IDs to use. /// If not provided, will query the cache using the filter. + /// [auth] - which identity this reconciliation may be attributed to, see + /// [RelayAuth]. Without it, a relay that refuses the negotiation + /// without an identity is answered as the logged-in account. /// /// Returns a [Nip77Response] with streams for real-time updates and /// a future that completes with the final result. /// /// Throws [Nip77NotSupportedException] if the relay doesn't support NIP-77. /// Throws [Nip77TimeoutException] if reconciliation times out. + /// Throws [Nip77AuthUnavailableException] if [auth] requires an identity that + /// cannot sign, and [Nip77AuthRequiredException] if the relay asks for an + /// identity [auth] rules out. Nip77Response reconcile({ required String relayUrl, required Filter filter, Duration timeout = defaultTimeout, List? localIds, + RelayAuth? auth, }) { return _internal.reconcile( relayUrl: relayUrl, filter: filter, timeout: timeout, localIds: localIds, + auth: auth, ); } } diff --git a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart index 5c8891fd0..0e128174b 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart @@ -21,6 +21,7 @@ class _Nip77Internal { required Filter filter, Duration timeout = Nip77.defaultTimeout, List? localIds, + RelayAuth? auth, }) { final cleanUrl = cleanRelayUrl(relayUrl); if (cleanUrl == null) { @@ -30,20 +31,34 @@ class _Nip77Internal { // Generate subscription ID final subscriptionId = 'neg-${DateTime.now().microsecondsSinceEpoch}'; + final connectionKey = RelayAuth.keyFor(cleanUrl, auth); + // Create session state (starts with empty items, will be populated async) final state = Nip77State( subscriptionId: subscriptionId, - relayUrl: cleanUrl, + connectionKey: connectionKey ?? RelayConnectionKey.anonymous(cleanUrl), + filter: filter, localItems: [], + auth: auth, ); // Register in global state _globalState.inFlightNegotiations[subscriptionId] = state; + // nothing can carry this reconciliation: answer now rather than let a + // timeout fire on a request that was impossible from the start + if (connectionKey == null) { + state.completeWithError( + Nip77AuthUnavailableException(cleanUrl, auth!.account!.pubkey), + ); + _globalState.inFlightNegotiations.remove(subscriptionId); + return Nip77Response(state); + } + // Set up timeout Timer(timeout, () { if (!state.isCompleted) { - _sendNegClose(cleanUrl, subscriptionId); + _sendNegClose(state.connectionKey, subscriptionId); state.completeWithError(Nip77TimeoutException(cleanUrl, timeout)); _globalState.inFlightNegotiations.remove(subscriptionId); } @@ -51,8 +66,6 @@ class _Nip77Internal { // Start async initialization _startReconciliation( - cleanUrl: cleanUrl, - filter: filter, localIds: localIds, subscriptionId: subscriptionId, state: state, @@ -62,33 +75,27 @@ class _Nip77Internal { } Future _startReconciliation({ - required String cleanUrl, - required Filter filter, required String subscriptionId, required Nip77State state, List? localIds, }) async { + final cleanUrl = state.connectionKey.url; try { - // Connect to relay if needed - final connected = await _relayManager.reconnectRelay( - cleanUrl, - connectionSource: ConnectionSource.explicit, - ); + final connectivity = await _openConnection(state); if (state.isCompleted) { return; // Guard: timeout may have fired during await } - if (!connected) { + if (connectivity == null) { state.completeWithError( - Exception('Failed to connect to relay: $cleanUrl'), + Exception('Failed to connect to relay: ${state.connectionKey}'), ); _globalState.inFlightNegotiations.remove(subscriptionId); return; } // Check if relay supports NIP-77 - final relayConnectivity = _relayManager.getRelayConnectivity(cleanUrl); - if (relayConnectivity?.relayInfo != null && - !relayConnectivity!.relayInfo!.supportsNip(77)) { + if (connectivity.relayInfo != null && + !connectivity.relayInfo!.supportsNip(77)) { state.completeWithError(Nip77NotSupportedException(cleanUrl)); _globalState.inFlightNegotiations.remove(subscriptionId); return; @@ -99,7 +106,7 @@ class _Nip77Internal { if (localIds != null) { localItems = await _buildItemsFromIds(localIds); } else { - localItems = await _buildItemsFromFilter(filter); + localItems = await _buildItemsFromFilter(state.filter); } if (state.isCompleted) { return; // Guard: timeout may have fired during await @@ -108,33 +115,56 @@ class _Nip77Internal { // Update state with local items state.localItems.addAll(localItems); - // Create initial message (hex encoded per NIP-77) - final initialMessage = neg.NegentropyEncoder.createInitialMessage( - localItems, - neg.NegentropyEncoder.idSize, - ); - final initialPayload = neg.NegentropyEncoder.bytesToHex(initialMessage); - - // Send NEG-OPEN (final guard before network action) - if (state.isCompleted) return; - final negOpen = [ - 'NEG-OPEN', - subscriptionId, - filter.toMap(), - initialPayload, - ]; - _relayManager - .getRelayConnectivity(cleanUrl) - ?.relayTransport - ?.send(jsonEncode(negOpen)); - - Logger.log.d(() => 'NEG-OPEN sent to $cleanUrl: $subscriptionId'); + _sendNegOpen(state); } catch (e) { state.completeWithError(e); _globalState.inFlightNegotiations.remove(subscriptionId); } } + /// Opens the connection the session is bound to, handing over the account so + /// a bound connection works for an identity that was never registered. + Future _openConnection(Nip77State state) async { + final connected = await _relayManager.reconnectConnection( + state.connectionKey, + connectionSource: ConnectionSource.explicit, + as: state.auth?.account, + ); + if (!connected) { + return null; + } + return _relayManager.getConnectivity(state.connectionKey); + } + + /// Sends NEG-OPEN on the connection the session currently holds. The initial + /// message is rebuilt from [Nip77State.localItems], so a refused negotiation + /// can be reopened on another connection by calling this again. + void _sendNegOpen(Nip77State state) { + if (state.isCompleted) return; + + final initialMessage = neg.NegentropyEncoder.createInitialMessage( + state.localItems, + neg.NegentropyEncoder.idSize, + ); + final negOpen = [ + 'NEG-OPEN', + state.subscriptionId, + state.filter.toMap(), + neg.NegentropyEncoder.bytesToHex(initialMessage), + ]; + _send(state.connectionKey, negOpen); + + Logger.log.d( + () => 'NEG-OPEN sent to ${state.connectionKey}: ${state.subscriptionId}', + ); + } + + void _send(RelayConnectionKey key, List message) { + _relayManager.getConnectivity(key)?.relayTransport?.send( + jsonEncode(message), + ); + } + Future> _buildItemsFromIds(List ids) async { final items = []; @@ -172,25 +202,40 @@ class _Nip77Internal { .toList(); } - /// Process incoming NEG-MSG from a relay - void processNegMsg(String subscriptionId, String relayUrl, String payload) { + /// The session [subscriptionId] belongs to, when the message really came + /// from the connection it runs on. Matching the whole key, not just the url, + /// keeps a message seen on the anonymous socket from feeding a session that + /// moved to a bound one. + Nip77State? _sessionFor( + String subscriptionId, + RelayConnectionKey key, + String messageType, + ) { final state = _globalState.inFlightNegotiations[subscriptionId]; if (state == null) { Logger.log.w( - () => 'Received NEG-MSG for unknown session: $subscriptionId', + () => 'Received $messageType for unknown session: $subscriptionId', ); - return; + return null; } - - // Verify relay origin to avoid cross-relay session contamination - final cleanUrl = cleanRelayUrl(relayUrl); - if (cleanUrl == null || state.relayUrl != cleanUrl) { + if (state.connectionKey != key) { Logger.log.w( - () => - 'Received NEG-MSG from mismatched relay: expected ${state.relayUrl}, got $relayUrl', + () => 'Received $messageType from mismatched connection: expected ' + '${state.connectionKey}, got $key', ); - return; + return null; } + return state; + } + + /// Process incoming NEG-MSG from a relay + void processNegMsg( + String subscriptionId, + RelayConnectionKey key, + String payload, + ) { + final state = _sessionFor(subscriptionId, key, 'NEG-MSG'); + if (state == null) return; try { final messageBytes = neg.NegentropyEncoder.hexToBytes(payload); @@ -198,7 +243,7 @@ class _Nip77Internal { if (response == null) { // Reconciliation complete - _sendNegClose(relayUrl, subscriptionId); + _sendNegClose(key, subscriptionId); state.complete(); _globalState.inFlightNegotiations.remove(subscriptionId); Logger.log.d( @@ -208,12 +253,8 @@ class _Nip77Internal { } else { // Send response (hex encoded) final responsePayload = neg.NegentropyEncoder.bytesToHex(response); - final negMsg = ['NEG-MSG', subscriptionId, responsePayload]; - _relayManager - .getRelayConnectivity(relayUrl) - ?.relayTransport - ?.send(jsonEncode(negMsg)); - Logger.log.d(() => 'NEG-MSG sent to $relayUrl'); + _send(key, ['NEG-MSG', subscriptionId, responsePayload]); + Logger.log.d(() => 'NEG-MSG sent to $key'); } } catch (e) { Logger.log.e(() => 'Error processing NEG-MSG: $e'); @@ -223,49 +264,148 @@ class _Nip77Internal { } /// Process incoming NEG-ERR from a relay - void processNegErr(String subscriptionId, String relayUrl, String errorMsg) { - final state = _globalState.inFlightNegotiations[subscriptionId]; - if (state == null) { - Logger.log.w( - () => 'Received NEG-ERR for unknown session: $subscriptionId', - ); + void processNegErr( + String subscriptionId, + RelayConnectionKey key, + String errorMsg, + ) { + final state = _sessionFor(subscriptionId, key, 'NEG-ERR'); + if (state == null) return; + + Logger.log.e(() => 'NEG-ERR from $key: $errorMsg'); + + if (_isAuthRefusal(errorMsg)) { + _handleNegAuthRequired(state, errorMsg); return; } - // Verify relay origin to avoid cross-relay session contamination - final cleanUrl = cleanRelayUrl(relayUrl); - if (cleanUrl == null || state.relayUrl != cleanUrl) { - Logger.log.w( - () => - 'Received NEG-ERR from mismatched relay: expected ${state.relayUrl}, got $relayUrl', + if (errorMsg.contains('CLOSED')) { + state.completeWithError( + Nip77NotSupportedException(key.url, errorMsg), ); - return; + } else { + state.completeWithError(Exception(errorMsg)); } - Logger.log.e(() => 'NEG-ERR from $cleanUrl: $errorMsg'); + _globalState.inFlightNegotiations.remove(subscriptionId); + } - if (errorMsg.contains('CLOSED') || errorMsg.contains('auth-required')) { - state.completeWithError(Nip77NotSupportedException(cleanUrl, errorMsg)); - } else { - state.completeWithError(Exception(errorMsg)); + /// Process a CLOSED that ends a negotiation. NIP-77 only names NEG-ERR, but + /// relays that gate NEG-OPEN behind NIP-42 commonly refuse it the way they + /// refuse a REQ. + void processNegClosed( + String subscriptionId, + RelayConnectionKey key, + String? message, + ) { + final state = _sessionFor(subscriptionId, key, 'CLOSED'); + if (state == null) return; + + final reason = message ?? ''; + Logger.log.d(() => 'CLOSED for negotiation $subscriptionId on $key: $reason'); + + if (_isAuthRefusal(reason)) { + _handleNegAuthRequired(state, reason); + return; } + state.completeWithError(Exception(reason.isEmpty ? 'closed' : reason)); _globalState.inFlightNegotiations.remove(subscriptionId); } - void _sendNegClose(String relayUrl, String subscriptionId) { - final negClose = ['NEG-CLOSE', subscriptionId]; + /// NIP-77 only suggests `blocked` and `closed`, so a relay that gates the + /// negotiation behind an identity says so in the machine-readable prefixes + /// NIP-01 defines for CLOSED. + bool _isAuthRefusal(String message) { + final lower = message.toLowerCase(); + return lower.contains('auth-required') || lower.contains('restricted'); + } + + /// Reopens a refused negotiation on a connection bound to an identity, the + /// way a refused REQ is retried. + void _handleNegAuthRequired(Nip77State state, String message) { + final subscriptionId = state.subscriptionId; + final url = state.connectionKey.url; + + void fail(Object error) { + state.completeWithError(error); + _globalState.inFlightNegotiations.remove(subscriptionId); + } + + // a refusal that lands mid-session cannot be replayed: the streams already + // emitted, and a fresh NEG-OPEN would report those ids twice + if (state.needIds.isNotEmpty || state.haveIds.isNotEmpty) { + fail(Nip77AuthRequiredException(url, message)); + return; + } + + final account = _relayManager.accountForAuth(state.auth); + if (account == null) { + fail(Nip77AuthRequiredException(url, message)); + return; + } + + // the connection is already bound, so the relay wants the AUTH it has not + // been given yet rather than another identity + if (!state.connectionKey.isAnonymous) { + if (state.authenticatedAfterRefusal) { + fail(Nip77AuthRequiredException(url, message)); + return; + } + state.authenticatedAfterRefusal = true; + _relayManager.authenticateConnection(state.connectionKey).then(( + authenticated, + ) { + if (state.isCompleted) return; + if (!authenticated) { + fail(Nip77AuthRequiredException(url, message)); + return; + } + _sendNegOpen(state); + }); + return; + } + + if (state.movedToBoundConnection) { + fail(Nip77AuthRequiredException(url, message)); + return; + } + state.movedToBoundConnection = true; + + Logger.log.d( + () => 'AUTH required for negotiation $subscriptionId on $url, ' + 'retrying as ${account.pubkey}', + ); + _relayManager - .getRelayConnectivity(relayUrl) - ?.relayTransport - ?.send(jsonEncode(negClose)); - Logger.log.d(() => 'NEG-CLOSE sent to $relayUrl: $subscriptionId'); + .openConnectionAs( + url, + account, + connectionSource: ConnectionSource.explicit, + ) + .then((bound) { + if (state.isCompleted) return; + if (bound == null) { + fail(Nip77AuthRequiredException(url, message)); + return; + } + state.connectionKey = bound.key; + // sent without waiting for the AUTH: a relay that only challenges on + // demand needs this NEG-OPEN as the trigger, and the challenge it then + // sends authenticates the bound connection on its own + _sendNegOpen(state); + }); + } + + void _sendNegClose(RelayConnectionKey key, String subscriptionId) { + _send(key, ['NEG-CLOSE', subscriptionId]); + Logger.log.d(() => 'NEG-CLOSE sent to $key: $subscriptionId'); } void close(String subscriptionId) { final state = _globalState.inFlightNegotiations[subscriptionId]; if (state != null) { - _sendNegClose(state.relayUrl, subscriptionId); + _sendNegClose(state.connectionKey, subscriptionId); state.close(); _globalState.inFlightNegotiations.remove(subscriptionId); } @@ -273,7 +413,7 @@ class _Nip77Internal { void closeAll() { for (final entry in _globalState.inFlightNegotiations.entries.toList()) { - _sendNegClose(entry.value.relayUrl, entry.key); + _sendNegClose(entry.value.connectionKey, entry.key); entry.value.close(); } _globalState.inFlightNegotiations.clear(); diff --git a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart index b91c44b7e..0a38547b3 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart @@ -82,13 +82,17 @@ class RelayManager { final Duration authChallengeTimeout; /// Handler for NIP-77 NEG-MSG messages - void Function(String subscriptionId, String relayUrl, String payload)? + void Function(String subscriptionId, RelayConnectionKey key, String payload)? onNegMsg; /// Handler for NIP-77 NEG-ERR messages - void Function(String subscriptionId, String relayUrl, String errorMsg)? + void Function(String subscriptionId, RelayConnectionKey key, String errorMsg)? onNegErr; + /// Handler for CLOSED messages that end a NIP-77 negotiation + void Function(String subscriptionId, RelayConnectionKey key, String? message)? + onNegClosed; + /// nostr transport factory, to create new transports (usually websocket) final NostrTransportFactory nostrTransportFactory; @@ -974,7 +978,7 @@ class RelayManager { if (msgData.length >= 3 && onNegMsg != null) { final subscriptionId = msgData[1] as String; final payload = msgData[2] as String; - onNegMsg!(subscriptionId, relayConnectivity.url, payload); + onNegMsg!(subscriptionId, relayConnectivity.key, payload); } return Future.value(); } @@ -983,7 +987,7 @@ class RelayManager { if (msgData.length >= 3 && onNegErr != null) { final subscriptionId = msgData[1] as String; final errorMsg = msgData[2] as String; - onNegErr!(subscriptionId, relayConnectivity.url, errorMsg); + onNegErr!(subscriptionId, relayConnectivity.key, errorMsg); } return Future.value(); } @@ -1307,6 +1311,13 @@ class RelayManager { String id = eventJson[1]; String? message = eventJson.length > 2 ? eventJson[2] : null; + // a negentropy session is not a REQ: it owns its own retry and never has a + // RequestState for the auth branch below to find + if (globalState.inFlightNegotiations.containsKey(id)) { + onNegClosed?.call(id, relayConnectivity.key, message); + return; + } + // Check if this is an auth-required CLOSED message if (message != null && message.startsWith("auth-required")) { _handleClosedAuthRequired(id, relayConnectivity, message); @@ -1487,8 +1498,11 @@ class RelayManager { } /// Account a request authenticates as, null when it must stay unattributable. - Account? _accountForRequest(RequestState state) { - final auth = state.request.auth; + Account? _accountForRequest(RequestState state) => + accountForAuth(state.request.auth); + + /// Account [auth] authenticates as, null when it must stay unattributable. + Account? accountForAuth(RelayAuth? auth) { switch (auth) { case RelayAuthNever(): return null; @@ -1764,6 +1778,11 @@ class RelayManager { RelayConnectivity? getRelayConnectivity(String url) { return globalState.relays[RelayConnectionKey.anonymous(url)]; } + + /// return [RelayConnectivity] of one connection, anonymous or bound + RelayConnectivity? getConnectivity(RelayConnectionKey key) { + return globalState.relays[key]; + } } dynamic decodeJson(String jsonString) { diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index f214812f2..f79af77bb 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -127,7 +127,9 @@ export 'domain_layer/usecases/nip77/nip77.dart' Nip77, Nip77Response, Nip77NotSupportedException, - Nip77TimeoutException; + Nip77TimeoutException, + Nip77AuthUnavailableException, + Nip77AuthRequiredException; export 'domain_layer/entities/nip77_state.dart' show Nip77Result; export 'domain_layer/usecases/ta/trusted_assertions.dart'; export 'domain_layer/entities/nip_85.dart'; diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index b3e1743fb..7a28adf20 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -375,6 +375,7 @@ class Initialization { // Wire up NIP-77 handlers relayManager.onNegMsg = nip77.processNegMsg; relayManager.onNegErr = nip77.processNegErr; + relayManager.onNegClosed = nip77.processNegClosed; trustedAssertions = TrustedAssertions( requests: requests, diff --git a/packages/ndk/test/mocks/mock_relay.dart b/packages/ndk/test/mocks/mock_relay.dart index 0a5256374..dc49fc35c 100644 --- a/packages/ndk/test/mocks/mock_relay.dart +++ b/packages/ndk/test/mocks/mock_relay.dart @@ -11,6 +11,7 @@ import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip01/helpers.dart'; import 'package:ndk/shared/nips/nip01/key_pair.dart'; import 'package:ndk/shared/nips/nip09/deletion.dart'; +import 'package:ndk/shared/nips/nip77/negentropy.dart'; import 'package:ndk/shared/nips/nip04/nip04.dart'; import 'package:ndk/shared/nips/nip44/nip44.dart'; @@ -106,6 +107,37 @@ class MockRelay { /// in the middle of an authentication does. The next ones are answered int silenceFirstAuths; + /// when true a NEG-OPEN on an unauthenticated connection is refused + bool requireAuthForNegentropy = false; + + /// how a refused NEG-OPEN is answered. NIP-77 only names NEG-ERR, but relays + /// that gate it behind NIP-42 often refuse it the way they refuse a REQ + bool refuseNegentropyWithClosed = false; + + /// events the relay reconciles against, id to created_at + final Map negentropyItems = {}; + + /// every NEG-OPEN the relay received, whether or not it served it + final List receivedNegOpens = []; + + /// subscription ids of NEG-OPENs carried by connections authenticated as + /// [pubkey] + Set negOpensAuthenticatedAs(String pubkey) => { + for (final entry in _negOpenedSubscriptions.entries) + if (_authenticatedPubkeys[entry.key]?.contains(pubkey) ?? false) + ...entry.value, + }; + + /// subscription ids of NEG-OPENs carried by connections that were not + /// authenticated as [pubkey] + Set negOpensNotAuthenticatedAs(String pubkey) => { + for (final entry in _negOpenedSubscriptions.entries) + if (!(_authenticatedPubkeys[entry.key]?.contains(pubkey) ?? false)) + ...entry.value, + }; + + final Map> _negOpenedSubscriptions = {}; + // NIP-46 Remote Signer Support static const int kNip46Kind = BunkerRequest.kKind; @@ -538,6 +570,45 @@ class MockRelay { } return; } + + if (eventJson[0] == "NEG-OPEN") { + final String subscriptionId = eventJson[1]; + final String payload = eventJson[3]; + + // recorded before the auth check, so a refused NEG-OPEN is still + // visible to tests + receivedNegOpens.add(subscriptionId); + _negOpenedSubscriptions + .putIfAbsent(webSocket, () => {}) + .add(subscriptionId); + + if (requireAuthForNegentropy && authenticatedPubkeys.isEmpty) { + const reason = + "auth-required: we can't reconcile with unauthenticated users"; + _send( + webSocket, + jsonEncode( + refuseNegentropyWithClosed + ? ["CLOSED", subscriptionId, reason] + : ["NEG-ERR", subscriptionId, reason], + ), + ); + return; + } + + _respondToNegentropy(webSocket, subscriptionId, payload); + return; + } + + if (eventJson[0] == "NEG-MSG") { + _respondToNegentropy(webSocket, eventJson[1], eventJson[2]); + return; + } + + if (eventJson[0] == "NEG-CLOSE") { + _negOpenedSubscriptions[webSocket]?.remove(eventJson[1]); + return; + } }, onDone: () { // Clean up when client disconnects @@ -570,6 +641,41 @@ class MockRelay { } } + /// Answers one negentropy round against [negentropyItems]. A response that is + /// only the version byte means the relay has nothing left to say, so it is + /// not sent back and the client ends the session. + void _respondToNegentropy( + WebSocket webSocket, + String subscriptionId, + String payload, + ) { + final items = negentropyItems.entries + .map( + (e) => NegentropyItem.fromHex(timestamp: e.value, idHex: e.key), + ) + .toList(); + + try { + final response = NegentropyEncoder.respond( + NegentropyEncoder.hexToBytes(payload), + items, + ); + if (response.length <= 1) { + return; + } + _send( + webSocket, + jsonEncode([ + "NEG-MSG", + subscriptionId, + NegentropyEncoder.bytesToHex(response), + ]), + ); + } catch (e) { + _send(webSocket, jsonEncode(["NEG-ERR", subscriptionId, "$e"])); + } + } + void _respondToRequest( WebSocket webSocket, List filters, diff --git a/packages/ndk/test/usecases/nip77/nip77_auth_test.dart b/packages/ndk/test/usecases/nip77/nip77_auth_test.dart new file mode 100644 index 000000000..8dec95f30 --- /dev/null +++ b/packages/ndk/test/usecases/nip77/nip77_auth_test.dart @@ -0,0 +1,239 @@ +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_relay.dart'; + +void main() async { + group('NIP-77 relay authentication', () { + const portBase = 4300; + + final key1 = Bip340.generatePrivateKey(); + + Account signableAccount(KeyPair key) => Account( + pubkey: key.publicKey, + type: AccountType.privateKey, + signer: Bip340EventSigner( + privateKey: key.privateKey!, + publicKey: key.publicKey, + ), + ); + + Ndk ndkFor(MockRelay relay) => Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relay.url], + ), + ); + + /// a relay holding one event the client does not have, so a successful + /// reconciliation is told apart from one that simply never ran + Future negentropyRelay({ + required int port, + bool requireAuth = true, + bool refuseWithClosed = false, + }) async { + final relay = MockRelay( + name: "neg relay", + explicitPort: port, + signEvents: false, + ) + ..requireAuthForNegentropy = requireAuth + ..refuseNegentropyWithClosed = refuseWithClosed; + relay.negentropyItems['a' * 64] = 1000; + // the relay only challenges once it refuses, which is what `allow` waits + // for, so the challenge has to be offered on every connection + relay.sendAuthChallenge = true; + relay.requireAuthForRequests = true; + await relay.startServer(); + return relay; + } + + Filter notesOf(KeyPair key) => + Filter(kinds: [Nip01Event.kTextNodeKind], authors: [key.publicKey]); + + test('require reconciles on a bound connection from the start', () async { + final relay = await negentropyRelay(port: portBase); + final ndk = ndkFor(relay); + await Future.delayed(Duration(seconds: 1)); + + final response = ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + auth: RelayAuth.require(signableAccount(key1)), + timeout: Duration(seconds: 10), + ); + + final result = await response.future; + expect(result.needIds, contains('a' * 64)); + expect(relay.connectionsAuthenticatedAs(key1.publicKey), 1); + expect( + relay.negOpensNotAuthenticatedAs(key1.publicKey), + isEmpty, + reason: 'require never opens a negotiation on the anonymous connection', + ); + + await ndk.destroy(); + await relay.stopServer(); + }); + + test('allow reconciles after the relay refuses', () async { + final relay = await negentropyRelay(port: portBase + 1); + final ndk = ndkFor(relay); + await Future.delayed(Duration(seconds: 1)); + + final response = ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + auth: RelayAuth.allow(signableAccount(key1)), + timeout: Duration(seconds: 10), + ); + + final result = await response.future; + expect(result.needIds, contains('a' * 64)); + expect(relay.connectionsAuthenticatedAs(key1.publicKey), 1); + expect( + relay.negOpensNotAuthenticatedAs(key1.publicKey), + isNotEmpty, + reason: 'allow tries the anonymous connection before authenticating', + ); + + await ndk.destroy(); + await relay.stopServer(); + }); + + test('allow reconciles when the relay refuses with CLOSED', () async { + final relay = await negentropyRelay( + port: portBase + 2, + refuseWithClosed: true, + ); + final ndk = ndkFor(relay); + await Future.delayed(Duration(seconds: 1)); + + final response = ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + auth: RelayAuth.allow(signableAccount(key1)), + timeout: Duration(seconds: 10), + ); + + final result = await response.future; + expect(result.needIds, contains('a' * 64)); + expect(relay.connectionsAuthenticatedAs(key1.publicKey), 1); + + await ndk.destroy(); + await relay.stopServer(); + }); + + test('never stays unattributable when a relay refuses', () async { + final relay = await negentropyRelay(port: portBase + 3); + final ndk = ndkFor(relay); + + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + await Future.delayed(Duration(seconds: 1)); + + final response = ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + auth: const RelayAuth.never(), + timeout: Duration(seconds: 10), + ); + + await expectLater( + response.future, + throwsA(isA()), + ); + expect(relay.connectionsAuthenticatedAs(key1.publicKey), 0); + + await ndk.destroy(); + await relay.stopServer(); + }); + + test('without auth it authenticates as the logged account', () async { + final relay = await negentropyRelay(port: portBase + 4); + final ndk = ndkFor(relay); + + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + await Future.delayed(Duration(seconds: 1)); + + final response = ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + timeout: Duration(seconds: 10), + ); + + final result = await response.future; + expect(result.needIds, contains('a' * 64)); + expect(relay.connectionsAuthenticatedAs(key1.publicKey), 1); + + await ndk.destroy(); + await relay.stopServer(); + }); + + test('require with an account that cannot sign reaches no relay', () async { + final relay = await negentropyRelay(port: portBase + 5); + final ndk = ndkFor(relay); + await Future.delayed(Duration(seconds: 1)); + + final watchOnly = Account( + pubkey: key1.publicKey, + type: AccountType.publicKey, + signer: Bip340EventSigner(privateKey: null, publicKey: key1.publicKey), + ); + + final response = ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + auth: RelayAuth.require(watchOnly), + timeout: Duration(seconds: 10), + ); + + await expectLater( + response.future, + throwsA(isA()), + ); + expect( + relay.receivedNegOpens, + isEmpty, + reason: 'an impossible reconciliation is sent to no relay at all', + ); + + await ndk.destroy(); + await relay.stopServer(); + }); + + test('reconciles anonymously when the relay does not require auth', + () async { + final relay = await negentropyRelay( + port: portBase + 6, + requireAuth: false, + ); + relay.requireAuthForRequests = false; + relay.sendAuthChallenge = false; + final ndk = ndkFor(relay); + await Future.delayed(Duration(seconds: 1)); + + final response = ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + auth: const RelayAuth.never(), + timeout: Duration(seconds: 10), + ); + + final result = await response.future; + expect(result.needIds, contains('a' * 64)); + expect(relay.connectionsAuthenticatedAs(key1.publicKey), 0); + + await ndk.destroy(); + await relay.stopServer(); + }); + }); +} From 47407e9a68b4197886cbd7524994364df567ef24 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 7 Sep 2026 23:59:29 +0200 Subject: [PATCH 2/6] style: dart format --- .../ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart index 0e128174b..bc33157b9 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart @@ -302,7 +302,8 @@ class _Nip77Internal { if (state == null) return; final reason = message ?? ''; - Logger.log.d(() => 'CLOSED for negotiation $subscriptionId on $key: $reason'); + Logger.log + .d(() => 'CLOSED for negotiation $subscriptionId on $key: $reason'); if (_isAuthRefusal(reason)) { _handleNegAuthRequired(state, reason); From 13ebe8d6e7cd30dd0c9723efeef8abe5bacc028f Mon Sep 17 00:00:00 2001 From: Nogringo Date: Tue, 8 Sep 2026 09:03:50 +0200 Subject: [PATCH 3/6] fix: raise an impossible reconciliation from the call, not from its future A `require` naming an account that cannot sign was answered by completing the session's completer with an error, synchronously, before `reconcile` had returned the response the caller listens to. Nothing was attached to that future yet, so the error reached the zone as an unhandled one and crashed a caller that only read `response.future` after an await. --- doc/usecases/negentropy.md | 2 +- .../domain_layer/usecases/nip77/nip77.dart | 12 +++++----- .../usecases/nip77/nip77_internal.dart | 22 ++++++++----------- .../test/usecases/nip77/nip77_auth_test.dart | 18 +++++++-------- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/doc/usecases/negentropy.md b/doc/usecases/negentropy.md index b3620271a..27352bfae 100644 --- a/doc/usecases/negentropy.md +++ b/doc/usecases/negentropy.md @@ -48,7 +48,7 @@ explicitly whenever that matters. If `require` names an account that cannot sign, no connection can carry the reconciliation. Rather than fall back to the anonymous one, which is what -`require` rules out, nothing is sent and the future fails right away with +`require` rules out, nothing is sent and `reconcile` itself throws `Nip77AuthUnavailableException`. ## Error handling diff --git a/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart b/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart index bce816661..5ca3a69fd 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip77/nip77.dart @@ -165,11 +165,13 @@ class Nip77 { /// Returns a [Nip77Response] with streams for real-time updates and /// a future that completes with the final result. /// - /// Throws [Nip77NotSupportedException] if the relay doesn't support NIP-77. - /// Throws [Nip77TimeoutException] if reconciliation times out. - /// Throws [Nip77AuthUnavailableException] if [auth] requires an identity that - /// cannot sign, and [Nip77AuthRequiredException] if the relay asks for an - /// identity [auth] rules out. + /// Throws [Nip77AuthUnavailableException] from the call itself, before + /// anything is sent, if [auth] requires an identity that cannot sign. + /// + /// The returned future fails with [Nip77NotSupportedException] if the relay + /// doesn't support NIP-77, [Nip77TimeoutException] if reconciliation times + /// out, and [Nip77AuthRequiredException] if the relay asks for an identity + /// [auth] rules out. Nip77Response reconcile({ required String relayUrl, required Filter filter, diff --git a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart index bc33157b9..43d4296e6 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart @@ -28,15 +28,21 @@ class _Nip77Internal { throw ArgumentError('Invalid relay URL: $relayUrl'); } + // nothing can carry this reconciliation, and nothing is sent. Raised like + // the invalid url above rather than through a future the caller has had no + // chance to listen to yet + final connectionKey = RelayAuth.keyFor(cleanUrl, auth); + if (connectionKey == null) { + throw Nip77AuthUnavailableException(cleanUrl, auth!.account!.pubkey); + } + // Generate subscription ID final subscriptionId = 'neg-${DateTime.now().microsecondsSinceEpoch}'; - final connectionKey = RelayAuth.keyFor(cleanUrl, auth); - // Create session state (starts with empty items, will be populated async) final state = Nip77State( subscriptionId: subscriptionId, - connectionKey: connectionKey ?? RelayConnectionKey.anonymous(cleanUrl), + connectionKey: connectionKey, filter: filter, localItems: [], auth: auth, @@ -45,16 +51,6 @@ class _Nip77Internal { // Register in global state _globalState.inFlightNegotiations[subscriptionId] = state; - // nothing can carry this reconciliation: answer now rather than let a - // timeout fire on a request that was impossible from the start - if (connectionKey == null) { - state.completeWithError( - Nip77AuthUnavailableException(cleanUrl, auth!.account!.pubkey), - ); - _globalState.inFlightNegotiations.remove(subscriptionId); - return Nip77Response(state); - } - // Set up timeout Timer(timeout, () { if (!state.isCompleted) { diff --git a/packages/ndk/test/usecases/nip77/nip77_auth_test.dart b/packages/ndk/test/usecases/nip77/nip77_auth_test.dart index 8dec95f30..bb9bdcbe9 100644 --- a/packages/ndk/test/usecases/nip77/nip77_auth_test.dart +++ b/packages/ndk/test/usecases/nip77/nip77_auth_test.dart @@ -189,15 +189,15 @@ void main() async { signer: Bip340EventSigner(privateKey: null, publicKey: key1.publicKey), ); - final response = ndk.nip77.reconcile( - relayUrl: relay.url, - filter: notesOf(key1), - auth: RelayAuth.require(watchOnly), - timeout: Duration(seconds: 10), - ); - - await expectLater( - response.future, + // the call itself throws, so a caller that only reads the future later + // never faces an error nobody was listening to + expect( + () => ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + auth: RelayAuth.require(watchOnly), + timeout: Duration(seconds: 10), + ), throwsA(isA()), ); expect( From 48a3fdb8ac45d525bc9980fff3b5dbd6bd64be46 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Tue, 8 Sep 2026 09:31:59 +0200 Subject: [PATCH 4/6] fix: stop spending the reconciliation budget on waiting for a signer A negotiation refused for auth-required retries on a bound connection, and the time that takes was counted against the reconciliation timeout. Signing is the part that hurts: a remote signer waits for a human, so a bunker tap alone could outlast the 30s budget and time out a session the relay never got a chance to answer. --- .../domain_layer/entities/nip77_state.dart | 50 ++++++++ .../usecases/nip77/nip77_internal.dart | 110 +++++++++++------- .../test/usecases/nip77/nip77_auth_test.dart | 35 ++++++ 3 files changed, 151 insertions(+), 44 deletions(-) diff --git a/packages/ndk/lib/domain_layer/entities/nip77_state.dart b/packages/ndk/lib/domain_layer/entities/nip77_state.dart index faa62a45b..f80376949 100644 --- a/packages/ndk/lib/domain_layer/entities/nip77_state.dart +++ b/packages/ndk/lib/domain_layer/entities/nip77_state.dart @@ -77,6 +77,53 @@ class Nip77State { /// Whether the session is completed bool get isCompleted => _isCompleted; + Timer? _timeoutTimer; + DateTime? _timeoutStartedAt; + Duration? _remainingTimeout; + void Function()? _onTimeout; + + /// how long the reconciliation itself may take. A paused timeout resumes + /// with what is left of it, not with a fresh one + Duration? _timeoutDuration; + + /// Starts the session timeout, [onTimeout] firing at most once. + void startTimeout(Duration duration, void Function() onTimeout) { + _timeoutDuration = duration; + _onTimeout = onTimeout; + _startTimeout(duration); + } + + void _startTimeout(Duration duration) { + _timeoutStartedAt = DateTime.now(); + _timeoutTimer = Timer(duration, () => _onTimeout?.call()); + } + + /// Pauses the timeout for a wait that is not the relay's to answer, the way + /// a request pauses before signing. Call it before an authentication. + void pauseTimeout() { + if (_timeoutTimer == null || _timeoutDuration == null) return; + + final elapsed = DateTime.now().difference(_timeoutStartedAt!); + final remaining = _timeoutDuration! - elapsed; + _remainingTimeout = remaining.isNegative ? Duration.zero : remaining; + _timeoutTimer!.cancel(); + _timeoutTimer = null; + } + + /// Resumes a paused timeout with the time it had left. + void resumeTimeout() { + final remaining = _remainingTimeout; + if (remaining == null) return; + _remainingTimeout = null; + _startTimeout(remaining); + } + + void _cancelTimeout() { + _timeoutTimer?.cancel(); + _timeoutTimer = null; + _remainingTimeout = null; + } + /// Process an incoming NEG-MSG from relay /// Returns the response message bytes to send back, or null if done Uint8List? processMessage(Uint8List messageBytes) { @@ -112,6 +159,7 @@ class Nip77State { void complete() { if (_isCompleted) return; _isCompleted = true; + _cancelTimeout(); _needController.close(); _haveController.close(); _completer.complete( @@ -126,6 +174,7 @@ class Nip77State { void completeWithError(Object error) { if (_isCompleted) return; _isCompleted = true; + _cancelTimeout(); this.error = error.toString(); _needController.close(); _haveController.close(); @@ -136,6 +185,7 @@ class Nip77State { void close() { if (_isCompleted) return; _isCompleted = true; + _cancelTimeout(); _needController.close(); _haveController.close(); if (!_completer.isCompleted) { diff --git a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart index 43d4296e6..2922eede3 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart @@ -51,13 +51,11 @@ class _Nip77Internal { // Register in global state _globalState.inFlightNegotiations[subscriptionId] = state; - // Set up timeout - Timer(timeout, () { - if (!state.isCompleted) { - _sendNegClose(state.connectionKey, subscriptionId); - state.completeWithError(Nip77TimeoutException(cleanUrl, timeout)); - _globalState.inFlightNegotiations.remove(subscriptionId); - } + // Set up timeout. The state owns it so an authentication can pause it + state.startTimeout(timeout, () { + if (state.isCompleted) return; + _sendNegClose(state.connectionKey, subscriptionId); + _fail(state, Nip77TimeoutException(cleanUrl, timeout)); }); // Start async initialization @@ -318,27 +316,26 @@ class _Nip77Internal { return lower.contains('auth-required') || lower.contains('restricted'); } + void _fail(Nip77State state, Object error) { + state.completeWithError(error); + _globalState.inFlightNegotiations.remove(state.subscriptionId); + } + /// Reopens a refused negotiation on a connection bound to an identity, the /// way a refused REQ is retried. void _handleNegAuthRequired(Nip77State state, String message) { - final subscriptionId = state.subscriptionId; final url = state.connectionKey.url; - void fail(Object error) { - state.completeWithError(error); - _globalState.inFlightNegotiations.remove(subscriptionId); - } - // a refusal that lands mid-session cannot be replayed: the streams already // emitted, and a fresh NEG-OPEN would report those ids twice if (state.needIds.isNotEmpty || state.haveIds.isNotEmpty) { - fail(Nip77AuthRequiredException(url, message)); + _fail(state, Nip77AuthRequiredException(url, message)); return; } final account = _relayManager.accountForAuth(state.auth); if (account == null) { - fail(Nip77AuthRequiredException(url, message)); + _fail(state, Nip77AuthRequiredException(url, message)); return; } @@ -346,52 +343,77 @@ class _Nip77Internal { // been given yet rather than another identity if (!state.connectionKey.isAnonymous) { if (state.authenticatedAfterRefusal) { - fail(Nip77AuthRequiredException(url, message)); + _fail(state, Nip77AuthRequiredException(url, message)); return; } - state.authenticatedAfterRefusal = true; - _relayManager.authenticateConnection(state.connectionKey).then(( - authenticated, - ) { - if (state.isCompleted) return; - if (!authenticated) { - fail(Nip77AuthRequiredException(url, message)); - return; - } - _sendNegOpen(state); - }); + unawaited(_authenticateAndReopen(state, message)); return; } if (state.movedToBoundConnection) { - fail(Nip77AuthRequiredException(url, message)); + _fail(state, Nip77AuthRequiredException(url, message)); + return; + } + unawaited(_moveToBoundConnection(state, account, message)); + } + + /// Answers the challenge on the bound connection, then reopens. + /// + /// The timeout is paused: signing may sit on a remote signer waiting for a + /// human, which is not time the relay is taking to reconcile. + Future _authenticateAndReopen(Nip77State state, String message) async { + final url = state.connectionKey.url; + state.authenticatedAfterRefusal = true; + + state.pauseTimeout(); + final authenticated = + await _relayManager.authenticateConnection(state.connectionKey); + + if (state.isCompleted) return; + state.resumeTimeout(); + + if (!authenticated) { + _fail(state, Nip77AuthRequiredException(url, message)); return; } + _sendNegOpen(state); + } + + /// Moves a refused anonymous negotiation onto a connection bound to + /// [account]. The timeout is paused for the same reason as above: opening + /// that connection is not the relay reconciling. + Future _moveToBoundConnection( + Nip77State state, + Account account, + String message, + ) async { + final url = state.connectionKey.url; state.movedToBoundConnection = true; Logger.log.d( - () => 'AUTH required for negotiation $subscriptionId on $url, ' + () => 'AUTH required for negotiation ${state.subscriptionId} on $url, ' 'retrying as ${account.pubkey}', ); - _relayManager - .openConnectionAs( + state.pauseTimeout(); + final bound = await _relayManager.openConnectionAs( url, account, connectionSource: ConnectionSource.explicit, - ) - .then((bound) { - if (state.isCompleted) return; - if (bound == null) { - fail(Nip77AuthRequiredException(url, message)); - return; - } - state.connectionKey = bound.key; - // sent without waiting for the AUTH: a relay that only challenges on - // demand needs this NEG-OPEN as the trigger, and the challenge it then - // sends authenticates the bound connection on its own - _sendNegOpen(state); - }); + ); + + if (state.isCompleted) return; + state.resumeTimeout(); + + if (bound == null) { + _fail(state, Nip77AuthRequiredException(url, message)); + return; + } + state.connectionKey = bound.key; + // sent without waiting for the AUTH: a relay that only challenges on + // demand needs this NEG-OPEN as the trigger, and the challenge it then + // sends authenticates the bound connection on its own + _sendNegOpen(state); } void _sendNegClose(RelayConnectionKey key, String subscriptionId) { diff --git a/packages/ndk/test/usecases/nip77/nip77_auth_test.dart b/packages/ndk/test/usecases/nip77/nip77_auth_test.dart index bb9bdcbe9..3c4a91503 100644 --- a/packages/ndk/test/usecases/nip77/nip77_auth_test.dart +++ b/packages/ndk/test/usecases/nip77/nip77_auth_test.dart @@ -4,6 +4,7 @@ import 'package:ndk/shared/nips/nip01/key_pair.dart'; import 'package:test/test.dart'; import '../../mocks/mock_relay.dart'; +import '../../mocks/mock_slow_signer.dart'; void main() async { group('NIP-77 relay authentication', () { @@ -235,5 +236,39 @@ void main() async { await ndk.destroy(); await relay.stopServer(); }); + + test('a slow signer does not spend the reconciliation budget', () async { + final relay = await negentropyRelay(port: portBase + 7); + final ndk = ndkFor(relay); + await Future.delayed(Duration(seconds: 1)); + + final slow = Account( + pubkey: key1.publicKey, + type: AccountType.privateKey, + signer: MockSlowSigner( + innerSigner: Bip340EventSigner( + privateKey: key1.privateKey!, + publicKey: key1.publicKey, + ), + delay: Duration(seconds: 4), + ), + ); + + // the signature alone outlasts the timeout, so this only reconciles if + // waiting on the signer does not count against it + final response = ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + auth: RelayAuth.require(slow), + timeout: Duration(seconds: 2), + ); + + final result = await response.future; + expect(result.needIds, contains('a' * 64)); + expect(relay.connectionsAuthenticatedAs(key1.publicKey), 1); + + await ndk.destroy(); + await relay.stopServer(); + }); }); } From 4dea6c340fb5cf347b17dbc2fafb9912d44f45bd Mon Sep 17 00:00:00 2001 From: Nogringo Date: Tue, 8 Sep 2026 09:49:19 +0200 Subject: [PATCH 5/6] test: keep a NEG-OPEN on the record once its negotiation is over --- packages/ndk/test/mocks/mock_relay.dart | 56 +++++++++++++------ .../test/usecases/nip77/nip77_auth_test.dart | 5 ++ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/ndk/test/mocks/mock_relay.dart b/packages/ndk/test/mocks/mock_relay.dart index dc49fc35c..4555b6246 100644 --- a/packages/ndk/test/mocks/mock_relay.dart +++ b/packages/ndk/test/mocks/mock_relay.dart @@ -117,27 +117,31 @@ class MockRelay { /// events the relay reconciles against, id to created_at final Map negentropyItems = {}; - /// every NEG-OPEN the relay received, whether or not it served it - final List receivedNegOpens = []; + /// every NEG-OPEN the relay received, refused ones included, kept for the + /// whole run. A NEG-CLOSE or a socket that dies must not erase what a test + /// is about to assert on + final List<_ReceivedNegOpen> _negOpens = []; + + /// subscription ids of every NEG-OPEN the relay received + List get receivedNegOpens => + [for (final negOpen in _negOpens) negOpen.subscriptionId]; /// subscription ids of NEG-OPENs carried by connections authenticated as /// [pubkey] Set negOpensAuthenticatedAs(String pubkey) => { - for (final entry in _negOpenedSubscriptions.entries) - if (_authenticatedPubkeys[entry.key]?.contains(pubkey) ?? false) - ...entry.value, + for (final negOpen in _negOpens) + if (negOpen.connectionPubkeys.contains(pubkey)) + negOpen.subscriptionId, }; - /// subscription ids of NEG-OPENs carried by connections that were not + /// subscription ids of NEG-OPENs carried by connections that were never /// authenticated as [pubkey] Set negOpensNotAuthenticatedAs(String pubkey) => { - for (final entry in _negOpenedSubscriptions.entries) - if (!(_authenticatedPubkeys[entry.key]?.contains(pubkey) ?? false)) - ...entry.value, + for (final negOpen in _negOpens) + if (!negOpen.connectionPubkeys.contains(pubkey)) + negOpen.subscriptionId, }; - final Map> _negOpenedSubscriptions = {}; - // NIP-46 Remote Signer Support static const int kNip46Kind = BunkerRequest.kKind; @@ -576,11 +580,15 @@ class MockRelay { final String payload = eventJson[3]; // recorded before the auth check, so a refused NEG-OPEN is still - // visible to tests - receivedNegOpens.add(subscriptionId); - _negOpenedSubscriptions - .putIfAbsent(webSocket, () => {}) - .add(subscriptionId); + // visible to tests. It holds the connection's own pubkey set, so + // it still tells which identity carried the negotiation once the + // socket is gone and an AUTH that lands later still counts + _negOpens.add( + _ReceivedNegOpen( + subscriptionId: subscriptionId, + connectionPubkeys: authenticatedPubkeys, + ), + ); if (requireAuthForNegentropy && authenticatedPubkeys.isEmpty) { const reason = @@ -606,7 +614,6 @@ class MockRelay { } if (eventJson[0] == "NEG-CLOSE") { - _negOpenedSubscriptions[webSocket]?.remove(eventJson[1]); return; } }, @@ -1252,3 +1259,18 @@ class MockRelay { } } } + +/// One NEG-OPEN as the relay received it. +/// +/// [connectionPubkeys] is the connection's own set, not a copy, so a NEG-OPEN +/// sent before the AUTH that follows it still counts as carried by the +/// identity that connection ended up holding. +class _ReceivedNegOpen { + final String subscriptionId; + final Set connectionPubkeys; + + _ReceivedNegOpen({ + required this.subscriptionId, + required this.connectionPubkeys, + }); +} diff --git a/packages/ndk/test/usecases/nip77/nip77_auth_test.dart b/packages/ndk/test/usecases/nip77/nip77_auth_test.dart index 3c4a91503..e833cd682 100644 --- a/packages/ndk/test/usecases/nip77/nip77_auth_test.dart +++ b/packages/ndk/test/usecases/nip77/nip77_auth_test.dart @@ -70,6 +70,11 @@ void main() async { final result = await response.future; expect(result.needIds, contains('a' * 64)); expect(relay.connectionsAuthenticatedAs(key1.publicKey), 1); + expect( + relay.negOpensAuthenticatedAs(key1.publicKey), + isNotEmpty, + reason: 'require opens the negotiation on the bound connection', + ); expect( relay.negOpensNotAuthenticatedAs(key1.publicKey), isEmpty, From 5264d9982a8e09e9de98b3dc93b109eaa7fea35f Mon Sep 17 00:00:00 2001 From: Nogringo Date: Tue, 8 Sep 2026 16:00:19 +0200 Subject: [PATCH 6/6] fix: end a reconciliation whose signer refuses to answer the challenge A remote signer answers a refused request by throwing, and nothing caught that. The exception escaped an unawaited future. --- .../usecases/nip77/nip77_internal.dart | 32 +++++-- .../domain_layer/usecases/relay_manager.dart | 31 +++++-- .../ndk/test/mocks/mock_refusing_signer.dart | 83 +++++++++++++++++++ .../test/usecases/nip77/nip77_auth_test.dart | 79 ++++++++++++++++++ 4 files changed, 209 insertions(+), 16 deletions(-) create mode 100644 packages/ndk/test/mocks/mock_refusing_signer.dart diff --git a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart index 2922eede3..a731aa6b0 100644 --- a/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart +++ b/packages/ndk/lib/domain_layer/usecases/nip77/nip77_internal.dart @@ -366,8 +366,18 @@ class _Nip77Internal { state.authenticatedAfterRefusal = true; state.pauseTimeout(); - final authenticated = - await _relayManager.authenticateConnection(state.connectionKey); + final bool authenticated; + try { + authenticated = + await _relayManager.authenticateConnection(state.connectionKey); + } catch (e) { + // nothing resumes a paused timeout once this future is gone, so a session + // that cannot authenticate has to end here rather than wait forever + if (!state.isCompleted) { + _fail(state, Nip77AuthRequiredException(url, '$message ($e)')); + } + return; + } if (state.isCompleted) return; state.resumeTimeout(); @@ -396,11 +406,19 @@ class _Nip77Internal { ); state.pauseTimeout(); - final bound = await _relayManager.openConnectionAs( - url, - account, - connectionSource: ConnectionSource.explicit, - ); + final RelayConnectivity? bound; + try { + bound = await _relayManager.openConnectionAs( + url, + account, + connectionSource: ConnectionSource.explicit, + ); + } catch (e) { + if (!state.isCompleted) { + _fail(state, Nip77AuthRequiredException(url, '$message ($e)')); + } + return; + } if (state.isCompleted) return; state.resumeTimeout(); diff --git a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart index 0a38547b3..ad0eebd1e 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart @@ -1188,15 +1188,28 @@ class RelayManager { return false; } - final signedAuth = await account.signer.sign( - AuthEvent( - pubKey: account.pubkey, - tags: [ - ["relay", key.url], - ["challenge", challenge], - ], - ), - ); + // signing throws for anything from a declined request to an unreachable + // signer, and a caller that paused its timeout to wait for it would never + // resume that timeout if the error escaped here + final Nip01Event signedAuth; + try { + signedAuth = await account.signer.sign( + AuthEvent( + pubKey: account.pubkey, + tags: [ + ["relay", key.url], + ["challenge", challenge], + ], + ), + ); + } catch (error, stackTrace) { + Logger.log.w( + () => "Could not sign AUTH for $key", + error: error, + stackTrace: stackTrace, + ); + return false; + } if (transportGone()) { return false; } diff --git a/packages/ndk/test/mocks/mock_refusing_signer.dart b/packages/ndk/test/mocks/mock_refusing_signer.dart new file mode 100644 index 000000000..b2342d83a --- /dev/null +++ b/packages/ndk/test/mocks/mock_refusing_signer.dart @@ -0,0 +1,83 @@ +import 'package:ndk/domain_layer/entities/nip_01_event.dart'; +import 'package:ndk/domain_layer/entities/pending_signer_request.dart'; +import 'package:ndk/domain_layer/entities/signer_request_rejected_exception.dart'; +import 'package:ndk/domain_layer/repositories/event_signer.dart'; + +/// A wrapper signer that declines every request, the way a bunker answers one +/// its owner rejected: it throws instead of returning an event. +class MockRefusingSigner implements EventSigner { + final EventSigner _innerSigner; + + /// how many times something asked this signer to sign + int signAttempts = 0; + + MockRefusingSigner({required EventSigner innerSigner}) + : _innerSigner = innerSigner; + + @override + bool get requiresInteractiveSigning => true; + + @override + bool get requiresSignerNetwork => _innerSigner.requiresSignerNetwork; + + @override + Iterable get signerTransportRelayUrls => + _innerSigner.signerTransportRelayUrls; + + @override + Future sign(Nip01Event event) async { + signAttempts++; + throw SignerRequestRejectedException( + requestId: 'refused-$signAttempts', + originalMessage: 'user rejected', + ); + } + + @override + String getPublicKey() => _innerSigner.getPublicKey(); + + @override + bool canSign() => _innerSigner.canSign(); + + @override + Future decrypt(String msg, String destPubKey) => + _innerSigner.decrypt(msg, destPubKey); + + @override + Future encrypt(String msg, String destPubKey) => + _innerSigner.encrypt(msg, destPubKey); + + @override + Future encryptNip44({ + required String plaintext, + required String recipientPubKey, + }) => + _innerSigner.encryptNip44( + plaintext: plaintext, + recipientPubKey: recipientPubKey, + ); + + @override + Future decryptNip44({ + required String ciphertext, + required String senderPubKey, + }) => + _innerSigner.decryptNip44( + ciphertext: ciphertext, + senderPubKey: senderPubKey, + ); + + @override + Stream> get pendingRequestsStream => + _innerSigner.pendingRequestsStream; + + @override + List get pendingRequests => + _innerSigner.pendingRequests; + + @override + bool cancelRequest(String requestId) => _innerSigner.cancelRequest(requestId); + + @override + Future dispose() => _innerSigner.dispose(); +} diff --git a/packages/ndk/test/usecases/nip77/nip77_auth_test.dart b/packages/ndk/test/usecases/nip77/nip77_auth_test.dart index e833cd682..c64eb67f6 100644 --- a/packages/ndk/test/usecases/nip77/nip77_auth_test.dart +++ b/packages/ndk/test/usecases/nip77/nip77_auth_test.dart @@ -3,6 +3,7 @@ import 'package:ndk/shared/nips/nip01/bip340.dart'; import 'package:ndk/shared/nips/nip01/key_pair.dart'; import 'package:test/test.dart'; +import '../../mocks/mock_refusing_signer.dart'; import '../../mocks/mock_relay.dart'; import '../../mocks/mock_slow_signer.dart'; @@ -275,5 +276,83 @@ void main() async { await ndk.destroy(); await relay.stopServer(); }); + + test('a refused signature ends the reconciliation', () async { + final relay = await negentropyRelay(port: portBase + 8); + final ndk = ndkFor(relay); + await Future.delayed(Duration(seconds: 1)); + + final refusing = MockRefusingSigner( + innerSigner: Bip340EventSigner( + privateKey: key1.privateKey!, + publicKey: key1.publicKey, + ), + ); + + // the timeout is paused while the signer holds the request, so a rejection + // that never came back would leave this session without any clock + final response = ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + auth: RelayAuth.require( + Account( + pubkey: key1.publicKey, + type: AccountType.privateKey, + signer: refusing, + ), + ), + timeout: Duration(seconds: 10), + ); + + await expectLater( + response.future, + throwsA(isA()), + ); + expect(relay.connectionsAuthenticatedAs(key1.publicKey), 0); + + await ndk.destroy(); + await relay.stopServer(); + }); + + test('a refused signature ends a reconciliation that started anonymously', + () async { + final relay = await negentropyRelay(port: portBase + 9); + final ndk = ndkFor(relay); + await Future.delayed(Duration(seconds: 1)); + + final refusing = MockRefusingSigner( + innerSigner: Bip340EventSigner( + privateKey: key1.privateKey!, + publicKey: key1.publicKey, + ), + ); + + final response = ndk.nip77.reconcile( + relayUrl: relay.url, + filter: notesOf(key1), + auth: RelayAuth.allow( + Account( + pubkey: key1.publicKey, + type: AccountType.privateKey, + signer: refusing, + ), + ), + timeout: Duration(seconds: 10), + ); + + await expectLater( + response.future, + throwsA(isA()), + ); + expect(relay.connectionsAuthenticatedAs(key1.publicKey), 0); + expect( + refusing.signAttempts, + greaterThan(0), + reason: 'the refusal has to come from a signature that was asked for', + ); + + await ndk.destroy(); + await relay.stopServer(); + }); }); }