From eee75a1c7ba1520571cdb432baa51fc4ea7367bd Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 3 Nov 2026 08:00:26 -0800 Subject: [PATCH] perf(mobile): verify owned-agent registrations natively (#8938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `loadOwnedAgentDirectory` checks the signature of each kind:30177 agent registration before it trusts the registration's `model`. It did this with `nostr.Event.fromJson`, which runs the `nostr` package's pure-Dart Schnorr code on the UI isolate. This change uses `verifiedNostrEventFromJson`, which the app's other verification sites already use. It makes the same timestamp, event id and BIP-340 signature checks with the bundled libsecp256k1. It also requires lowercase hex for the pubkey and signature, which relay events already use. A registration that fails verification still lists its agent without a model, as before. ## Measurements - **Synthetic** (Mac, `flutter test`, 20 signed registrations, warm): pure Dart 163 ms, native 2.6 ms. - **Device** (iPhone 12 mini, Profile build, first channel open after launch, first 5 s; runs not matched): `loadOwnedAgentDirectory` inclusive CPU was 71 ms before (69 ms in `Event._assertValid`) and 2 ms after. ## Testing - New test: a registration with a wrong signature still lists the agent, with no model. The correctly signed registration keeps its model. - `just mobile-check mobile-test` passes (pre-push hook). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../shared/agents/owned_agent_directory.dart | 7 ++-- .../agents/owned_agent_directory_test.dart | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/mobile/lib/shared/agents/owned_agent_directory.dart b/mobile/lib/shared/agents/owned_agent_directory.dart index e8d6f347ea..bf11a50c5a 100644 --- a/mobile/lib/shared/agents/owned_agent_directory.dart +++ b/mobile/lib/shared/agents/owned_agent_directory.dart @@ -1,8 +1,9 @@ import 'dart:async'; import 'dart:convert'; -import 'package:nostr/nostr.dart' as nostr; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../crypto/event_signing.dart'; import '../crypto/nip_oa.dart'; import '../mentions/agent_identity_provider.dart'; import '../relay/relay.dart'; @@ -99,7 +100,9 @@ Future> loadOwnedAgentDirectory( String? model; try { final registration = registrations[event.pubkey]!; - nostr.Event.fromJson(jsonEncode(registration.toJson())); + // Native libsecp256k1 verification; the pure-Dart codec took + // several milliseconds per registration on the UI isolate. + verifiedNostrEventFromJson(jsonEncode(registration.toJson())); final data = jsonDecode(registration.content); if (data is Map && data['model'] is String) { final value = (data['model'] as String).trim(); diff --git a/mobile/test/shared/agents/owned_agent_directory_test.dart b/mobile/test/shared/agents/owned_agent_directory_test.dart index 7636e5e72b..17b0f0188b 100644 --- a/mobile/test/shared/agents/owned_agent_directory_test.dart +++ b/mobile/test/shared/agents/owned_agent_directory_test.dart @@ -3,6 +3,7 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; + import '../../helpers/remote_agents_fixture.dart'; NostrEvent registration( @@ -137,6 +138,40 @@ void main() { ]); }); + test('a registration whose signature fails keeps the agent without its ' + 'model', () async { + final signed = registration( + fakeAgent, + content: '{"name":"Remote","model":"Claude Sonnet"}', + ); + // Same id and content, but another key's signature over the id. + final forged = NostrEvent.fromJson({ + ...signed.toJson(), + 'sig': nostr.Event.from( + kind: 1, + content: 'other', + secretKey: fakeAgentKeys.secret, + ).sig, + }); + final profiles = [ + signedAgentProfile( + fakeAgentKeys, + fakeOwner, + content: '{"name":"Remote"}', + ), + ]; + Future> load(NostrEvent event) => + loadOwnedAgentDirectory( + fakeOwner.public, + (filter) async => filter.kinds.contains(30177) ? [event] : profiles, + ); + + expect((await load(signed)).single.model, 'Claude Sonnet'); + final found = (await load(forged)).single; + expect(found.pubkey, fakeAgent); + expect(found.model, isNull); + }); + test('a registration is listed only with a type runtime and a name', () { final agent = fakeAgent; final cases = <(String, NostrEvent, bool)>[