diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 78d31e58198..c06ca342e10 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -374,6 +374,7 @@ void _sendTypingIndicator( content: '', tags: tags, secretKey: privkeyHex, + pubkey: pubkeyForPrivkey(privkeyHex), verify: false, ); diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 6f50b6be2f9..ac4b2e31a9f 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -810,6 +810,7 @@ class PairingNotifier extends Notifier { content: content, tags: tags, secretKey: _ephemeralPrivkey!, + pubkey: pubkeyForPrivkey(_ephemeralPrivkey!), createdAt: createdAt, ); diff --git a/mobile/lib/features/pairing/pairing_socket.dart b/mobile/lib/features/pairing/pairing_socket.dart index 73999dd02d3..6bb06921ec7 100644 --- a/mobile/lib/features/pairing/pairing_socket.dart +++ b/mobile/lib/features/pairing/pairing_socket.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:nostr/nostr.dart' as nostr; import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../shared/relay/event_signing.dart'; import '../../shared/relay/nostr_models.dart'; const _desktopPairingAuthChallengeGrace = Duration(seconds: 3); @@ -197,6 +198,7 @@ class PairingSocket { content: '', tags: tags, secretKey: _ephemeralPrivkey, + pubkey: pubkeyForPrivkey(_ephemeralPrivkey), createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); diff --git a/mobile/lib/features/profile/user_status_provider.dart b/mobile/lib/features/profile/user_status_provider.dart index 1ed3c9218d2..350a04973d8 100644 --- a/mobile/lib/features/profile/user_status_provider.dart +++ b/mobile/lib/features/profile/user_status_provider.dart @@ -99,6 +99,7 @@ class UserStatusNotifier extends AsyncNotifier { content: trimmed, tags: tags, secretKey: privkeyHex, + pubkey: pubkeyForPrivkey(privkeyHex), verify: false, ); diff --git a/mobile/lib/shared/relay/event_signing.dart b/mobile/lib/shared/relay/event_signing.dart new file mode 100644 index 00000000000..e7cc1dd2bd4 --- /dev/null +++ b/mobile/lib/shared/relay/event_signing.dart @@ -0,0 +1,27 @@ +import 'package:nostr/nostr.dart' as nostr; + +/// The x-only public key for a signing key, derived at most once per key. +/// +/// `Event.from` derives the pubkey itself when the caller omits it, which is a +/// second elliptic-curve multiply on top of the signature — for a value that is +/// constant per identity. On this stack that is not free: the crypto is +/// pure-Dart bigint math, and a host-AOT measurement put `Event.from` at 8.13ms +/// p50 against 5.41ms with the pubkey supplied. +/// +/// Supplying it is behaviour-identical. `Event`'s id hash lowercases the pubkey +/// before hashing, and the signature covers the id, so a supplied key produces +/// the same id and the same signature as a derived one. +/// +/// Caches the last key only. The app signs with one identity at a time and +/// switches on community change, so a single slot hits on every call in a run +/// of signatures without holding keys it no longer needs. +String pubkeyForPrivkey(String privkeyHex) { + if (privkeyHex != _cachedPrivkeyHex) { + _cachedPubkey = nostr.Schnorr.derivePublicKey(privkeyHex); + _cachedPrivkeyHex = privkeyHex; + } + return _cachedPubkey; +} + +String _cachedPrivkeyHex = ''; +String _cachedPubkey = ''; diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index b21eeca36d6..be994de3095 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'event_signing.dart'; import 'relay_provider.dart'; const _mediaGetAuthKind = 24242; @@ -118,6 +119,7 @@ class MediaGetAuthService { content: 'Get buzz-media', tags: tags, secretKey: privkeyHex, + pubkey: pubkeyForPrivkey(privkeyHex), verify: false, ); } diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 3161a70362d..0efa33e0f1f 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -12,6 +12,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; +import 'event_signing.dart'; import 'animated_image_sanitizer.dart'; import 'media_auth.dart'; import 'mp4_fast_start.dart'; @@ -615,6 +616,7 @@ class MediaUploadService { content: 'Upload buzz-media', tags: tags, secretKey: privkeyHex, + pubkey: pubkeyForPrivkey(privkeyHex), verify: false, ); } diff --git a/mobile/lib/shared/relay/relay.dart b/mobile/lib/shared/relay/relay.dart index dd153fc9282..52b68920b82 100644 --- a/mobile/lib/shared/relay/relay.dart +++ b/mobile/lib/shared/relay/relay.dart @@ -1,4 +1,5 @@ export 'app_lifecycle_provider.dart'; +export 'event_signing.dart'; export 'identity_scoped_prefs.dart'; export 'media_auth.dart'; export 'media_image.dart'; diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index d6c094d82e1..e597eae5930 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -10,6 +10,7 @@ import 'package:uuid/uuid.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'event_signing.dart'; import '../auth/auth.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; @@ -973,6 +974,7 @@ String buildNip98AuthHeader({ ['nonce', const Uuid().v4()], ], secretKey: privkeyHex, + pubkey: pubkeyForPrivkey(privkeyHex), verify: false, ); return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}'; diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 5b23279e814..b690df13129 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -6,6 +6,7 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import 'event_signing.dart'; import 'nostr_models.dart'; /// Low-level websocket connection with NIP-42 authentication. @@ -221,6 +222,7 @@ class RelaySocket { content: '', tags: tags, secretKey: privkeyHex, + pubkey: pubkeyForPrivkey(privkeyHex), ); _pendingAuthEventId = event.id; diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index a739b765941..8f43aa186a5 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -1,5 +1,6 @@ import 'package:nostr/nostr.dart' as nostr; +import 'event_signing.dart'; import 'nostr_models.dart'; import 'relay_session.dart'; @@ -48,6 +49,7 @@ class SignedEventRelay { content: content, tags: tags, secretKey: privkeyHex, + pubkey: pubkeyForPrivkey(privkeyHex), createdAt: createdAt, verify: false, ); diff --git a/mobile/test/shared/relay/event_signing_test.dart b/mobile/test/shared/relay/event_signing_test.dart new file mode 100644 index 00000000000..31e22da3467 --- /dev/null +++ b/mobile/test/shared/relay/event_signing_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import 'package:buzz/shared/relay/event_signing.dart'; + +// Two distinct keys, so the cache is exercised in both directions. +const _keyA = + '5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12'; +const _keyB = + '0000000000000000000000000000000000000000000000000000000000000003'; + +void main() { + group('pubkeyForPrivkey', () { + test('matches the key the nostr package derives', () { + expect(pubkeyForPrivkey(_keyA), nostr.Schnorr.derivePublicKey(_keyA)); + expect(pubkeyForPrivkey(_keyB), nostr.Schnorr.derivePublicKey(_keyB)); + }); + + test('is stable across repeat calls and key switches', () { + final first = pubkeyForPrivkey(_keyA); + expect(pubkeyForPrivkey(_keyA), first); + final other = pubkeyForPrivkey(_keyB); + expect(other, isNot(first)); + // Switching back must re-derive rather than return the stale slot. + expect(pubkeyForPrivkey(_keyA), first); + }); + }); + + group('supplying the pubkey to Event.from', () { + // The point of the change: skipping Event.from's own derive must not alter + // the wire bytes. Asserted rather than argued, because it is crypto. + test('produces an identical event to letting Event.from derive it', () { + const createdAt = 1771000000; + final tags = [ + ['h', 'a-channel'], + ]; + + final derived = nostr.Event.from( + kind: 9, + content: 'equivalence', + tags: tags, + secretKey: _keyA, + createdAt: createdAt, + ); + final supplied = nostr.Event.from( + kind: 9, + content: 'equivalence', + tags: tags, + secretKey: _keyA, + createdAt: createdAt, + pubkey: pubkeyForPrivkey(_keyA), + ); + + expect(supplied.id, derived.id); + expect(supplied.pubkey, derived.pubkey); + expect(supplied.isValid(), isTrue); + expect(derived.isValid(), isTrue); + }); + + test('the derived pubkey is already lower-case hex', () { + // Event.from lower-cases only in the branch that derives, so a supplied + // key reaches the serialized `pubkey` field verbatim. + final pubkey = pubkeyForPrivkey(_keyA); + expect(pubkey, pubkey.toLowerCase()); + expect(pubkey, matches(RegExp(r'^[0-9a-f]{64}$'))); + }); + }); +}