perf(mobile): verify signatures with native libsecp256k1 (#8657)

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>
This commit is contained in:
Tom Brow
2026-10-29 12:50:19 -07:00
committed by GitHub
parent ff284d00d3
commit ccfc1490e4
9 changed files with 179 additions and 13 deletions

View File

@@ -526,7 +526,7 @@ class PairingNotifier extends Notifier<PairingState> {
// 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

View File

@@ -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<String> signOwnerAuthorization(nostr.Keys owner, String agent) {
'auth',
owner.public,
'',
nostr.Schnorr.sign(secretKey: owner.secret, message: digest),
signSchnorrHex(secretKey: owner.secret, messageHex: digest),
];
}

View File

@@ -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;
}

View File

@@ -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<List<String>> tags, String agentPubkey) {
.join();
try {
if (nostr.Schnorr.verify(
if (verifySchnorrHex(
publicKey: owner,
message: message,
messageHex: message,
signature: sig,
)) {
return owner;

View File

@@ -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<String, String> _inventoryNames(List<NostrEvent> 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;

View File

@@ -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<String> _archived(List<NostrEvent> 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 &&

View File

@@ -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<AgentDirectoryEntry> _verifiedProfiles(
final latest = <String, NostrEvent>{};
for (final event in events) {
try {
nostr.Event.fromJson(jsonEncode(event.toJson()));
verifiedNostrEventFromJson(jsonEncode(event.toJson()));
} catch (_) {
continue;
}

View File

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

View File

@@ -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<String, dynamic> map() => Map<String, dynamic>.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 = <String, Map<String, dynamic>>{
'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<nostr.NostrException>()),
reason: '$reason (pure Dart)',
);
expect(
() => verifiedNostrEventFromJson(json),
throwsA(isA<nostr.NostrException>()),
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,
);
});
}