diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 8c9d080430..0b23b4143a 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -212,6 +212,9 @@ jobs: - name: Test if: matrix.lane == 'native' run: swift test --package-path mobile/ios/BuzzPushKit + - name: Test native event signing through Dart FFI + if: matrix.lane == 'native' + run: /bin/bash scripts/test-mobile-native-signing.sh - name: Test native pagination geometry if: matrix.lane == 'native' run: /bin/bash scripts/test-mobile-pagination.sh diff --git a/mobile/README.md b/mobile/README.md index 8dfae1a7cd..abddd8c57f 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -79,6 +79,23 @@ GitHub's mobile CI currently uses the Xcode default of its `macos-latest` runner. The internal release toolchain is configured separately in [buzz-releases](https://github.com/squareup/buzz-releases). +### Event signing + +Use `shared/crypto/event_signer.dart` for signed events. iOS calls the existing +CSecp256k1 library through synchronous FFI; other platforms retain the Dart +signer. Burst-producing media authentication retains its bounded worker path +because Debug Simulator bursts still exceed a frame budget. Event hashing remains in `nostr.Event.unsigned`. The native context is +randomized once and shared read-only; each call creates, self-verifies with, +and destroys its own keypair. It never caches account secrets. + +The Runner linker flags retain all three FFI symbols in Debug and Release. +`NativeEventSignerTests` exercises the C adapter through Swift. The Dart +cross-implementation tests additionally accept `BUZZ_SIGNER_LIBRARY` pointing +to a locally compiled adapter library. `tool/benchmark_event_signer.dart` +measures complete host calls; `tool/native_signing_probe.dart` is an explicit +Simulator entry point for complete event, media-auth, and NIP-98 measurements. +Neither benchmark represents physical-device release performance. + ## Run ```bash diff --git a/mobile/ios/BuzzPushKit/Package.swift b/mobile/ios/BuzzPushKit/Package.swift index 4725549e92..aae66b93fe 100644 --- a/mobile/ios/BuzzPushKit/Package.swift +++ b/mobile/ios/BuzzPushKit/Package.swift @@ -13,7 +13,7 @@ let package = Package( targets: [ .target( name: "CSecp256k1", - sources: ["vendor/src/secp256k1.c", "vendor/src/precomputed_ecmult.c", "vendor/src/precomputed_ecmult_gen.c"], + sources: ["buzz_signer.c", "vendor/src/secp256k1.c", "vendor/src/precomputed_ecmult.c", "vendor/src/precomputed_ecmult_gen.c"], publicHeadersPath: "include", cSettings: [.define("ENABLE_MODULE_EXTRAKEYS"), .define("ENABLE_MODULE_SCHNORRSIG")] ), diff --git a/mobile/ios/BuzzPushKit/Sources/CSecp256k1/buzz_signer.c b/mobile/ios/BuzzPushKit/Sources/CSecp256k1/buzz_signer.c new file mode 100644 index 0000000000..3a1e9d5eda --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/CSecp256k1/buzz_signer.c @@ -0,0 +1,74 @@ +#include "include/CSecp256k1.h" +#include +#include +#include +#include + +struct buzz_signer { secp256k1_keypair pair; }; +static pthread_once_t context_once = PTHREAD_ONCE_INIT; +static secp256k1_context *context; +static int context_status = 3; + +static void wipe(void *buffer, size_t count) { + volatile unsigned char *bytes = buffer; + while (count--) *bytes++ = 0; +} + +/* Published once, then immutable. libsecp permits concurrent const operations. */ +static void initialize_context(void) { + unsigned char seed[32]; + if (SecRandomCopyBytes(kSecRandomDefault, sizeof(seed), seed) != errSecSuccess) { + wipe(seed, sizeof(seed)); + context_status = 2; + return; + } + context = secp256k1_context_create(SECP256K1_CONTEXT_NONE); + if (context && secp256k1_context_randomize(context, seed)) context_status = 0; + wipe(seed, sizeof(seed)); +} + +__attribute__((visibility("default"), used)) +int buzz_signer_create(const unsigned char secret[32], unsigned char public_key[32], buzz_signer **out) { + if (!secret || !public_key || !out) return 3; + *out = NULL; + pthread_once(&context_once, initialize_context); + if (context_status) return context_status; + buzz_signer *signer = calloc(1, sizeof(*signer)); + if (!signer) return 3; + if (!secp256k1_keypair_create(context, &signer->pair, secret)) { + buzz_signer_destroy(signer); + return 1; + } + secp256k1_xonly_pubkey key; + if (!secp256k1_keypair_xonly_pub(context, &key, NULL, &signer->pair) || + !secp256k1_xonly_pubkey_serialize(context, public_key, &key)) { + buzz_signer_destroy(signer); + return 3; + } + *out = signer; + return 0; +} + +__attribute__((visibility("default"), used)) +int buzz_signer_sign(const buzz_signer *signer, const unsigned char message[32], unsigned char signature[64]) { + if (!signer || !message || !signature) return 3; + unsigned char aux[32]; + if (SecRandomCopyBytes(kSecRandomDefault, sizeof(aux), aux) != errSecSuccess) { + wipe(aux, sizeof(aux)); + return 2; + } + secp256k1_xonly_pubkey key; + int ok = secp256k1_schnorrsig_sign32(context, signature, message, &signer->pair, aux) && + secp256k1_keypair_xonly_pub(context, &key, NULL, &signer->pair) && + secp256k1_schnorrsig_verify(context, signature, message, 32, &key); + wipe(aux, sizeof(aux)); + if (!ok) { wipe(signature, 64); return 3; } + return 0; +} + +__attribute__((visibility("default"), used)) +void buzz_signer_destroy(buzz_signer *signer) { + if (!signer) return; + wipe(signer, sizeof(*signer)); + free(signer); +} diff --git a/mobile/ios/BuzzPushKit/Sources/CSecp256k1/include/CSecp256k1.h b/mobile/ios/BuzzPushKit/Sources/CSecp256k1/include/CSecp256k1.h index 2c28f24dcc..de83b79836 100644 --- a/mobile/ios/BuzzPushKit/Sources/CSecp256k1/include/CSecp256k1.h +++ b/mobile/ios/BuzzPushKit/Sources/CSecp256k1/include/CSecp256k1.h @@ -1,3 +1,10 @@ #include "../vendor/include/secp256k1.h" #include "../vendor/include/secp256k1_extrakeys.h" #include "../vendor/include/secp256k1_schnorrsig.h" + +/* Short-lived signing handles own a keypair, never an account/session cache. */ +typedef struct buzz_signer buzz_signer; +/* 0 success, 1 invalid key, 2 random source, 3 allocation/operation failure. */ +int buzz_signer_create(const unsigned char secret[32], unsigned char public_key[32], buzz_signer **out); +int buzz_signer_sign(const buzz_signer *signer, const unsigned char message[32], unsigned char signature[64]); +void buzz_signer_destroy(buzz_signer *signer); diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NativeEventSignerTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NativeEventSignerTests.swift new file mode 100644 index 0000000000..18f1b25ece --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NativeEventSignerTests.swift @@ -0,0 +1,50 @@ +import CSecp256k1 +import Foundation +import XCTest + +@testable import BuzzPushKit + +final class NativeEventSignerTests: XCTestCase { + func testFixtureKeysAndCrossImplementationSignatures() throws { + let url = try XCTUnwrap(Bundle.module.url(forResource: "bip340", withExtension: "csv")) + for row in try String(contentsOf: url, encoding: .utf8).split(whereSeparator: \.isNewline).dropFirst() { + let fields = row.split(separator: ",", omittingEmptySubsequences: false).map(String.init) + guard !fields[1].isEmpty else { continue } + let secret = try XCTUnwrap(VerifiedNostrEvent.hexBytes(fields[1])) + let message = [UInt8](repeating: 42, count: 32) + var publicKey = [UInt8](repeating: 0, count: 32) + var handle: OpaquePointer? + XCTAssertEqual(buzz_signer_create(secret, &publicKey, &handle), 0) + let signer = try XCTUnwrap(handle) + defer { buzz_signer_destroy(signer) } + XCTAssertEqual(publicKey, try XCTUnwrap(VerifiedNostrEvent.hexBytes(fields[2]))) + var signature = [UInt8](repeating: 0, count: 64) + XCTAssertEqual(buzz_signer_sign(signer, message, &signature), 0) + XCTAssertTrue(NostrSchnorr.verify(signature: signature, message: message, publicKey: publicKey)) + } + } + + func testInvalidScalarDoesNotReturnHandle() { + for secret in [[UInt8](repeating: 0, count: 32), [UInt8](repeating: 255, count: 32)] { + var publicKey = [UInt8](repeating: 0, count: 32) + var handle: OpaquePointer? + XCTAssertEqual(buzz_signer_create(secret, &publicKey, &handle), 1) + XCTAssertNil(handle) + } + buzz_signer_destroy(nil) + } + + func testConcurrentIndependentKeypairs() { + DispatchQueue.concurrentPerform(iterations: 64) { index in + let secret = [UInt8](repeating: 0, count: 31) + [UInt8(index + 1)] + var publicKey = [UInt8](repeating: 0, count: 32) + var handle: OpaquePointer? + XCTAssertEqual(buzz_signer_create(secret, &publicKey, &handle), 0) + defer { buzz_signer_destroy(handle) } + let message = [UInt8](repeating: UInt8(index), count: 32) + var signature = [UInt8](repeating: 0, count: 64) + XCTAssertEqual(buzz_signer_sign(handle, message, &signature), 0) + XCTAssertTrue(NostrSchnorr.verify(signature: signature, message: message, publicKey: publicKey)) + } + } +} diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 3b2ba6e454..081d0fa956 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -27,3 +27,6 @@ BUZZ_APP_ATTEST_ENVIRONMENT = development // later-include-wins is per variable, so a personal signing identity beats the // worktree default while unset variables still fall through to tracked values. #include? "AppOverrides.xcconfig" + +// Keep the Dart FFI entry points visible in the app executable. +OTHER_LDFLAGS = $(inherited) -Wl,-u,_buzz_signer_create -Wl,-u,_buzz_signer_sign -Wl,-u,_buzz_signer_destroy diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index cdacb9e89b..fe283176be 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -17,3 +17,6 @@ BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) BUZZ_IOS_PUSH_ENVIRONMENT = production BUZZ_APP_ATTEST_ENVIRONMENT = production #include? "AppOverrides.xcconfig" + +// Keep the Dart FFI entry points visible in the app executable. +OTHER_LDFLAGS = $(inherited) -Wl,-u,_buzz_signer_create -Wl,-u,_buzz_signer_sign -Wl,-u,_buzz_signer_destroy diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index e528970a04..a115a4b5ad 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/crypto/event_signer.dart'; import 'composer_send_origin.dart'; import 'dart:async'; import 'dart:collection'; diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 45c57d2615..5517a5ca47 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -396,7 +396,7 @@ void _sendTypingIndicator( ['e', threadHeadId, '', 'reply'], ]; - final event = nostr.Event.from( + final event = signEvent( kind: EventKind.typingIndicator, content: '', tags: tags, diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index c426c85494..d882c0fb8a 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/crypto/event_signer.dart'; import '../../shared/auth/auth.dart'; import '../../shared/hosted/hosted_flow_provider.dart'; import '../../shared/hosted/hosted_environment.dart'; @@ -837,7 +838,7 @@ class PairingNotifier extends Notifier { final jitter = math.Random.secure().nextInt(31); final createdAt = (DateTime.now().millisecondsSinceEpoch ~/ 1000) - jitter; - final event = nostr.Event.from( + final event = signEvent( kind: kind, content: content, tags: tags, diff --git a/mobile/lib/features/pairing/pairing_socket.dart b/mobile/lib/features/pairing/pairing_socket.dart index 004f63d6c7..a33cf0d79e 100644 --- a/mobile/lib/features/pairing/pairing_socket.dart +++ b/mobile/lib/features/pairing/pairing_socket.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'dart:convert'; -import 'package:nostr/nostr.dart' as nostr; import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:buzz/shared/crypto/event_signer.dart'; import '../../shared/relay/nostr_models.dart'; const _desktopPairingAuthChallengeGrace = Duration(seconds: 3); @@ -212,7 +212,7 @@ class PairingSocket { ['challenge', challenge], ]; - final event = nostr.Event.from( + final event = signEvent( kind: EventKind.auth, content: '', tags: tags, diff --git a/mobile/lib/features/profile/user_status_provider.dart b/mobile/lib/features/profile/user_status_provider.dart index 1ed3c9218d..49b0fb554e 100644 --- a/mobile/lib/features/profile/user_status_provider.dart +++ b/mobile/lib/features/profile/user_status_provider.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/crypto/event_signer.dart'; import '../../shared/relay/relay.dart'; import 'user_status.dart'; import 'user_status_cache_provider.dart'; @@ -94,7 +95,7 @@ class UserStatusNotifier extends AsyncNotifier { } final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - final event = nostr.Event.from( + final event = signEvent( kind: EventKind.userStatus, content: trimmed, tags: tags, diff --git a/mobile/lib/shared/crypto/event_signer.dart b/mobile/lib/shared/crypto/event_signer.dart new file mode 100644 index 0000000000..2a867d32b3 --- /dev/null +++ b/mobile/lib/shared/crypto/event_signer.dart @@ -0,0 +1,130 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:nostr/nostr.dart' as nostr; + +/// Signs canonical Nostr events with libsecp256k1 on iOS. +/// +/// Native keypairs exist only for this call and are wiped before returning. +/// Other platforms retain the existing Dart implementation. +nostr.Event signEvent({ + required int kind, + required String content, + required String secretKey, + int? createdAt, + List> tags = const [], + bool verify = false, +}) { + if (!Platform.isIOS) { + return nostr.Event.from( + kind: kind, + content: content, + secretKey: secretKey, + createdAt: createdAt, + tags: tags, + verify: verify, + ); + } + final event = _native.sign( + kind: kind, + content: content, + secretKey: secretKey, + createdAt: createdAt, + tags: tags, + ); + return verify ? event.copyWith(verify: true) : event; +} + +final _native = NativeEventSigner(DynamicLibrary.process()); + +/// Native adapter exposed for cross-implementation tests and benchmarks. +class NativeEventSigner { + /// Loads the three versioned-by-app native entry points. Missing symbols fail + /// explicitly; iOS must never silently fall back to expensive Dart signing. + NativeEventSigner(DynamicLibrary library) + : _create = library + .lookupFunction< + Int32 Function( + Pointer, + Pointer, + Pointer>, + ), + int Function(Pointer, Pointer, Pointer>) + >('buzz_signer_create'), + _sign = library + .lookupFunction< + Int32 Function(Pointer, Pointer, Pointer), + int Function(Pointer, Pointer, Pointer) + >('buzz_signer_sign'), + _destroy = library + .lookupFunction< + Void Function(Pointer), + void Function(Pointer) + >('buzz_signer_destroy'); + + final int Function(Pointer, Pointer, Pointer>) + _create; + final int Function(Pointer, Pointer, Pointer) _sign; + final void Function(Pointer) _destroy; + + /// Builds the canonical hash using the same package as the Dart signer. + nostr.Event sign({ + required int kind, + required String content, + required String secretKey, + int? createdAt, + List> tags = const [], + }) { + if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(secretKey)) { + throw const FormatException('Signing key must be 32 hex bytes'); + } + final bytes = calloc(160); + final handle = calloc>(); + try { + for (var i = 0; i < 32; i++) { + bytes[i] = int.parse(secretKey.substring(i * 2, i * 2 + 2), radix: 16); + } + final publicKey = bytes + 32; + _check(_create(bytes, publicKey, handle)); + final event = nostr.Event.unsigned( + pubkey: _hex(publicKey, 32), + kind: kind, + content: content, + createdAt: createdAt, + tags: tags, + ); + final message = bytes + 64; + for (var i = 0; i < 32; i++) { + message[i] = int.parse(event.id.substring(i * 2, i * 2 + 2), radix: 16); + } + final signature = bytes + 96; + _check(_sign(handle.value, message, signature)); + // Native signing self-verifies. Avoid a second pure-Dart verification. + return event.copyWith(sig: _hex(signature, 64)); + } finally { + _destroy(handle.value); + bytes.asTypedList(160).fillRange(0, 160, 0); + calloc.free(bytes); + calloc.free(handle); + } + } + + static String _hex(Pointer bytes, int length) => bytes + .asTypedList(length) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + static void _check(int status) { + switch (status) { + case 0: + return; + case 1: + throw const FormatException('Invalid secp256k1 signing key'); + case 2: + throw StateError('Native signing random source failed'); + default: + throw StateError('Native signing failed ($status)'); + } + } +} diff --git a/mobile/lib/shared/huddle/huddle_auth.dart b/mobile/lib/shared/huddle/huddle_auth.dart index f6829c7a12..a559005c2b 100644 --- a/mobile/lib/shared/huddle/huddle_auth.dart +++ b/mobile/lib/shared/huddle/huddle_auth.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/crypto/event_signer.dart'; import '../relay/nostr_models.dart'; import 'huddle_wire.dart'; @@ -66,7 +67,7 @@ abstract final class HuddleAuthV2 { } final secretKey = _decodeSecretKey(parameters.nsec); - final event = nostr.Event.from( + final event = signEvent( kind: EventKind.auth, content: '', tags: [ diff --git a/mobile/lib/shared/me/me_sync.dart b/mobile/lib/shared/me/me_sync.dart index 00fc1c7bed..fac4bd7d49 100644 --- a/mobile/lib/shared/me/me_sync.dart +++ b/mobile/lib/shared/me/me_sync.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:buzz/shared/crypto/event_signer.dart'; import '../theme/theme_provider.dart'; import '../crypto/nip44.dart'; @@ -470,7 +471,7 @@ class MeSync { } check(); final signed = NostrEvent.fromJson( - nostr.Event.from( + signEvent( kind: 30078, content: content, secretKey: privateHex, diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index cc423a99c6..71ef0a795e 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 'package:buzz/shared/crypto/event_signer.dart'; import 'relay_provider.dart'; const _mediaGetAuthKind = 24242; @@ -120,7 +121,7 @@ class MediaGetAuthService { ['server', authority], ]; - return nostr.Event.from( + return signEvent( kind: _mediaGetAuthKind, content: 'Get buzz-media', tags: tags, diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index f94b476172..ba788841d1 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 'package:buzz/shared/crypto/event_signer.dart'; import 'animated_image_sanitizer.dart'; import 'media_auth.dart'; import 'mp4_fast_start.dart'; @@ -702,7 +703,7 @@ class MediaUploadService { ['server', serverAuthority], ]; - return nostr.Event.from( + return signEvent( kind: _uploadAuthKind, content: 'Upload buzz-media', tags: tags, diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index f955640a3e..a4aa380f40 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 'package:buzz/shared/crypto/event_signer.dart'; import '../../features/age_gate/age_signal_provider.dart'; import '../auth/auth.dart'; import '../hosted/hosted_relay_auth.dart'; diff --git a/mobile/lib/shared/relay/relay_session_auth.dart b/mobile/lib/shared/relay/relay_session_auth.dart index a198fc0151..f2838c9579 100644 --- a/mobile/lib/shared/relay/relay_session_auth.dart +++ b/mobile/lib/shared/relay/relay_session_auth.dart @@ -20,7 +20,7 @@ String buildNip98AuthHeader({ .process(Uint8List.fromList(bodyBytes)) .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(); - final event = nostr.Event.from( + final event = signEvent( kind: 27235, content: '', tags: [ diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 858f17e8e1..52d799d230 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -7,6 +7,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 'package:buzz/shared/crypto/event_signer.dart'; import 'nostr_models.dart'; import 'relay_websocket.dart'; @@ -224,7 +225,7 @@ class RelaySocket { ]; // Create and sign the kind:22242 AUTH event. - final event = nostr.Event.from( + final event = signEvent( kind: EventKind.auth, content: '', tags: tags, diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index b0106639eb..415dc966ae 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/crypto/event_signer.dart'; import 'nostr_models.dart'; import 'relay_session.dart'; import 'relay_socket.dart'; @@ -46,7 +47,7 @@ class SignedEventRelay { throw Exception('Invalid nsec'); } - final event = nostr.Event.from( + final event = signEvent( kind: kind, content: content, tags: tags, @@ -76,7 +77,7 @@ Future submitSignedEventOnce({ }) async { final privateKey = nostr.Nip19.decode(payload: nsec).data; if (privateKey.isEmpty) throw const FormatException('Invalid nsec'); - final signed = nostr.Event.from( + final signed = signEvent( kind: kind, content: content, tags: tags, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 2bfd5e1656..c040055d9b 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -370,7 +370,7 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 133a0fcf4e..cca7b7e910 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: web_socket_channel: ^3.0.1 connectivity_plus: ^7.0.0 nostr: ^2.0.0 + ffi: ^2.1.4 pointycastle: ^4.0.0 url_launcher: ^6.3.2 gpt_markdown: ^1.2.1 diff --git a/mobile/test/shared/crypto/event_signer_test.dart b/mobile/test/shared/crypto/event_signer_test.dart new file mode 100644 index 0000000000..d276b39fb5 --- /dev/null +++ b/mobile/test/shared/crypto/event_signer_test.dart @@ -0,0 +1,115 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:buzz/shared/crypto/event_signer.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + final library = Platform.environment['BUZZ_SIGNER_LIBRARY']; + final native = library == null + ? null + : NativeEventSigner(DynamicLibrary.open(library)); + final key = '0' * 63 + '3'; + + test('platform signer preserves canonical payload and valid signature', () { + final event = signEvent( + kind: 9, + content: 'hi\nšŸ', + secretKey: key, + createdAt: 1700000000, + tags: [ + ['h', 'community'], + ['x', '"\\'], + ], + ); + expect(event.isValid(), isTrue); + expect(event.content, 'hi\nšŸ'); + }); + + group( + 'native signing', + skip: library == null + ? 'Set BUZZ_SIGNER_LIBRARY to the compiled C adapter' + : false, + () { + test('BIP340 fixture keys produce cross-verified event signatures', () { + final lines = File( + 'ios/BuzzPushKit/Tests/BuzzPushKitTests/CryptoFixtures/bip340.csv', + ).readAsLinesSync().skip(1); + for (final line in lines) { + final fields = line.split(','); + if (fields[1].isEmpty) continue; + final event = native!.sign( + kind: 24242, + content: 'Fixture ${fields[0]} šŸ', + secretKey: fields[1], + createdAt: 1700000000, + tags: [ + ['t', 'get'], + ['expiration', '1700000060'], + ], + ); + expect(event.pubkey, fields[2].toLowerCase()); + expect(event.isValid(), isTrue); + final dart = nostr.Event.from( + kind: event.kind, + content: event.content, + secretKey: fields[1], + createdAt: event.createdAt, + tags: event.tags, + ); + expect(event.id, dart.id); + } + }); + test('alternating identities and scopes never reuse key material', () { + for (var i = 1; i <= 12; i++) { + final secret = i.toRadixString(16).padLeft(64, '0'); + final event = native!.sign( + kind: 9, + content: '$i', + secretKey: secret, + createdAt: 1700000000 + i, + tags: [ + ['h', 'scope-$i'], + ], + ); + expect(event.pubkey, nostr.Keys(secret).public); + expect(event.isValid(), isTrue); + } + }); + test('invalid key fails and does not poison subsequent signing', () { + for (final secret in ['', 'z' * 64, '0' * 64, 'f' * 64]) { + expect( + () => native!.sign(kind: 9, content: '', secretKey: secret), + throwsFormatException, + ); + } + expect( + native!.sign(kind: 9, content: '', secretKey: key).isValid(), + isTrue, + ); + }); + test( + 'fresh auxiliary randomness produces different valid signatures', + () { + final first = native!.sign( + kind: 9, + content: '', + secretKey: key, + createdAt: 1700000000, + ); + final second = native.sign( + kind: 9, + content: '', + secretKey: key, + createdAt: first.createdAt, + ); + expect(first.id, second.id); + expect(first.sig, isNot(second.sig)); + expect(second.isValid(), isTrue); + }, + ); + }, + ); +} diff --git a/mobile/tool/benchmark_event_signer.dart b/mobile/tool/benchmark_event_signer.dart new file mode 100644 index 0000000000..366dab626e --- /dev/null +++ b/mobile/tool/benchmark_event_signer.dart @@ -0,0 +1,40 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'package:buzz/shared/crypto/event_signer.dart'; + +/// Host FFI benchmark. Pass the compiled adapter dylib path as the argument. +void main(List args) { + final setup = Stopwatch()..start(); + final signer = NativeEventSigner(DynamicLibrary.open(args.single)); + final key = '0' * 63 + '3'; + void sign() => signer.sign( + kind: 24242, + content: '', + secretKey: key, + tags: [ + ['t', 'get'], + ['expiration', '1800000060'], + ], + ); + sign(); + stdout.writeln( + 'cold binding + context + complete event: ${setup.elapsedMicroseconds} us', + ); + for (var i = 0; i < 100; i++) { + sign(); + } + for (final burst in [1, 8, 32, 64]) { + final samples = []; + for (var round = 0; round < 100; round++) { + final watch = Stopwatch()..start(); + for (var i = 0; i < burst; i++) { + sign(); + } + samples.add(watch.elapsedMicroseconds); + } + samples.sort(); + stdout.writeln( + 'burst $burst: median ${samples[50]} us, p95 ${samples[95]} us, max ${samples.last} us', + ); + } +} diff --git a/mobile/tool/native_signing_probe.dart b/mobile/tool/native_signing_probe.dart new file mode 100644 index 0000000000..ec497c9178 --- /dev/null +++ b/mobile/tool/native_signing_probe.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/relay/media_auth.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/crypto/event_signer.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final results = []; + final key = '0' * 63 + '3'; + final nsec = nostr.Nip19.encode(prefix: nostr.Nip19Prefix.nsec, data: key); + final cold = Stopwatch()..start(); + final first = signEvent( + kind: 9, + content: 'iOS native signing šŸ', + secretKey: key, + ); + results.add('cold complete event: ${cold.elapsedMicroseconds} us'); + if (!first.isValid()) throw StateError('Native signature rejected by Dart'); + for (var i = 0; i < 100; i++) { + signEvent(kind: 9, content: '', secretKey: key); + } + for (final count in [1, 8, 32, 64]) { + final samples = []; + for (var round = 0; round < 100; round++) { + final watch = Stopwatch()..start(); + for (var i = 0; i < count; i++) { + signEvent( + kind: 24242, + content: '', + secretKey: key, + tags: [ + ['t', 'get'], + ['expiration', '1800000060'], + ], + ); + } + samples.add(watch.elapsedMicroseconds); + } + samples.sort(); + results.add( + 'burst $count: p50 ${samples[50]} p95 ${samples[95]} max ${samples.last} us', + ); + } + final media = MediaGetAuthService( + baseUrl: 'https://relay.example', + nsec: nsec, + ); + final watch = Stopwatch()..start(); + final proofs = >[]; + for (var i = 0; i < 64; i++) { + proofs.add(await media.headersForAsync('https://relay.example/media/$i')); + } + results.add('64 complete media headers: ${watch.elapsedMicroseconds} us'); + for (final proof in proofs) { + final encoded = proof['Authorization']!.split(' ').last; + final event = nostr.Event.fromJson(utf8.decode(base64Decode(encoded))); + if (!event.isValid()) throw StateError('Invalid media proof'); + } + if ((await media.headersForAsync( + 'https://foreign.example/media/x', + )).isNotEmpty) { + throw StateError('Foreign host received credentials'); + } + results.add('media signatures and foreign-host isolation: PASS'); + final queryWatch = Stopwatch()..start(); + final headers = []; + for (var i = 0; i < 32; i++) { + headers.add( + buildNip98AuthHeader( + nsec: nsec, + url: 'https://relay.example/query', + method: 'POST', + bodyBytes: utf8.encode( + jsonEncode({ + 'kinds': [9], + '#h': ['channel-$i'], + 'limit': 50, + }), + ), + ), + ); + } + results.add( + '32 complete NIP98 headers: ${queryWatch.elapsedMicroseconds} us', + ); + for (final header in headers) { + nostr.Event.fromJson(utf8.decode(base64Decode(header.split(' ').last))); + } + results.add('query signatures: PASS'); + for (final result in results) { + debugPrint('SIGNING_PROBE $result'); + } + runApp( + MaterialApp( + home: Scaffold(body: SafeArea(child: Text(results.join('\n')))), + ), + ); +} diff --git a/scripts/test-mobile-native-signing.sh b/scripts/test-mobile-native-signing.sh new file mode 100755 index 0000000000..02f965c8e4 --- /dev/null +++ b/scripts/test-mobile-native-signing.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Exercise the production C adapter from Dart on the macOS native CI runner. +set -euo pipefail +cd "$(dirname "$0")/.." +. ./bin/activate-hermit +native_test_dir=$(mktemp -d) +trap 'rm -rf "$native_test_dir"' EXIT +source_dir=mobile/ios/BuzzPushKit/Sources/CSecp256k1 +clang -O2 -dynamiclib -DENABLE_MODULE_EXTRAKEYS -DENABLE_MODULE_SCHNORRSIG \ + -framework Security \ + "$source_dir/buzz_signer.c" \ + "$source_dir/vendor/src/secp256k1.c" \ + "$source_dir/vendor/src/precomputed_ecmult.c" \ + "$source_dir/vendor/src/precomputed_ecmult_gen.c" \ + -o "$native_test_dir/libbuzz_signer.dylib" +cd mobile +BUZZ_SIGNER_LIBRARY="$native_test_dir/libbuzz_signer.dylib" \ + flutter test test/shared/crypto/event_signer_test.dart