mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
perf(mobile): verify signatures with native libsecp256k1
Route the existing pure-Dart signature checks (NIP-OA profiles and owner attestations, Solo me_* records, pairing events) through verifiedNostrEventFromJson and verifySchnorrHex, and sign NIP-OA owner authorizations with signSchnorrHex. Incoming relay events are still not verified. Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -526,7 +526,7 @@ class PairingNotifier extends Notifier<PairingState> {
|
|||||||
// NIP-AB §Event Validation: verify event signature (NIP-01).
|
// NIP-AB §Event Validation: verify event signature (NIP-01).
|
||||||
// The nostr package's Event.fromJson verifies id + sig on construction.
|
// The nostr package's Event.fromJson verifies id + sig on construction.
|
||||||
try {
|
try {
|
||||||
final event = nostr.Event.fromJson(jsonEncode(eventJson));
|
final event = verifiedNostrEventFromJson(jsonEncode(eventJson));
|
||||||
if (event.id != eventId) return; // id mismatch
|
if (event.id != eventId) return; // id mismatch
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return; // invalid signature or malformed event
|
return; // invalid signature or malformed event
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:buzz/shared/crypto/event_signing.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -734,7 +735,7 @@ List<String> signOwnerAuthorization(nostr.Keys owner, String agent) {
|
|||||||
'auth',
|
'auth',
|
||||||
owner.public,
|
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));
|
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:convert';
|
||||||
import 'dart:typed_data';
|
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 'package:pointycastle/digests/sha256.dart';
|
||||||
import '../relay/nostr_models.dart';
|
import '../relay/nostr_models.dart';
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ String? verifiedProfileOaOwnerPubkey(NostrEvent event) {
|
|||||||
.toList();
|
.toList();
|
||||||
if (tags.length != 1 || tags.single.length != 4) return null;
|
if (tags.length != 1 || tags.single.length != 4) return null;
|
||||||
try {
|
try {
|
||||||
nostr.Event.fromJson(jsonEncode(event.toJson()));
|
verifiedNostrEventFromJson(jsonEncode(event.toJson()));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -76,9 +76,9 @@ String? verifiedOaOwnerPubkey(List<List<String>> tags, String agentPubkey) {
|
|||||||
.join();
|
.join();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (nostr.Schnorr.verify(
|
if (verifySchnorrHex(
|
||||||
publicKey: owner,
|
publicKey: owner,
|
||||||
message: message,
|
messageHex: message,
|
||||||
signature: sig,
|
signature: sig,
|
||||||
)) {
|
)) {
|
||||||
return owner;
|
return owner;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'package:nostr/nostr.dart' as nostr;
|
import 'package:buzz/shared/crypto/event_signing.dart';
|
||||||
|
|
||||||
import '../relay/relay.dart';
|
import '../relay/relay.dart';
|
||||||
import 'me_sync.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;
|
if (id == null || !RegExp(r'^[0-9a-f]{64}$').hasMatch(id)) continue;
|
||||||
// Verify before allowing a newer tombstone or record to replace a head.
|
// Verify before allowing a newer tombstone or record to replace a head.
|
||||||
try {
|
try {
|
||||||
nostr.Event.fromJson(jsonEncode(event.toJson()));
|
verifiedNostrEventFromJson(jsonEncode(event.toJson()));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// A corrupt record must not hide unrelated verified agents.
|
// A corrupt record must not hide unrelated verified agents.
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
|
|
||||||
|
import 'package:buzz/shared/crypto/event_signing.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:nostr/nostr.dart' as nostr;
|
|
||||||
|
|
||||||
import '../relay/relay.dart';
|
import '../relay/relay.dart';
|
||||||
import 'me_sync.dart';
|
import 'me_sync.dart';
|
||||||
@@ -83,7 +83,7 @@ Set<String> _archived(List<NostrEvent> events, String authority) {
|
|||||||
}
|
}
|
||||||
final encoded = jsonEncode(event.toJson());
|
final encoded = jsonEncode(event.toJson());
|
||||||
if (utf8.encode(encoded).length > 2 * 1024 * 1024) return {};
|
if (utf8.encode(encoded).length > 2 * 1024 * 1024) return {};
|
||||||
nostr.Event.fromJson(encoded);
|
verifiedNostrEventFromJson(encoded);
|
||||||
return {
|
return {
|
||||||
for (final tag in event.tags)
|
for (final tag in event.tags)
|
||||||
if (tag.length >= 2 &&
|
if (tag.length >= 2 &&
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:isolate';
|
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 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../crypto/nip_oa.dart';
|
import '../crypto/nip_oa.dart';
|
||||||
@@ -105,7 +105,7 @@ List<AgentDirectoryEntry> _verifiedProfiles(
|
|||||||
final latest = <String, NostrEvent>{};
|
final latest = <String, NostrEvent>{};
|
||||||
for (final event in events) {
|
for (final event in events) {
|
||||||
try {
|
try {
|
||||||
nostr.Event.fromJson(jsonEncode(event.toJson()));
|
verifiedNostrEventFromJson(jsonEncode(event.toJson()));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ class MeSync {
|
|||||||
)) {
|
)) {
|
||||||
throw const FormatException('Invalid Solo record');
|
throw const FormatException('Invalid Solo record');
|
||||||
}
|
}
|
||||||
nostr.Event.fromJson(jsonEncode(event.toJson()));
|
verifiedNostrEventFromJson(jsonEncode(event.toJson()));
|
||||||
return MeGroups.decode(nip44Decrypt(key, event.content), community);
|
return MeGroups.decode(nip44Decrypt(key, event.content), community);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:buzz/shared/crypto/event_signing.dart';
|
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