diff --git a/mobile/lib/features/pairing/pairing_socket.dart b/mobile/lib/features/pairing/pairing_socket.dart index 1b8eb29320b..a985a3e5d91 100644 --- a/mobile/lib/features/pairing/pairing_socket.dart +++ b/mobile/lib/features/pairing/pairing_socket.dart @@ -6,6 +6,9 @@ import 'package:web_socket_channel/web_socket_channel.dart'; import '../../shared/relay/nostr_models.dart'; +const _desktopPairingAuthChallengeGrace = Duration(seconds: 3); +const _pairingAuthOkTimeout = Duration(seconds: 5); + /// Ephemeral WebSocket connection for NIP-AB pairing. /// /// Uses ephemeral keys for NIP-42 auth (not the stored user keys). @@ -35,7 +38,7 @@ class PairingSocket { bool get isConnected => _connected; - /// Connect and authenticate via NIP-42. + /// Connect to the pairing relay and answer NIP-42 auth when requested. Future connect() async { try { _channel = WebSocketChannel.connect(Uri.parse(_wsUrl)); @@ -59,23 +62,27 @@ class PairingSocket { }, ); - // Wait for auth with 8s timeout. - _authTimeout = Timer(const Duration(seconds: 8), () { + // Pairing relay auth is optional: the dedicated pairing relay is + // intentionally authless, while main relays may still request NIP-42. + // Match desktop's grace window: if no challenge arrives, proceed + // unauthenticated; if one arrives, wait for the auth OK below. + _authTimeout = Timer(_desktopPairingAuthChallengeGrace, () { if (_authCompleter != null && !_authCompleter!.isCompleted) { - _authCompleter!.completeError( - TimeoutException('NIP-42 auth timed out'), - ); + _authCompleter!.complete(); } }); try { await _authCompleter!.future; _authTimeout?.cancel(); + _authTimeout = null; _connected = true; } catch (e) { _authTimeout?.cancel(); + _authTimeout = null; await disconnect(); _onDisconnected(e); + rethrow; } } @@ -106,6 +113,7 @@ class PairingSocket { _subscription?.cancel(); _subscription = null; _authTimeout?.cancel(); + _authTimeout = null; final channel = _channel; _channel = null; if (channel != null) { @@ -155,6 +163,15 @@ class PairingSocket { if (data.length < 2) return; final challenge = data[1] as String; + // If a relay did challenge, keep waiting for the matching OK instead of + // falling through as unauthenticated at the grace deadline. + _authTimeout?.cancel(); + _authTimeout = Timer(_pairingAuthOkTimeout, () { + if (_authCompleter != null && !_authCompleter!.isCompleted) { + _failAuth(TimeoutException('NIP-42 auth OK timed out')); + } + }); + try { // Build NIP-42 auth event (kind:22242) with ephemeral keys. final tags = >[ diff --git a/mobile/test/features/pairing/pairing_socket_test.dart b/mobile/test/features/pairing/pairing_socket_test.dart new file mode 100644 index 00000000000..ef92430c6cd --- /dev/null +++ b/mobile/test/features/pairing/pairing_socket_test.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/features/pairing/pairing_socket.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'connect completes when pairing relay does not request auth', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + + final sockets = []; + server.listen((request) async { + final socket = await WebSocketTransformer.upgrade(request); + sockets.add(socket); + addTearDown(socket.close); + await for (final _ in socket) { + // This relay intentionally never sends AUTH. + } + }); + + final socket = PairingSocket( + wsUrl: 'ws://${server.address.host}:${server.port}', + ephemeralPrivkey: '1' * 64, + onMessage: (_) {}, + onDisconnected: (_) {}, + ); + addTearDown(socket.dispose); + + await socket.connect(); + + expect(socket.isConnected, isTrue); + expect(sockets, hasLength(1)); + }, + timeout: const Timeout(Duration(seconds: 5)), + ); + + test( + 'answers auth when relay requests NIP-42', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + + final receivedAuth = Completer>(); + server.listen((request) async { + final socket = await WebSocketTransformer.upgrade(request); + addTearDown(socket.close); + socket.add(jsonEncode(['AUTH', 'challenge'])); + await for (final raw in socket) { + final message = jsonDecode(raw as String) as List; + if (message.first == 'AUTH') { + receivedAuth.complete(message); + final event = message[1] as Map; + socket.add(jsonEncode(['OK', event['id'], true, ''])); + } + } + }); + + final socket = PairingSocket( + wsUrl: 'ws://${server.address.host}:${server.port}', + ephemeralPrivkey: '1' * 64, + onMessage: (_) {}, + onDisconnected: (_) {}, + ); + addTearDown(socket.dispose); + + await socket.connect(); + + expect(socket.isConnected, isTrue); + final auth = await receivedAuth.future; + expect(auth.first, 'AUTH'); + final event = auth[1] as Map; + expect(event['kind'], 22242); + expect( + event['tags'], + contains( + predicate>( + (tag) => + tag.length == 2 && + tag[0] == 'challenge' && + tag[1] == 'challenge', + ), + ), + ); + }, + timeout: const Timeout(Duration(seconds: 5)), + ); +}