mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
Follows [#8657](https://github.com/block/buzz/pull/8657) with the three optional notes from Carl's [review](https://github.com/block/buzz/pull/8657#pullrequestreview-5703482477). - **Lowercase hex only.** `verifySchnorrHex` now rejects uppercase pubkey, message and sig hex, and `verifiedNostrEventFromJson` rejects an uppercase sig, as the pure-Dart `bip340` decoder does and NIP-01 requires. (The `nostr` package already lowercases an event's pubkey when it parses one.) Before this, a key holder could publish a second, uppercase encoding of the same signature and it would verify. `_decodeSecretKey` still accepts either case. - **Bestie record.** `decodeDefaultAgent` now verifies with `verifiedNostrEventFromJson`, like the other Solo `me_*` records. - **Rejection tests.** The "other pubkey" row now recomputes the event id, so it reaches the native Schnorr check instead of failing on the id. Every row now asserts its `EventValidationReason`, a new row covers an uppercase sig, and `verifySchnorrHex` is checked with uppercase pubkey, message and sig. ## Testing - Making either lowercase pattern accept uppercase again fails the tests. - `just mobile-check mobile-test` passed on this change before it moved onto main. On main, the crypto and Solo/me tests pass. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
4.2 KiB
Dart
134 lines
4.2 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../crypto/event_signing.dart';
|
|
import '../crypto/nip44.dart';
|
|
import '../relay/relay.dart';
|
|
import 'me_sync.dart';
|
|
|
|
// Shared with buzz-app's native-sidebar mainAgent record (PR #1665).
|
|
// The relay scopes the wire record; the local cache also includes the account.
|
|
const defaultAgentTag = 'main-agent';
|
|
String defaultAgentCoordinate(String community) => defaultAgentTag;
|
|
|
|
/// Reads the owner's signed, self-encrypted desktop/mobile Bestie selection.
|
|
String? decodeDefaultAgent(NostrEvent event, MeSync sync) {
|
|
if (event.kind != 30078 ||
|
|
event.pubkey != sync.pubkey ||
|
|
event.content.length > 4096 ||
|
|
event.tags.where((t) => t.isNotEmpty && t[0] == 'd').length != 1 ||
|
|
event.getTagValue('d') != defaultAgentTag) {
|
|
throw const FormatException('Invalid Bestie preference');
|
|
}
|
|
verifiedNostrEventFromJson(jsonEncode(event.toJson()));
|
|
final raw = jsonDecode(nip44Decrypt(sync.key, event.content));
|
|
if (raw is! Map<String, dynamic> ||
|
|
raw.length != 2 ||
|
|
raw['version'] != 1 ||
|
|
!raw.containsKey('agent') ||
|
|
(raw['agent'] != null &&
|
|
(raw['agent'] is! String ||
|
|
!RegExp(r'^[0-9a-f]{64}$').hasMatch(raw['agent'] as String)))) {
|
|
throw const FormatException('Invalid Bestie preference');
|
|
}
|
|
return raw['agent'] as String?;
|
|
}
|
|
|
|
/// A cached choice remains available offline; account/community changes reset it.
|
|
final defaultAgentProvider = StreamProvider<String?>((ref) {
|
|
final sync = ref.watch(meSyncProvider);
|
|
ref.listen(relaySessionProvider.select((s) => s.status), (before, after) {
|
|
if (before != after && after == SessionStatus.connected) {
|
|
ref.invalidateSelf();
|
|
}
|
|
});
|
|
final output = StreamController<String?>();
|
|
final cacheKey = 'main-agent:${sync.community}:${sync.pubkey}';
|
|
final filter = NostrFilter(
|
|
kinds: const [30078],
|
|
authors: [sync.pubkey],
|
|
tags: {
|
|
'#d': [defaultAgentCoordinate(sync.community)],
|
|
},
|
|
limit: 1,
|
|
);
|
|
var disposed = false;
|
|
NostrEvent? latest;
|
|
void Function()? unsubscribe;
|
|
Future<void> writes = Future.value();
|
|
void accept(NostrEvent event) {
|
|
if (disposed || (latest != null && !meEventIsNewer(event, latest!))) return;
|
|
try {
|
|
final choice = decodeDefaultAgent(event, sync);
|
|
latest = event;
|
|
output.add(choice);
|
|
writes = writes
|
|
.then((_) async {
|
|
if (disposed || latest?.id != event.id) return;
|
|
final prefs = sync.prefs;
|
|
if (prefs != null &&
|
|
!await prefs.setString(cacheKey, jsonEncode(event.toJson()))) {
|
|
throw StateError('Could not save the Bestie for offline use.');
|
|
}
|
|
})
|
|
.catchError((Object error, StackTrace stack) {
|
|
if (!disposed) output.addError(error, stack);
|
|
});
|
|
} catch (error, stack) {
|
|
if (!disposed) output.addError(error, stack);
|
|
}
|
|
}
|
|
|
|
Future<void> start() async {
|
|
try {
|
|
final cached = sync.prefs?.getString(cacheKey);
|
|
if (cached != null) {
|
|
final event = NostrEvent.fromJson(
|
|
jsonDecode(cached) as Map<String, dynamic>,
|
|
);
|
|
final choice = decodeDefaultAgent(event, sync);
|
|
latest = event;
|
|
output.add(choice);
|
|
}
|
|
} catch (_) {
|
|
// A damaged local cache cannot block a verified relay read.
|
|
}
|
|
try {
|
|
sync.check();
|
|
unsubscribe = await sync.session.subscribe(
|
|
filter,
|
|
accept,
|
|
onClosed: (_) {
|
|
if (!disposed) {
|
|
output.addError(
|
|
StateError('Bestie sync stopped. Retry to reconnect.'),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
if (disposed) {
|
|
unsubscribe?.call();
|
|
return;
|
|
}
|
|
final events = await sync.session.fetchHistory(filter);
|
|
if (disposed) return;
|
|
for (final event in events) {
|
|
accept(event);
|
|
}
|
|
if (latest == null) output.add(null);
|
|
} catch (error, stack) {
|
|
if (!disposed && latest == null) output.addError(error, stack);
|
|
}
|
|
}
|
|
|
|
ref.onDispose(() {
|
|
disposed = true;
|
|
unsubscribe?.call();
|
|
unawaited(output.close());
|
|
});
|
|
unawaited(start());
|
|
return output.stream;
|
|
}, retry: (_, _) => null);
|