Make pairing relay auth optional on mobile

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
2026-07-16 10:08:24 -04:00
parent 2c08271ba9
commit a6c20eb50d
2 changed files with 114 additions and 6 deletions

View File

@@ -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<void> 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 = <List<String>>[

View File

@@ -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 = <WebSocket>[];
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<List<dynamic>>();
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<dynamic>;
if (message.first == 'AUTH') {
receivedAuth.complete(message);
final event = message[1] as Map<String, dynamic>;
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<String, dynamic>;
expect(event['kind'], 22242);
expect(
event['tags'],
contains(
predicate<List<dynamic>>(
(tag) =>
tag.length == 2 &&
tag[0] == 'challenge' &&
tag[1] == 'challenge',
),
),
);
},
timeout: const Timeout(Duration(seconds: 5)),
);
}