Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mobile/lib/features/channels/compose_bar/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ void _sendTypingIndicator(
content: '',
tags: tags,
secretKey: privkeyHex,
pubkey: pubkeyForPrivkey(privkeyHex),
verify: false,
);

Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/pairing/pairing_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,7 @@ class PairingNotifier extends Notifier<PairingState> {
content: content,
tags: tags,
secretKey: _ephemeralPrivkey!,
pubkey: pubkeyForPrivkey(_ephemeralPrivkey!),
createdAt: createdAt,
);

Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/features/pairing/pairing_socket.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -197,6 +198,7 @@ class PairingSocket {
content: '',
tags: tags,
secretKey: _ephemeralPrivkey,
pubkey: pubkeyForPrivkey(_ephemeralPrivkey),
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);

Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/profile/user_status_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class UserStatusNotifier extends AsyncNotifier<UserStatus?> {
content: trimmed,
tags: tags,
secretKey: privkeyHex,
pubkey: pubkeyForPrivkey(privkeyHex),
verify: false,
);

Expand Down
27 changes: 27 additions & 0 deletions mobile/lib/shared/relay/event_signing.dart
Original file line number Diff line number Diff line change
@@ -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 = '';
2 changes: 2 additions & 0 deletions mobile/lib/shared/relay/media_auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -118,6 +119,7 @@ class MediaGetAuthService {
content: 'Get buzz-media',
tags: tags,
secretKey: privkeyHex,
pubkey: pubkeyForPrivkey(privkeyHex),
verify: false,
);
}
Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/shared/relay/media_upload.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -615,6 +616,7 @@ class MediaUploadService {
content: 'Upload buzz-media',
tags: tags,
secretKey: privkeyHex,
pubkey: pubkeyForPrivkey(privkeyHex),
verify: false,
);
}
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/shared/relay/relay.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/shared/relay/relay_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()))}';
Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/shared/relay/relay_socket.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -221,6 +222,7 @@ class RelaySocket {
content: '',
tags: tags,
secretKey: privkeyHex,
pubkey: pubkeyForPrivkey(privkeyHex),
);

_pendingAuthEventId = event.id;
Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/shared/relay/signed_event_relay.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:nostr/nostr.dart' as nostr;

import 'event_signing.dart';
import 'nostr_models.dart';
import 'relay_session.dart';

Expand Down Expand Up @@ -48,6 +49,7 @@ class SignedEventRelay {
content: content,
tags: tags,
secretKey: privkeyHex,
pubkey: pubkeyForPrivkey(privkeyHex),
createdAt: createdAt,
verify: false,
);
Expand Down
68 changes: 68 additions & 0 deletions mobile/test/shared/relay/event_signing_test.dart
Original file line number Diff line number Diff line change
@@ -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}$')));
});
});
}