mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
fix(mobile): require lowercase hex in native verification (#8702)
Follows [#8657](https://github.com/block/buzz/pull/8657) with the three optional notes from Carl's [review](https://github.com/block/buzz/pull/8657#pullrequestreview-5703482477). - **Lowercase hex only.** `verifySchnorrHex` now rejects uppercase pubkey, message and sig hex, and `verifiedNostrEventFromJson` rejects an uppercase sig, as the pure-Dart `bip340` decoder does and NIP-01 requires. (The `nostr` package already lowercases an event's pubkey when it parses one.) Before this, a key holder could publish a second, uppercase encoding of the same signature and it would verify. `_decodeSecretKey` still accepts either case. - **Bestie record.** `decodeDefaultAgent` now verifies with `verifiedNostrEventFromJson`, like the other Solo `me_*` records. - **Rejection tests.** The "other pubkey" row now recomputes the event id, so it reaches the native Schnorr check instead of failing on the id. Every row now asserts its `EventValidationReason`, a new row covers an uppercase sig, and `verifySchnorrHex` is checked with uppercase pubkey, message and sig. ## Testing - Making either lowercase pattern accept uppercase again fails the tests. - `just mobile-check mobile-test` passed on this change before it moved onto main. On main, the crypto and Solo/me tests pass. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -55,20 +55,23 @@ nostr.Event signNostrEvent({
|
||||
return unsigned.copyWith(sig: _encodeHex(signature));
|
||||
}
|
||||
|
||||
final _hex128 = RegExp(r'^[0-9a-fA-F]{128}$');
|
||||
// Verify inputs are lowercase only, as NIP-01 and bip340's decoder require.
|
||||
final _lowerHex64 = RegExp(r'^[0-9a-f]{64}$');
|
||||
final _lowerHex128 = RegExp(r'^[0-9a-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.
|
||||
/// Returns false for malformed or uppercase 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)) {
|
||||
if (!_lowerHex64.hasMatch(publicKey) ||
|
||||
!_lowerHex64.hasMatch(messageHex) ||
|
||||
!_lowerHex128.hasMatch(signature)) {
|
||||
return false;
|
||||
}
|
||||
return secp256k1.schnorrVerify(
|
||||
@@ -111,7 +114,8 @@ nostr.Event verifiedNostrEventFromJson(String payload) {
|
||||
nostr.EventValidationReason.idMismatch,
|
||||
);
|
||||
}
|
||||
if (!_hex64.hasMatch(event.pubkey) || !_hex128.hasMatch(event.sig)) {
|
||||
if (!_lowerHex64.hasMatch(event.pubkey) ||
|
||||
!_lowerHex128.hasMatch(event.sig)) {
|
||||
throw const nostr.EventValidationException(
|
||||
'malformed pubkey or signature',
|
||||
nostr.EventValidationReason.malformedSignature,
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../crypto/event_signing.dart';
|
||||
import '../crypto/nip44.dart';
|
||||
import '../relay/relay.dart';
|
||||
import 'me_sync.dart';
|
||||
@@ -22,7 +22,7 @@ String? decodeDefaultAgent(NostrEvent event, MeSync sync) {
|
||||
event.getTagValue('d') != defaultAgentTag) {
|
||||
throw const FormatException('Invalid Bestie preference');
|
||||
}
|
||||
nostr.Event.fromJson(jsonEncode(event.toJson()));
|
||||
verifiedNostrEventFromJson(jsonEncode(event.toJson()));
|
||||
final raw = jsonDecode(nip44Decrypt(sync.key, event.content));
|
||||
if (raw is! Map<String, dynamic> ||
|
||||
raw.length != 2 ||
|
||||
|
||||
@@ -107,25 +107,65 @@ void main() {
|
||||
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 otherPubkey = derivePublicKeyHex(_randomSecretKey(Random(4)));
|
||||
final otherPubkeyId = nostr.Event.unsigned(
|
||||
pubkey: otherPubkey,
|
||||
kind: event.kind,
|
||||
content: event.content,
|
||||
createdAt: event.createdAt,
|
||||
tags: event.tags,
|
||||
).id;
|
||||
final cases =
|
||||
<String, (Map<String, dynamic>, nostr.EventValidationReason)>{
|
||||
'tampered content': (
|
||||
map()..['content'] = 'hellO',
|
||||
nostr.EventValidationReason.idMismatch,
|
||||
),
|
||||
'wrong signature': (
|
||||
map()..['sig'] = otherSig,
|
||||
nostr.EventValidationReason.invalidSignature,
|
||||
),
|
||||
'other pubkey': (
|
||||
map()
|
||||
..['pubkey'] = otherPubkey
|
||||
..['id'] = otherPubkeyId,
|
||||
nostr.EventValidationReason.invalidSignature,
|
||||
),
|
||||
'malformed sig': (
|
||||
map()..['sig'] = 'zz' * 64,
|
||||
nostr.EventValidationReason.malformedSignature,
|
||||
),
|
||||
'uppercase sig': (
|
||||
map()..['sig'] = event.sig.toUpperCase(),
|
||||
nostr.EventValidationReason.malformedSignature,
|
||||
),
|
||||
'short sig': (
|
||||
map()..['sig'] = event.sig.substring(2),
|
||||
nostr.EventValidationReason.malformedSignature,
|
||||
),
|
||||
'zero timestamp': (
|
||||
map()..['created_at'] = 0,
|
||||
nostr.EventValidationReason.invalidTimestamp,
|
||||
),
|
||||
};
|
||||
for (final MapEntry(key: name, value: (payload, reason))
|
||||
in cases.entries) {
|
||||
final json = jsonEncode(payload);
|
||||
expect(
|
||||
() => nostr.Event.fromJson(json),
|
||||
throwsA(isA<nostr.NostrException>()),
|
||||
reason: '$reason (pure Dart)',
|
||||
reason: '$name (pure Dart)',
|
||||
);
|
||||
expect(
|
||||
() => verifiedNostrEventFromJson(json),
|
||||
throwsA(isA<nostr.NostrException>()),
|
||||
reason: reason,
|
||||
throwsA(
|
||||
isA<nostr.EventValidationException>().having(
|
||||
(e) => e.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
),
|
||||
reason: name,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -169,5 +209,18 @@ void main() {
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
final uppercase = {
|
||||
'pubkey': (publicKey.toUpperCase(), digest, reference),
|
||||
'message': (publicKey, digest.toUpperCase(), reference),
|
||||
'signature': (publicKey, digest, reference.toUpperCase()),
|
||||
};
|
||||
for (final MapEntry(key: name, value: (key, message, sig))
|
||||
in uppercase.entries) {
|
||||
expect(
|
||||
verifySchnorrHex(publicKey: key, messageHex: message, signature: sig),
|
||||
isFalse,
|
||||
reason: 'uppercase $name',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user