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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions mobile/lib/features/pairing/pairing_socket.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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));
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -106,6 +113,7 @@ class PairingSocket {
_subscription?.cancel();
_subscription = null;
_authTimeout?.cancel();
_authTimeout = null;
final channel = _channel;
_channel = null;
if (channel != null) {
Expand Down Expand Up @@ -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>>[
Expand Down
91 changes: 91 additions & 0 deletions mobile/test/features/pairing/pairing_socket_test.dart
Original file line number Diff line number Diff line change
@@ -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)),
);
}