mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user