mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
Mobile signs every Nostr event (sends, relay AUTH, media auth headers, preference writes) in pure Dart on the UI isolate. On an iPhone SE (2nd gen, Profile build) one BIP-340 signature costs about 10.7 ms and deriving the public key another 5.6 ms, so a burst of signed writes drops frames. This adds `mobile/packages/buzz_secp256k1`, a small FFI package that builds vendored bitcoin-core/libsecp256k1 v0.8.0 (ecdh, extrakeys, schnorrsig modules) through a Dart build hook, so the same C and the same Dart API ship on iOS and Android with no platform bridge code. All 13 `nostr.Event.from` signing sites now go through `signNostrEvent`, which keeps the `nostr` package's event-id serialization and moves only key derivation and signing to native code. Calls stay synchronous. The package also exposes `schnorrVerify` and `ecdhX`; follow-up PRs move verification and the NIP-44 conversation key over. Incoming relay events are still not verified, unchanged by this PR. Safety: the signing context is randomized once per isolate, secret keys and keypairs are wiped from the native scratch buffer after each call, aux randomness comes from `Random.secure()`, and the static verify context runs libsecp256k1's self test before first use. ### Validation - Added tests: BIP-340 official vectors and native-vs-pure-Dart differential tests in the package; `event_signing_test.dart` signs with 64 random keys and checks pubkey, event id and signature against the existing `nostr` builder and verifier, plus malformed-key rejection. - Full mobile analyze and test suite pass locally. - Builds: iOS release (framework ~1.3 MB) and Android debug APK with the library for arm64-v8a, armeabi-v7a and x86_64 (~1.2–1.3 MB each). - On-device (iPhone SE 2nd gen, Profile, median per op): sign 10,736 µs → 41 µs, derive pubkey 5,580 µs → 16.5 µs, full signed event 10,744 µs → 65 µs. Event-id JSON and SHA-256 are 23 µs, so they stay in Dart. - Device, iOS (iPhone SE 2nd gen, Profile): `buzz_secp256k1.framework` embedded; the signed-in app authenticated to a real community relay, loaded channels and history, and paged older messages. No native-asset or secp256k1 errors in the console. - Device, Android (emulator, debug): APK contains `libbuzz_secp256k1.so` and loads it. Against a local mock relay that verifies with the pure-Dart `nostr` package, the app's NIP-42 AUTH, NIP-98 `POST /query` headers, and kind 20001/30078 events all verified. - Blossom GET auth, iOS: the dev relay returns 401 for unauthenticated `/media/` GETs. From inside the running app, the production `MediaGetAuthService.headersFor()` header for a nonexistent hash got 404 (accepted, file missing), compared with 401 without it. The community has no relay-hosted image, so none was rendered. buzz-review-completed (agent review, Codex and security review clean; device validation above; human app test not applicable under the mobile testing exception) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
6.9 KiB
Dart
255 lines
6.9 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
|
|
import '../../shared/relay/nostr_models.dart';
|
|
import 'package:buzz/shared/crypto/event_signing.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? _authChallengeTimer;
|
|
Timer? _authResponseTimer;
|
|
String? _pendingAuthEventId;
|
|
bool _connected = false;
|
|
final Completer<void> _closed = Completer<void>();
|
|
|
|
PairingSocket({
|
|
required String wsUrl,
|
|
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,
|
|
_authChallengeTimeout = authChallengeTimeout,
|
|
_authResponseTimeout = authResponseTimeout,
|
|
_channelFactory = channelFactory;
|
|
|
|
bool get isConnected => _connected;
|
|
|
|
/// Connect and answer a NIP-42 challenge when the relay requires one.
|
|
Future<void> connect() async {
|
|
if (_closed.isCompleted) {
|
|
throw const PairingAuthException('Pairing connection cancelled');
|
|
}
|
|
final channel = _channelFactory(Uri.parse(_wsUrl));
|
|
_channel = channel;
|
|
await Future.any([channel.ready, _closed.future]);
|
|
if (_closed.isCompleted) {
|
|
throw const PairingAuthException('Pairing connection cancelled');
|
|
}
|
|
|
|
_authCompleter = Completer<void>();
|
|
|
|
_subscription = _channel!.stream.listen(
|
|
_handleRawMessage,
|
|
onError: _failAuth,
|
|
onDone: () => _failAuth(null),
|
|
);
|
|
|
|
// 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;
|
|
if (_closed.isCompleted) {
|
|
throw const PairingAuthException('Pairing connection cancelled');
|
|
}
|
|
_connected = true;
|
|
} catch (error) {
|
|
final deliberatelyClosed = _closed.isCompleted;
|
|
await disconnect();
|
|
if (!deliberatelyClosed) _onDisconnected(error);
|
|
rethrow;
|
|
} finally {
|
|
_authChallengeTimer?.cancel();
|
|
_authResponseTimer?.cancel();
|
|
}
|
|
}
|
|
|
|
/// Send a raw JSON array.
|
|
void send(List<dynamic> payload) {
|
|
_channel?.sink.add(jsonEncode(payload));
|
|
}
|
|
|
|
/// Send a subscribe request.
|
|
void subscribe(String subId, int kind, String pubkeyHex) {
|
|
send([
|
|
'REQ',
|
|
subId,
|
|
{
|
|
'kinds': [kind],
|
|
'#p': [pubkeyHex],
|
|
},
|
|
]);
|
|
}
|
|
|
|
/// Publish a Nostr event (already JSON-encoded map).
|
|
void publishEvent(Map<String, dynamic> event) {
|
|
send(['EVENT', event]);
|
|
}
|
|
|
|
Future<void> disconnect() async {
|
|
_retireConnection();
|
|
_connected = false;
|
|
_subscription?.cancel();
|
|
_subscription = null;
|
|
_authChallengeTimer?.cancel();
|
|
_authResponseTimer?.cancel();
|
|
final channel = _channel;
|
|
_channel = null;
|
|
if (channel != null) {
|
|
await channel.sink.close();
|
|
}
|
|
}
|
|
|
|
void dispose() {
|
|
_retireConnection();
|
|
_connected = false;
|
|
_subscription?.cancel();
|
|
_channel?.sink.close();
|
|
_channel = null;
|
|
_authChallengeTimer?.cancel();
|
|
_authResponseTimer?.cancel();
|
|
}
|
|
|
|
void _retireConnection() {
|
|
if (!_closed.isCompleted) _closed.complete();
|
|
final auth = _authCompleter;
|
|
if (auth != null && !auth.isCompleted) auth.complete();
|
|
}
|
|
|
|
void _failAuth(Object? error) {
|
|
final authError = error ?? Exception('Connection closed');
|
|
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
|
_authCompleter!.completeError(authError);
|
|
return;
|
|
}
|
|
if (_connected) {
|
|
unawaited(disconnect());
|
|
_onDisconnected(authError);
|
|
}
|
|
}
|
|
|
|
void _handleRawMessage(dynamic raw) {
|
|
if (raw is! String) return;
|
|
|
|
final List<dynamic> data;
|
|
try {
|
|
data = jsonDecode(raw) as List<dynamic>;
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
|
|
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 upstream.
|
|
_onMessage(data);
|
|
}
|
|
}
|
|
|
|
void _handleAuthChallenge(List<dynamic> data) {
|
|
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>>[
|
|
['relay', _wsUrl],
|
|
['challenge', challenge],
|
|
];
|
|
|
|
final event = signNostrEvent(
|
|
kind: EventKind.auth,
|
|
content: '',
|
|
tags: tags,
|
|
secretKey: _ephemeralPrivkey,
|
|
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
);
|
|
|
|
_pendingAuthEventId = event.id;
|
|
send(['AUTH', event.toMap()]);
|
|
} catch (e) {
|
|
_failAuth(e);
|
|
}
|
|
}
|
|
|
|
void _handleOk(List<dynamic> data) {
|
|
if (data.length < 3) return;
|
|
final eventId = data[1] as String;
|
|
final accepted = data[2] as bool;
|
|
|
|
if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) {
|
|
_pendingAuthEventId = null;
|
|
_authResponseTimer?.cancel();
|
|
if (accepted) {
|
|
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
|
_authCompleter!.complete();
|
|
}
|
|
} else {
|
|
final message = data.length > 3
|
|
? data[3] as String
|
|
: 'Auth rejected by relay';
|
|
_failAuth(PairingAuthException(message));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Pass non-auth OK upstream.
|
|
_onMessage(data);
|
|
}
|
|
}
|