Files
buzz/mobile/lib/shared/me/default_agent.dart
Tom Brow 762adad54f fix(mobile): require lowercase hex in native verification (#8702)
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>
2026-10-29 13:53:24 -07:00

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);