fix(mobile): support open pairing relays (#1939)

Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-07-16 16:03:25 +00:00
committed by GitHub
co-authored by npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu
parent 158259d953
commit a0081944ed
4 changed files with 424 additions and 39 deletions
@@ -57,10 +57,34 @@ class PairingState {
);
}
typedef PairingSocketFactory =
PairingSocket Function({
required String wsUrl,
required String ephemeralPrivkey,
required void Function(List<dynamic> message) onMessage,
required void Function(Object? error) onDisconnected,
});
class PairingNotifier extends Notifier<PairingState> {
final PairingSocketFactory _socketFactory;
PairingSocket? _socket;
Timer? _sessionTimeout;
PairingNotifier({PairingSocketFactory? socketFactory})
: _socketFactory = socketFactory ?? _createPairingSocket;
static PairingSocket _createPairingSocket({
required String wsUrl,
required String ephemeralPrivkey,
required void Function(List<dynamic> message) onMessage,
required void Function(Object? error) onDisconnected,
}) => PairingSocket(
wsUrl: wsUrl,
ephemeralPrivkey: ephemeralPrivkey,
onMessage: onMessage,
onDisconnected: onDisconnected,
);
@override
PairingState build() => const PairingState();
@@ -171,20 +195,21 @@ class PairingNotifier extends Notifier<PairingState> {
);
// 4. Connect to relay with ephemeral keys.
_socket = PairingSocket(
final socket = _socketFactory(
wsUrl: relayWsUrl,
ephemeralPrivkey: _ephemeralPrivkey!,
onMessage: _handleRelayMessage,
onDisconnected: _handleDisconnected,
);
await _socket!.connect();
_socket = socket;
await socket.connect();
if (!_socket!.isConnected) {
throw Exception('Failed to connect to pairing relay');
if (!socket.isConnected) {
throw StateError('Pairing socket did not reach the connected state');
}
// 5. Subscribe for kind:24134 events tagged to our ephemeral pubkey.
_socket!.subscribe('pair', 24134, _ephemeralPubkey!);
socket.subscribe('pair', 24134, _ephemeralPubkey!);
// 6. Wait briefly for EOSE, then send offer.
// (In practice, we send the offer immediately — the relay will buffer it.)
@@ -244,11 +269,18 @@ class PairingNotifier extends Notifier<PairingState> {
message.contains('Connection refused') ||
message.contains('Network is unreachable') ||
message.contains('No route to host') ||
message.contains('Failed to connect') ||
message.contains('Null check operator used on a null value')) {
message.contains('Failed to connect')) {
return 'Could not reach the pairing relay. Check your internet '
'connection and VPN, then try again.';
}
if (error is PairingAuthException) {
return 'The pairing relay rejected authentication. Try creating a new '
'pairing code.';
}
if (error is StateError ||
message.contains('Null check operator used on a null value')) {
return 'Pairing stopped because of an internal error. Please try again.';
}
if (message.contains('HandshakeException') ||
message.contains('CERTIFICATE_VERIFY_FAILED')) {
return 'Secure connection failed. Check your network settings '
+63 -32
View File
@@ -6,20 +6,36 @@ import 'package:web_socket_channel/web_socket_channel.dart';
import '../../shared/relay/nostr_models.dart';
const _desktopPairingAuthChallengeGrace = Duration(seconds: 3);
const _pairingAuthOkTimeout = Duration(seconds: 8);
/// Ephemeral WebSocket connection for NIP-AB pairing.
///
/// Uses ephemeral keys for NIP-42 auth (not the stored user keys).
/// Single-use — disposed after the pairing session completes.
class PairingAuthException implements Exception {
final String message;
const PairingAuthException(this.message);
@override
String toString() => 'PairingAuthException: $message';
}
class PairingSocket {
final String _wsUrl;
final String _ephemeralPrivkey;
final void Function(List<dynamic> message) _onMessage;
final void Function(Object? error) _onDisconnected;
final Duration _authChallengeTimeout;
final Duration _authResponseTimeout;
final WebSocketChannel Function(Uri uri) _channelFactory;
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _subscription;
Completer<void>? _authCompleter;
Timer? _authTimeout;
Timer? _authChallengeTimer;
Timer? _authResponseTimer;
String? _pendingAuthEventId;
bool _connected = false;
@@ -28,54 +44,52 @@ class PairingSocket {
required String ephemeralPrivkey,
required void Function(List<dynamic> message) onMessage,
required void Function(Object? error) onDisconnected,
Duration authChallengeTimeout = _desktopPairingAuthChallengeGrace,
Duration authResponseTimeout = _pairingAuthOkTimeout,
WebSocketChannel Function(Uri uri) channelFactory =
WebSocketChannel.connect,
}) : _wsUrl = wsUrl,
_ephemeralPrivkey = ephemeralPrivkey,
_onMessage = onMessage,
_onDisconnected = onDisconnected;
_onDisconnected = onDisconnected,
_authChallengeTimeout = authChallengeTimeout,
_authResponseTimeout = authResponseTimeout,
_channelFactory = channelFactory;
bool get isConnected => _connected;
/// Connect and authenticate via NIP-42.
/// Connect and answer a NIP-42 challenge when the relay requires one.
Future<void> connect() async {
try {
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl));
await _channel!.ready;
} catch (e) {
_onDisconnected(e);
return;
}
_channel = _channelFactory(Uri.parse(_wsUrl));
await _channel!.ready;
_authCompleter = Completer<void>();
_subscription = _channel!.stream.listen(
_handleRawMessage,
onError: (Object error) {
_failAuth(error);
_onDisconnected(error);
},
onDone: () {
_failAuth(null);
_onDisconnected(null);
},
onError: _failAuth,
onDone: () => _failAuth(null),
);
// Wait for auth with 8s timeout.
_authTimeout = Timer(const Duration(seconds: 8), () {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.completeError(
TimeoutException('NIP-42 auth timed out'),
);
// Dedicated pairing relays may be open and send no NIP-42 challenge.
_authChallengeTimer = Timer(_authChallengeTimeout, () {
if (_pendingAuthEventId == null &&
_authCompleter != null &&
!_authCompleter!.isCompleted) {
_authCompleter!.complete();
}
});
try {
await _authCompleter!.future;
_authTimeout?.cancel();
_connected = true;
} catch (e) {
_authTimeout?.cancel();
} catch (error) {
await disconnect();
_onDisconnected(e);
_onDisconnected(error);
rethrow;
} finally {
_authChallengeTimer?.cancel();
_authResponseTimer?.cancel();
}
}
@@ -105,7 +119,8 @@ class PairingSocket {
_connected = false;
_subscription?.cancel();
_subscription = null;
_authTimeout?.cancel();
_authChallengeTimer?.cancel();
_authResponseTimer?.cancel();
final channel = _channel;
_channel = null;
if (channel != null) {
@@ -118,12 +133,19 @@ class PairingSocket {
_subscription?.cancel();
_channel?.sink.close();
_channel = null;
_authTimeout?.cancel();
_authChallengeTimer?.cancel();
_authResponseTimer?.cancel();
}
void _failAuth(Object? error) {
final authError = error ?? Exception('Connection closed');
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.completeError(error ?? Exception('Connection closed'));
_authCompleter!.completeError(authError);
return;
}
if (_connected) {
unawaited(disconnect());
_onDisconnected(authError);
}
}
@@ -155,6 +177,14 @@ class PairingSocket {
if (data.length < 2) return;
final challenge = data[1] as String;
_authChallengeTimer?.cancel();
_authResponseTimer?.cancel();
_authResponseTimer = Timer(_authResponseTimeout, () {
_failAuth(
const PairingAuthException('Relay did not confirm authentication'),
);
});
try {
// Build NIP-42 auth event (kind:22242) with ephemeral keys.
final tags = <List<String>>[
@@ -184,6 +214,7 @@ class PairingSocket {
if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) {
_pendingAuthEventId = null;
_authResponseTimer?.cancel();
if (accepted) {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.complete();
@@ -192,7 +223,7 @@ class PairingSocket {
final message = data.length > 3
? data[3] as String
: 'Auth rejected by relay';
_failAuth(Exception(message));
_failAuth(PairingAuthException(message));
}
return;
}
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/features/pairing/pairing_socket.dart';
import 'package:buzz/shared/auth/auth.dart';
/// Tests for [PairingNotifier]'s legacy `buzz://` payload parsing and
@@ -43,6 +44,36 @@ void main() {
expect(state.errorMessage, isNull);
});
test(
'disconnect during connect does not null-dereference the socket',
() async {
final notifier = PairingNotifier(
socketFactory:
({
required wsUrl,
required ephemeralPrivkey,
required onMessage,
required void Function(Object? error) onDisconnected,
}) => _DisconnectingSocket(disconnectCallback: onDisconnected),
);
container = ProviderContainer(
overrides: [pairingProvider.overrideWith(() => notifier)],
);
const code =
'nostrpair://62287897da61e3fa294b4570575f7db8bea147d6631150f2e4656714c645fb1e'
'?secret=abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'
'&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1';
await container.read(pairingProvider.notifier).pair(code);
expect(container.read(pairingProvider).status, PairingStatus.error);
expect(
container.read(pairingProvider).errorMessage,
contains('internal error'),
);
},
);
test('payload missing nsec errors before contacting relay', () async {
container = createContainer();
@@ -192,3 +223,21 @@ class FakeAuthNotifier extends AsyncNotifier<AuthState>
);
}
}
class _DisconnectingSocket extends PairingSocket {
final void Function(Object? error) disconnectCallback;
_DisconnectingSocket({required this.disconnectCallback})
: super(
wsUrl: 'ws://unused',
ephemeralPrivkey:
'09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506',
onMessage: (_) {},
onDisconnected: (_) {},
);
@override
Future<void> connect() async {
disconnectCallback(Exception('Connection closed'));
}
}
@@ -0,0 +1,273 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:buzz/features/pairing/pairing_socket.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
const _privateKey =
'09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506';
void main() {
group('PairingSocket', () {
test('connects when the pairing relay sends no AUTH challenge', () async {
final server = await _TestRelay.start((_) {});
addTearDown(server.close);
final socket = _socket(
server.url,
authChallengeTimeout: const Duration(milliseconds: 30),
);
addTearDown(socket.disconnect);
await socket.connect();
expect(socket.isConnected, isTrue);
});
test('answers an AUTH challenge and requires an accepted OK', () async {
final authReceived = Completer<List<dynamic>>();
final server = await _TestRelay.start((webSocket) async {
webSocket.add(jsonEncode(['AUTH', 'challenge']));
final auth =
jsonDecode(await webSocket.first as String) as List<dynamic>;
authReceived.complete(auth);
final event = auth[1] as Map<String, dynamic>;
webSocket.add(jsonEncode(['OK', event['id'], true, 'authenticated']));
});
addTearDown(server.close);
final socket = _socket(server.url);
addTearDown(socket.disconnect);
await socket.connect();
expect(socket.isConnected, isTrue);
expect((await authReceived.future).first, 'AUTH');
});
test('fails when the pairing relay rejects AUTH', () async {
final server = await _TestRelay.start((webSocket) async {
webSocket.add(jsonEncode(['AUTH', 'challenge']));
final auth =
jsonDecode(await webSocket.first as String) as List<dynamic>;
final event = auth[1] as Map<String, dynamic>;
webSocket.add(jsonEncode(['OK', event['id'], false, 'bad auth']));
});
addTearDown(server.close);
var disconnectCount = 0;
final socket = _socket(
server.url,
onDisconnected: (_) => disconnectCount++,
);
addTearDown(socket.disconnect);
await expectLater(socket.connect(), throwsA(isA<PairingAuthException>()));
expect(socket.isConnected, isFalse);
expect(disconnectCount, 1);
});
test(
'answers a challenge after the optional AUTH wait completes',
() async {
final authReceived = Completer<void>();
final server = await _TestRelay.start((webSocket) async {
await Future<void>.delayed(const Duration(milliseconds: 80));
webSocket.add(jsonEncode(['AUTH', 'late-challenge']));
final auth =
jsonDecode(await webSocket.first as String) as List<dynamic>;
final event = auth[1] as Map<String, dynamic>;
webSocket.add(jsonEncode(['OK', event['id'], true, 'authenticated']));
authReceived.complete();
});
addTearDown(server.close);
final socket = _socket(
server.url,
authChallengeTimeout: const Duration(milliseconds: 30),
);
addTearDown(socket.disconnect);
await socket.connect();
await authReceived.future;
expect(socket.isConnected, isTrue);
},
);
test('fails when AUTH receives no OK response', () async {
final server = await _TestRelay.start((webSocket) {
webSocket.add(jsonEncode(['AUTH', 'challenge']));
});
addTearDown(server.close);
final socket = _socket(
server.url,
authResponseTimeout: const Duration(milliseconds: 100),
);
addTearDown(socket.disconnect);
await expectLater(socket.connect(), throwsA(isA<PairingAuthException>()));
expect(socket.isConnected, isFalse);
});
test('notifies once when a connected stream emits an error', () async {
var disconnectCount = 0;
final channel = _ControlledWebSocketChannel();
final socket = _socket(
'ws://unused',
onDisconnected: (_) => disconnectCount++,
channelFactory: (_) => channel,
authChallengeTimeout: Duration.zero,
);
addTearDown(socket.disconnect);
await socket.connect();
channel.emitError(Exception('stream failed'));
await Future<void>.delayed(Duration.zero);
expect(disconnectCount, 1);
});
test('notifies once when a connected stream closes', () async {
var disconnectCount = 0;
final channel = _ControlledWebSocketChannel();
final socket = _socket(
'ws://unused',
onDisconnected: (_) => disconnectCount++,
channelFactory: (_) => channel,
authChallengeTimeout: Duration.zero,
);
addTearDown(socket.disconnect);
await socket.connect();
await channel.closeStream();
expect(disconnectCount, 1);
});
test(
'does not notify when deliberately disconnected or disposed',
() async {
var disconnectCount = 0;
final disconnectChannel = _ControlledWebSocketChannel();
final disconnectingSocket = _socket(
'ws://unused',
onDisconnected: (_) => disconnectCount++,
channelFactory: (_) => disconnectChannel,
authChallengeTimeout: Duration.zero,
);
await disconnectingSocket.connect();
await disconnectingSocket.disconnect();
final disposeChannel = _ControlledWebSocketChannel();
final disposingSocket = _socket(
'ws://unused',
onDisconnected: (_) => disconnectCount++,
channelFactory: (_) => disposeChannel,
authChallengeTimeout: Duration.zero,
);
await disposingSocket.connect();
disposingSocket.dispose();
await Future<void>.delayed(Duration.zero);
expect(disconnectCount, 0);
},
);
});
}
PairingSocket _socket(
String url, {
Duration authChallengeTimeout = const Duration(milliseconds: 500),
Duration authResponseTimeout = const Duration(seconds: 10),
void Function(Object? error)? onDisconnected,
WebSocketChannel Function(Uri uri)? channelFactory,
}) => PairingSocket(
wsUrl: url,
ephemeralPrivkey: _privateKey,
onMessage: (_) {},
onDisconnected: onDisconnected ?? (_) {},
authChallengeTimeout: authChallengeTimeout,
authResponseTimeout: authResponseTimeout,
channelFactory: channelFactory ?? WebSocketChannel.connect,
);
class _ControlledWebSocketChannel implements WebSocketChannel {
final StreamController<dynamic> _streamController = StreamController();
final WebSocketSink _sink = _ControlledWebSocketSink();
void emitError(Object error) => _streamController.addError(error);
Future<void> closeStream() => _streamController.close();
@override
Future<void> get ready => Future.value();
@override
Stream<dynamic> get stream => _streamController.stream;
@override
WebSocketSink get sink => _sink;
@override
int? get closeCode => null;
@override
String? get closeReason => null;
@override
String? get protocol => null;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ControlledWebSocketSink implements WebSocketSink {
@override
void add(dynamic event) {}
@override
void addError(Object error, [StackTrace? stackTrace]) {}
@override
Future<void> addStream(Stream<dynamic> stream) async {
await stream.drain<void>();
}
@override
Future<void> close([int? closeCode, String? closeReason]) async {}
@override
Future<void> get done => Future.value();
}
class _TestRelay {
final HttpServer _server;
final List<WebSocket> _sockets = [];
_TestRelay._(this._server);
String get url => 'ws://${_server.address.host}:${_server.port}';
static Future<_TestRelay> start(
FutureOr<void> Function(WebSocket socket) onConnected,
) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final relay = _TestRelay._(server);
server.listen((request) async {
final socket = await WebSocketTransformer.upgrade(request);
relay._sockets.add(socket);
await onConnected(socket);
});
return relay;
}
Future<void> close() async {
for (final socket in _sockets) {
await socket.close();
}
await _server.close(force: true);
}
}