From 762adad54f79fc07caecbefd102aa170a9b4cce3 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Oct 2026 13:53:24 -0700 Subject: [PATCH] fix(mobile): require lowercase hex in native verification (#8702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Opus 5.5 --- mobile/lib/shared/crypto/event_signing.dart | 16 ++-- mobile/lib/shared/me/default_agent.dart | 4 +- .../shared/crypto/event_signing_test.dart | 77 ++++++++++++++++--- 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/mobile/lib/shared/crypto/event_signing.dart b/mobile/lib/shared/crypto/event_signing.dart index a9c47a54fd..f7011215c3 100644 --- a/mobile/lib/shared/crypto/event_signing.dart +++ b/mobile/lib/shared/crypto/event_signing.dart @@ -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, diff --git a/mobile/lib/shared/me/default_agent.dart b/mobile/lib/shared/me/default_agent.dart index ed40c0e1db..81c55e9888 100644 --- a/mobile/lib/shared/me/default_agent.dart +++ b/mobile/lib/shared/me/default_agent.dart @@ -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 || raw.length != 2 || diff --git a/mobile/test/shared/crypto/event_signing_test.dart b/mobile/test/shared/crypto/event_signing_test.dart index 3339e0839e..a8860b5109 100644 --- a/mobile/test/shared/crypto/event_signing_test.dart +++ b/mobile/test/shared/crypto/event_signing_test.dart @@ -107,25 +107,65 @@ void main() { content: 'other', secretKey: secretKey, ).sig; - final cases = >{ - '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 = + , 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()), - reason: '$reason (pure Dart)', + reason: '$name (pure Dart)', ); expect( () => verifiedNostrEventFromJson(json), - throwsA(isA()), - reason: reason, + throwsA( + isA().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', + ); + } }); }