mirror of
https://github.com/block/buzz.git
synced 2026-11-15 22:02:04 +00:00
The app already verifies signatures in a few places: NIP-OA agent
profiles and owner attestations, Solo `me_*` records (agent inventory,
archived agents, assistants, sync), and pairing events. Each used
`nostr.Event.fromJson`'s pure-Dart BIP-340 verify, about 11.2 ms per
event on an iPhone SE (2nd gen, Profile). This routes them through
`verifiedNostrEventFromJson` and `verifySchnorrHex`, which keep the same
checks (timestamp range, canonical id, Schnorr signature) and the same
`nostr` exception types, with the signature check in libsecp256k1 (40
µs). NIP-OA owner authorizations are now signed with `signSchnorrHex`,
which removes the last pure-Dart Schnorr call in `lib`.
Incoming relay events are still not verified; that stays a separate
decision.
A native trace showed pure-Dart Schnorr verify in `me_sync` and `nip_oa`
costs 100-125 ms per foreground. This PR moves exactly those sites.
### Validation
- Added tests: the native verifier accepts a valid event and rejects
tampered content, a wrong signature, a different pubkey, malformed and
short signatures, and a zero timestamp, each also rejected by the
pure-Dart verifier. Hex Schnorr helpers are checked in both directions
against `nostr.Schnorr`.
- Focused existing validation: crypto, NIP-OA, Solo/me, pairing and
agent tests pass. `just mobile-install mobile-check mobile-test` passes
at f90823cd1.
- Device: a Profile build of the full native stack ran on an iPhone 12
mini against the internal relay.
buzz-review-completed
🤖 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>
120 lines
3.8 KiB
Dart
120 lines
3.8 KiB
Dart
import 'dart:convert';
|
|
import 'dart:isolate';
|
|
|
|
import 'package:buzz/shared/crypto/event_signing.dart';
|
|
|
|
import '../relay/relay.dart';
|
|
import 'me_sync.dart';
|
|
|
|
/// Desktop's owner-authored agent inventory. A historical kind:0 ownership
|
|
/// attestation alone does not mean an agent is still in this inventory.
|
|
Future<Map<String, String>> loadMeAgentInventory(MeSync sync) async {
|
|
const pageSize = 200;
|
|
final events = <NostrEvent>[];
|
|
int? until;
|
|
String? beforeId;
|
|
for (var page = 0; page < 50; page++) {
|
|
sync.check();
|
|
final batch = await sync.session.queryRelay([
|
|
NostrFilter(
|
|
kinds: const [30177],
|
|
authors: [sync.pubkey],
|
|
limit: pageSize,
|
|
until: until,
|
|
extensions: {'before_id': ?beforeId},
|
|
),
|
|
]);
|
|
sync.check();
|
|
if (batch.length > pageSize ||
|
|
batch.any((e) => e.kind != 30177 || e.pubkey != sync.pubkey)) {
|
|
throw StateError('Agent inventory response is outside its owner scope.');
|
|
}
|
|
events.addAll(batch);
|
|
if (batch.length < pageSize) {
|
|
final owner = sync.pubkey;
|
|
return _parseInventory(events, owner);
|
|
}
|
|
final ordered = [...batch]
|
|
..sort((a, b) {
|
|
final time = b.createdAt.compareTo(a.createdAt);
|
|
return time != 0 ? time : a.id.compareTo(b.id);
|
|
});
|
|
final last = ordered.last;
|
|
if (until != null &&
|
|
(last.createdAt > until ||
|
|
(last.createdAt == until && last.id.compareTo(beforeId!) <= 0))) {
|
|
throw StateError('Agent inventory pagination did not advance.');
|
|
}
|
|
until = last.createdAt;
|
|
beforeId = last.id;
|
|
}
|
|
throw StateError('Agent inventory exceeds the read budget.');
|
|
}
|
|
|
|
Future<Map<String, String>> _parseInventory(
|
|
List<NostrEvent> events,
|
|
String owner,
|
|
) => Isolate.run(() => _inventoryNames(events, owner));
|
|
|
|
Map<String, String> _inventoryNames(List<NostrEvent> events, String owner) {
|
|
final heads = <String, NostrEvent>{};
|
|
for (final event in events) {
|
|
if (event.pubkey != owner) continue;
|
|
final id = event.getTagValue('d');
|
|
if (id == null || !RegExp(r'^[0-9a-f]{64}$').hasMatch(id)) continue;
|
|
// Verify before allowing a newer tombstone or record to replace a head.
|
|
try {
|
|
verifiedNostrEventFromJson(jsonEncode(event.toJson()));
|
|
} catch (_) {
|
|
// A corrupt record must not hide unrelated verified agents.
|
|
continue;
|
|
}
|
|
final previous = heads[id];
|
|
if (previous == null || meEventIsNewer(event, previous)) heads[id] = event;
|
|
}
|
|
// Empty/deleted, malformed and unsupported heads never resurrect an earlier
|
|
// listing.
|
|
return {
|
|
for (final MapEntry(key: id, value: head) in heads.entries)
|
|
id: ?_supportedPolicyName(head),
|
|
};
|
|
}
|
|
|
|
/// Desktop's `ManagedAgentEventContent` contract: a head desktop cannot parse
|
|
/// fails closed instead of offering an agent whose policy is unknown.
|
|
String? _supportedPolicyName(NostrEvent event) {
|
|
final Object? decoded;
|
|
try {
|
|
decoded = jsonDecode(event.content);
|
|
} on FormatException {
|
|
return null;
|
|
}
|
|
if (decoded is! Map<String, dynamic>) return null;
|
|
final body = decoded;
|
|
final name = body['name'];
|
|
if (name is! String || name.trim().isEmpty) return null;
|
|
final parallelism = body['parallelism'];
|
|
final allowlist = body['respond_to_allowlist'];
|
|
final supported =
|
|
parallelism is int &&
|
|
parallelism >= 0 &&
|
|
parallelism <= 0xFFFFFFFF &&
|
|
_respondTo.contains(body['respond_to']) &&
|
|
(!body.containsKey('respond_to_allowlist') ||
|
|
(allowlist is List && allowlist.every((entry) => entry is String))) &&
|
|
_optionalPolicyText.every((field) {
|
|
final value = body[field];
|
|
return value == null || value is String;
|
|
});
|
|
return supported ? name.trim() : null;
|
|
}
|
|
|
|
const _respondTo = {'owner-only', 'allowlist', 'anyone'};
|
|
const _optionalPolicyText = [
|
|
'persona_id',
|
|
'system_prompt',
|
|
'model',
|
|
'provider',
|
|
'persona_source_version',
|
|
];
|