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:
Tom Brow
2026-10-29 13:53:24 -07:00
committed by GitHub
parent fffd8ca957
commit 762adad54f
3 changed files with 77 additions and 20 deletions

View File

@@ -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,

View File

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

View File

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