From ccfc1490e451b99e8bc472ab7df5d65b0f4d3837 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Oct 2026 12:50:19 -0700 Subject: [PATCH] perf(mobile): verify signatures with native libsecp256k1 (#8657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> --- .../features/pairing/pairing_provider.dart | 2 +- mobile/lib/shared/agents/remote_agents.dart | 3 +- mobile/lib/shared/crypto/event_signing.dart | 75 ++++++++++++++++ mobile/lib/shared/crypto/nip_oa.dart | 8 +- mobile/lib/shared/me/me_agent_inventory.dart | 4 +- mobile/lib/shared/me/me_archived_agents.dart | 4 +- mobile/lib/shared/me/me_assistants.dart | 4 +- mobile/lib/shared/me/me_sync.dart | 2 +- .../shared/crypto/event_signing_test.dart | 90 +++++++++++++++++++ 9 files changed, 179 insertions(+), 13 deletions(-) diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index dd6343de18..5e155eeb36 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -526,7 +526,7 @@ class PairingNotifier extends Notifier { // NIP-AB §Event Validation: verify event signature (NIP-01). // The nostr package's Event.fromJson verifies id + sig on construction. try { - final event = nostr.Event.fromJson(jsonEncode(eventJson)); + final event = verifiedNostrEventFromJson(jsonEncode(eventJson)); if (event.id != eventId) return; // id mismatch } catch (_) { return; // invalid signature or malformed event diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 06478bff0e..3ea5518a63 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:buzz/shared/crypto/event_signing.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -848,7 +849,7 @@ List signOwnerAuthorization(nostr.Keys owner, String agent) { 'auth', owner.public, '', - nostr.Schnorr.sign(secretKey: owner.secret, message: digest), + signSchnorrHex(secretKey: owner.secret, messageHex: digest), ]; } diff --git a/mobile/lib/shared/crypto/event_signing.dart b/mobile/lib/shared/crypto/event_signing.dart index 2e6c07e966..a9c47a54fd 100644 --- a/mobile/lib/shared/crypto/event_signing.dart +++ b/mobile/lib/shared/crypto/event_signing.dart @@ -54,3 +54,78 @@ nostr.Event signNostrEvent({ ); return unsigned.copyWith(sig: _encodeHex(signature)); } + +final _hex128 = RegExp(r'^[0-9a-fA-F]{128}$'); + +/// Verifies a BIP-340 Schnorr [signature] (128 hex) over the 32-byte +/// [messageHex] digest for the x-only [publicKey] with libsecp256k1. +/// +/// Returns false for malformed hex as well as for invalid signatures. +bool verifySchnorrHex({ + required String publicKey, + required String messageHex, + required String signature, +}) { + if (!_hex64.hasMatch(publicKey) || + !_hex64.hasMatch(messageHex) || + !_hex128.hasMatch(signature)) { + return false; + } + return secp256k1.schnorrVerify( + signature: _decodeHex(signature), + message: _decodeHex(messageHex), + publicKey: _decodeHex(publicKey), + ); +} + +/// Signs a 32-byte [messageHex] digest with [secretKey] and returns the +/// 128-hex BIP-340 signature, computed by libsecp256k1. +String signSchnorrHex({required String secretKey, required String messageHex}) { + if (!_hex64.hasMatch(messageHex)) { + throw ArgumentError('Message must be 64 hex characters'); + } + return _encodeHex( + secp256k1.schnorrSign( + message: _decodeHex(messageHex), + secretKey: _decodeSecretKey(secretKey), + ), + ); +} + +/// Parses [payload] like `nostr.Event.fromJson` and verifies its timestamp, +/// id and signature like `verify: true` does, with the signature check in +/// libsecp256k1. +/// +/// Throws the same `nostr` exception types as the pure-Dart path. +nostr.Event verifiedNostrEventFromJson(String payload) { + final event = nostr.Event.fromJson(payload, verify: false); + if (event.createdAt <= 0 || event.createdAt >= 253402300800) { + throw nostr.EventValidationException( + 'created_at ${event.createdAt} is not a valid Unix timestamp (seconds)', + nostr.EventValidationReason.invalidTimestamp, + ); + } + if (event.id != event.getEventId()) { + throw const nostr.EventValidationException( + 'event id mismatch', + nostr.EventValidationReason.idMismatch, + ); + } + if (!_hex64.hasMatch(event.pubkey) || !_hex128.hasMatch(event.sig)) { + throw const nostr.EventValidationException( + 'malformed pubkey or signature', + nostr.EventValidationReason.malformedSignature, + ); + } + if (!verifySchnorrHex( + publicKey: event.pubkey, + messageHex: event.id, + signature: event.sig, + )) { + throw const nostr.EventValidationException( + 'Schnorr signature invalid', + nostr.EventValidationReason.invalidSignature, + ); + } + return event; +} diff --git a/mobile/lib/shared/crypto/nip_oa.dart b/mobile/lib/shared/crypto/nip_oa.dart index 6f89116757..35ef8091f4 100644 --- a/mobile/lib/shared/crypto/nip_oa.dart +++ b/mobile/lib/shared/crypto/nip_oa.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/crypto/event_signing.dart'; import 'package:pointycastle/digests/sha256.dart'; import '../relay/nostr_models.dart'; @@ -13,7 +13,7 @@ String? verifiedProfileOaOwnerPubkey(NostrEvent event) { .toList(); if (tags.length != 1 || tags.single.length != 4) return null; try { - nostr.Event.fromJson(jsonEncode(event.toJson())); + verifiedNostrEventFromJson(jsonEncode(event.toJson())); } catch (_) { return null; } @@ -76,9 +76,9 @@ String? verifiedOaOwnerPubkey(List> tags, String agentPubkey) { .join(); try { - if (nostr.Schnorr.verify( + if (verifySchnorrHex( publicKey: owner, - message: message, + messageHex: message, signature: sig, )) { return owner; diff --git a/mobile/lib/shared/me/me_agent_inventory.dart b/mobile/lib/shared/me/me_agent_inventory.dart index 2c8421771f..405666a36d 100644 --- a/mobile/lib/shared/me/me_agent_inventory.dart +++ b/mobile/lib/shared/me/me_agent_inventory.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:isolate'; -import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/crypto/event_signing.dart'; import '../relay/relay.dart'; import 'me_sync.dart'; @@ -64,7 +64,7 @@ Map _inventoryNames(List events, String owner) { 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 { - nostr.Event.fromJson(jsonEncode(event.toJson())); + verifiedNostrEventFromJson(jsonEncode(event.toJson())); } catch (_) { // A corrupt record must not hide unrelated verified agents. continue; diff --git a/mobile/lib/shared/me/me_archived_agents.dart b/mobile/lib/shared/me/me_archived_agents.dart index 9171a8814f..66b4894466 100644 --- a/mobile/lib/shared/me/me_archived_agents.dart +++ b/mobile/lib/shared/me/me_archived_agents.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; +import 'package:buzz/shared/crypto/event_signing.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; -import 'package:nostr/nostr.dart' as nostr; import '../relay/relay.dart'; import 'me_sync.dart'; @@ -83,7 +83,7 @@ Set _archived(List events, String authority) { } final encoded = jsonEncode(event.toJson()); if (utf8.encode(encoded).length > 2 * 1024 * 1024) return {}; - nostr.Event.fromJson(encoded); + verifiedNostrEventFromJson(encoded); return { for (final tag in event.tags) if (tag.length >= 2 && diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index 6f89e42f17..a2f7234a23 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:isolate'; -import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/crypto/event_signing.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../crypto/nip_oa.dart'; @@ -105,7 +105,7 @@ List _verifiedProfiles( final latest = {}; for (final event in events) { try { - nostr.Event.fromJson(jsonEncode(event.toJson())); + verifiedNostrEventFromJson(jsonEncode(event.toJson())); } catch (_) { continue; } diff --git a/mobile/lib/shared/me/me_sync.dart b/mobile/lib/shared/me/me_sync.dart index 7794e60e40..177c6cf761 100644 --- a/mobile/lib/shared/me/me_sync.dart +++ b/mobile/lib/shared/me/me_sync.dart @@ -348,7 +348,7 @@ class MeSync { )) { throw const FormatException('Invalid Solo record'); } - nostr.Event.fromJson(jsonEncode(event.toJson())); + verifiedNostrEventFromJson(jsonEncode(event.toJson())); return MeGroups.decode(nip44Decrypt(key, event.content), community); } diff --git a/mobile/test/shared/crypto/event_signing_test.dart b/mobile/test/shared/crypto/event_signing_test.dart index 2b4cc8698e..3339e0839e 100644 --- a/mobile/test/shared/crypto/event_signing_test.dart +++ b/mobile/test/shared/crypto/event_signing_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:math'; import 'package:buzz/shared/crypto/event_signing.dart'; @@ -80,4 +81,93 @@ void main() { ); } }); + + group('verifiedNostrEventFromJson', () { + final secretKey = _randomSecretKey(Random(2)); + final event = nostr.Event.from( + kind: 1, + content: 'hello', + secretKey: secretKey, + createdAt: 1700000000, + tags: [ + ['t', 'x'], + ], + ); + Map map() => Map.from(event.toMap()); + + test('accepts what the pure-Dart verifier accepts', () { + final verified = verifiedNostrEventFromJson(jsonEncode(map())); + expect(verified.id, event.id); + expect(verified.sig, event.sig); + }); + + test('rejects what the pure-Dart verifier rejects', () { + final otherSig = signNostrEvent( + kind: 1, + content: 'other', + secretKey: secretKey, + ).sig; + final cases = >{ + 'tampered content': map()..['content'] = 'hellO', + 'wrong signature': map()..['sig'] = otherSig, + 'other pubkey': map()..['pubkey'] = 'a' * 64, + 'malformed sig': map()..['sig'] = 'zz' * 64, + 'short sig': map()..['sig'] = event.sig.substring(2), + 'zero timestamp': map()..['created_at'] = 0, + }; + for (final MapEntry(key: reason, value: payload) in cases.entries) { + final json = jsonEncode(payload); + expect( + () => nostr.Event.fromJson(json), + throwsA(isA()), + reason: '$reason (pure Dart)', + ); + expect( + () => verifiedNostrEventFromJson(json), + throwsA(isA()), + reason: reason, + ); + } + }); + }); + + test('Schnorr hex helpers interoperate with nostr.Schnorr', () { + final secretKey = _randomSecretKey(Random(3)); + final publicKey = derivePublicKeyHex(secretKey); + final digest = 'ab' * 32; + final signature = signSchnorrHex(secretKey: secretKey, messageHex: digest); + expect( + nostr.Schnorr.verify( + publicKey: publicKey, + message: digest, + signature: signature, + ), + isTrue, + ); + final reference = nostr.Schnorr.sign(secretKey: secretKey, message: digest); + expect( + verifySchnorrHex( + publicKey: publicKey, + messageHex: digest, + signature: reference, + ), + isTrue, + ); + expect( + verifySchnorrHex( + publicKey: publicKey, + messageHex: 'cd' * 32, + signature: reference, + ), + isFalse, + ); + expect( + verifySchnorrHex( + publicKey: 'zz', + messageHex: digest, + signature: reference, + ), + isFalse, + ); + }); }