mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
## Problem Pairing a phone to an organization relay by QR (for example Block's `buzz.block.builderlab.xyz`) never finishes. After the codes match and the phone confirms, it sits on the loading surface. About 2 minutes later it returns to the welcome screen with "Lost connection to pairing relay." The desktop stays on "Finishing pairing on your phone…". This reproduces every time on Buzz 0.27.0, iOS 27.2, with desktop 0.7.7 (internal tracking: AIDA-2420). There are two causes: 1. **Validation can never pass for organization relays.** After the payload arrives, `_validateCredentials` opens a plain `RelaySocket` without a `Nostr-Federated-Identity` assertion. Organization relays refuse that upgrade with HTTP 401 until organization sign-in, which a freshly pairing phone hasn't done. 2. **The failure hangs instead of erroring.** `RelaySocket.connect()` catches the failed `ready` but keeps `_channel`. The validator's `finally` then awaits `disconnect()` → `sink.close()` on a channel that never opened, and that never completes. The phone waits until the pairing relay's 120 s connection cap closes the socket, and `_handleDisconnected` reports that as a lost connection. Standalone repro with `web_socket_channel` 3.0.3 against the real relay: ``` ready failed: ... was not upgraded to websocket, HTTP status code: 401 sink.close() DID NOT COMPLETE within 30s (hang) ``` ## Change - `RelaySocket.connect()` drops a channel whose upgrade failed. - `disconnect()` waits at most `RelaySocket.closeTimeout` (2 s). An upgrade that is still pending (connect timeout 10 s) can outlive pairing's 8 s validation deadline, and its close never completes either. - `PairingNotifier` skips the pre-sign-in check for `OrganizationEnvironment.protects` relays. Admission is left to the saved community's session and the existing organization sign-in banner, which matches how the hosted branch already defers admission. The predicate is injectable (`organizationRelay`) for tests. Other relays are still validated before saving. Trade-off: an identity that the organization relay would reject is now saved, and that rejection only surfaces after organization sign-in. The hosted path has the same property. ## Related issue No existing GitHub issue covers this failure; searched open issues and PRs for pairing relay, lost connection and `_validateCredentials`. Closest: - #8043 / #8060 cover the same `_validateCredentials` path from the other side: a failed connect currently returns normally. They're complementary. With #8060 merged, organization relays would fail pairing with an explicit 401 instead of hanging, and this PR keeps them out of that pre-check. Whichever merges second needs a small rebase in `_validateCredentials`. - #3181 is a different failure in the same flow. ## Testing - New `relay_socket_liveness_test` cases check that `disconnect()` completes in three situations: after a refused (401) upgrade, after a timed-out handshake, and while the upgrade is still pending. - New `pairing_provider_test` case: a malformed key is rejected locally before anything is saved, an organization relay is saved without the relay check, and a non-organization relay is still validated and rejected. - Each new test fails with its fix removed. The failures were "disconnect() hung on a channel that never opened", "disconnect() hung on a pending upgrade", and `validated` being true. - `just mobile-check` and `just mobile-test` pass: 4229 passed, 40 skipped. - **Simulator run (iOS 27.0 simulator, iPhone 18 Pro, Block organization dart-defines) against desktop 0.7.7 on `buzz.block.builderlab.xyz`:** pairing completed (desktop reported the phone paired; identity transferred; community saved) instead of hanging until the pairing relay closed. The app then showed the organization sign-in banner and opened browser sign-in for block.builderlab.xyz, as expected. Okta in the simulator requires FastPass, so the final relay connection after sign-in was not exercised there. - Human device testing is still pending. ## Follow-up - Test on a real phone with an organization build. The simulator couldn't complete Okta FastPass, so the relay connection after organization sign-in still needs checking. --------- Signed-off-by: Spencer Lewis <slewis@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
8.3 KiB
Dart
295 lines
8.3 KiB
Dart
import 'dart:io';
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:web_socket_channel/io.dart';
|
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
|
|
import 'nostr_models.dart';
|
|
import 'relay_websocket.dart';
|
|
import 'package:buzz/shared/crypto/event_signing.dart';
|
|
|
|
/// Low-level websocket connection with NIP-42 authentication.
|
|
///
|
|
/// Handles the raw websocket lifecycle: connect, authenticate via NIP-42
|
|
/// challenge/response, send/receive JSON frames, and disconnect.
|
|
///
|
|
/// Does NOT handle reconnection — that is [RelaySessionNotifier]'s job.
|
|
enum SocketState { disconnected, connecting, authenticating, connected }
|
|
|
|
class RelayAuthRejectedException implements Exception {
|
|
final String message;
|
|
|
|
const RelayAuthRejectedException(this.message);
|
|
|
|
@override
|
|
String toString() => 'Relay authentication rejected: $message';
|
|
}
|
|
|
|
Exception classifyRelayAuthFailure(String message) {
|
|
if (message.startsWith('error:')) return Exception(message);
|
|
return RelayAuthRejectedException(message);
|
|
}
|
|
|
|
class RelaySocket {
|
|
/// Interval for sending a ping and awaiting its pong before disconnecting.
|
|
static const pingInterval = Duration(seconds: 30);
|
|
|
|
@visibleForTesting
|
|
static Duration debugPingInterval = pingInterval;
|
|
|
|
@visibleForTesting
|
|
static Duration debugConnectTimeout = relayWebSocketConnectTimeout;
|
|
|
|
final String _wsUrl;
|
|
final String? _nsec;
|
|
final void Function(List<dynamic> message) _onMessage;
|
|
final void Function() _onConnected;
|
|
final void Function(Object? error) _onDisconnected;
|
|
|
|
WebSocketChannel? _channel;
|
|
StreamSubscription<dynamic>? _subscription;
|
|
SocketState _state = SocketState.disconnected;
|
|
Completer<void>? _authCompleter;
|
|
Timer? _authTimeout;
|
|
String? _pendingAuthEventId;
|
|
int? _closeCode;
|
|
|
|
SocketState get state => _state;
|
|
|
|
/// The WebSocket close code the relay sent, once it has closed the
|
|
/// connection; null if the connection failed without one.
|
|
int? get closeCode => _closeCode;
|
|
|
|
/// Scoped assertion supplied by the connection owner before connecting.
|
|
Map<String, String> headers = const {};
|
|
|
|
RelaySocket({
|
|
required String wsUrl,
|
|
required String? nsec,
|
|
required void Function(List<dynamic> message) onMessage,
|
|
required void Function() onConnected,
|
|
required void Function(Object? error) onDisconnected,
|
|
}) : _wsUrl = wsUrl,
|
|
_nsec = nsec,
|
|
_onMessage = onMessage,
|
|
_onConnected = onConnected,
|
|
_onDisconnected = onDisconnected;
|
|
|
|
/// Connect to the relay and complete NIP-42 authentication.
|
|
Future<void> connect() async {
|
|
if (_state != SocketState.disconnected) return;
|
|
_state = SocketState.connecting;
|
|
|
|
try {
|
|
_channel = IOWebSocketChannel(
|
|
openRelayWebSocket(
|
|
Uri.parse(_wsUrl),
|
|
headers: headers,
|
|
pingInterval: debugPingInterval,
|
|
connectTimeout: debugConnectTimeout,
|
|
),
|
|
);
|
|
await _channel!.ready;
|
|
} catch (e) {
|
|
// A channel that never opened never completes sink.close(), so a later
|
|
// disconnect() must not await it.
|
|
_channel = null;
|
|
_state = SocketState.disconnected;
|
|
_onDisconnected(e);
|
|
return;
|
|
}
|
|
|
|
// The channel may have been disposed while we were awaiting ready
|
|
// (e.g. provider rebuild triggered dispose() concurrently).
|
|
if (_channel == null) {
|
|
_state = SocketState.disconnected;
|
|
return;
|
|
}
|
|
|
|
_state = SocketState.authenticating;
|
|
_authCompleter = Completer<void>();
|
|
|
|
_subscription = _channel!.stream.listen(
|
|
_handleRawMessage,
|
|
onError: (Object error) {
|
|
_failAuth(error);
|
|
_resetConnection();
|
|
_onDisconnected(error);
|
|
},
|
|
onDone: () {
|
|
_closeCode = _channel?.closeCode;
|
|
_failAuth(null);
|
|
_resetConnection();
|
|
_onDisconnected(null);
|
|
},
|
|
);
|
|
|
|
// Wait for auth to complete (or timeout).
|
|
_authTimeout = Timer(const Duration(seconds: 8), () {
|
|
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
|
_authCompleter!.completeError(
|
|
TimeoutException('NIP-42 auth timed out after 8s'),
|
|
);
|
|
}
|
|
});
|
|
|
|
try {
|
|
await _authCompleter!.future;
|
|
_authTimeout?.cancel();
|
|
_state = SocketState.connected;
|
|
_onConnected();
|
|
} catch (e) {
|
|
_authTimeout?.cancel();
|
|
await disconnect();
|
|
_onDisconnected(e);
|
|
}
|
|
}
|
|
|
|
/// Send a raw JSON array over the websocket.
|
|
void send(List<dynamic> payload) {
|
|
_channel?.sink.add(jsonEncode(payload));
|
|
}
|
|
|
|
/// Upper bound on a graceful close. A channel whose upgrade is still pending
|
|
/// never completes sink.close(), and callers await disconnect() in cleanup.
|
|
static const closeTimeout = Duration(seconds: 2);
|
|
|
|
/// Gracefully close the connection, waiting at most [closeTimeout].
|
|
Future<void> disconnect() async {
|
|
_resetConnection();
|
|
final channel = _channel;
|
|
_channel = null;
|
|
if (channel != null) {
|
|
await channel.sink.close().timeout(closeTimeout, onTimeout: () {});
|
|
}
|
|
}
|
|
|
|
void dispose() {
|
|
_resetConnection();
|
|
_channel?.sink.close();
|
|
_channel = null;
|
|
}
|
|
|
|
void _resetConnection() {
|
|
_state = SocketState.disconnected;
|
|
_subscription?.cancel();
|
|
_subscription = null;
|
|
_authTimeout?.cancel();
|
|
_authTimeout = null;
|
|
_pendingAuthEventId = null;
|
|
}
|
|
|
|
void _failAuth(Object? error) {
|
|
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
|
_authCompleter!.completeError(error ?? Exception('Connection closed'));
|
|
}
|
|
}
|
|
|
|
void _handleRawMessage(dynamic raw) {
|
|
final String text;
|
|
if (raw is String) {
|
|
text = raw;
|
|
} else {
|
|
return; // Binary frames are not part of the Nostr protocol.
|
|
}
|
|
|
|
final List<dynamic> data;
|
|
try {
|
|
data = jsonDecode(text) as List<dynamic>;
|
|
} catch (_) {
|
|
return; // Malformed JSON.
|
|
}
|
|
|
|
if (data.isEmpty) return;
|
|
final type = data[0] as String;
|
|
|
|
switch (type) {
|
|
case 'AUTH':
|
|
_handleAuthChallenge(data);
|
|
case 'OK':
|
|
_handleOk(data);
|
|
default:
|
|
// Pass EVENT, EOSE, NOTICE, etc. upstream.
|
|
_onMessage(data);
|
|
}
|
|
}
|
|
|
|
/// Handle the relay's AUTH challenge: sign a kind:22242 event and respond.
|
|
void _handleAuthChallenge(List<dynamic> data) {
|
|
if (data.length < 2) return;
|
|
final challenge = data[1] as String;
|
|
|
|
if (_nsec == null) {
|
|
_failAuth(Exception('No nsec available for NIP-42 auth'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Decode bech32 nsec to hex private key.
|
|
final privkeyHex = nostr.Nip19.decode(payload: _nsec).data;
|
|
if (privkeyHex.isEmpty) {
|
|
_failAuth(Exception('Invalid nsec'));
|
|
return;
|
|
}
|
|
|
|
// Build the auth tags.
|
|
final tags = <List<String>>[
|
|
['relay', _wsUrl],
|
|
['challenge', challenge],
|
|
];
|
|
|
|
// Create and sign the kind:22242 AUTH event.
|
|
final event = signNostrEvent(
|
|
kind: EventKind.auth,
|
|
content: '',
|
|
tags: tags,
|
|
secretKey: privkeyHex,
|
|
);
|
|
|
|
_pendingAuthEventId = event.id;
|
|
send(['AUTH', event.toMap()]);
|
|
} catch (e) {
|
|
_failAuth(e);
|
|
}
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugHandleOkForTest(List<dynamic> data) => _onMessage(data);
|
|
|
|
/// Handle OK frames. During auth, complete the auth flow.
|
|
void _handleOk(List<dynamic> data) {
|
|
if (data.length < 3) return;
|
|
final eventId = data[1] as String;
|
|
final accepted = data[2] as bool;
|
|
|
|
// Check if this OK is for our pending AUTH event.
|
|
if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) {
|
|
_pendingAuthEventId = null;
|
|
if (accepted) {
|
|
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
|
_authCompleter!.complete();
|
|
}
|
|
} else {
|
|
final message = data.length > 3
|
|
? data[3] as String
|
|
: 'Auth rejected by relay';
|
|
_failAuth(classifyRelayAuthFailure(message));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Pass non-auth OK frames upstream for pending event tracking.
|
|
_onMessage(data);
|
|
}
|
|
}
|
|
|
|
/// HTTP upgrade failures retain their status through WebSocketChannel.
|
|
bool relayUpgradeRefused(Object? error) {
|
|
if (error is WebSocketChannelException) error = error.inner;
|
|
return error is WebSocketException &&
|
|
(error.httpStatusCode == 401 || error.httpStatusCode == 403);
|
|
}
|