mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
perf(mobile): sign iOS events with native secp256k1
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
3
.github/workflows/_ci-clients.yml
vendored
3
.github/workflows/_ci-clients.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
),
|
||||
|
||||
74
mobile/ios/BuzzPushKit/Sources/CSecp256k1/buzz_signer.c
Normal file
74
mobile/ios/BuzzPushKit/Sources/CSecp256k1/buzz_signer.c
Normal file
@@ -0,0 +1,74 @@
|
||||
#include "include/CSecp256k1.h"
|
||||
#include <Security/Security.h>
|
||||
#include <pthread.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:buzz/shared/crypto/event_signer.dart';
|
||||
import 'composer_send_origin.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
@@ -396,7 +396,7 @@ void _sendTypingIndicator(
|
||||
['e', threadHeadId, '', 'reply'],
|
||||
];
|
||||
|
||||
final event = nostr.Event.from(
|
||||
final event = signEvent(
|
||||
kind: EventKind.typingIndicator,
|
||||
content: '',
|
||||
tags: tags,
|
||||
|
||||
@@ -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<PairingState> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<UserStatus?> {
|
||||
}
|
||||
|
||||
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
||||
final event = nostr.Event.from(
|
||||
final event = signEvent(
|
||||
kind: EventKind.userStatus,
|
||||
content: trimmed,
|
||||
tags: tags,
|
||||
|
||||
130
mobile/lib/shared/crypto/event_signer.dart
Normal file
130
mobile/lib/shared/crypto/event_signer.dart
Normal file
@@ -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<List<String>> 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<Uint8>,
|
||||
Pointer<Uint8>,
|
||||
Pointer<Pointer<Void>>,
|
||||
),
|
||||
int Function(Pointer<Uint8>, Pointer<Uint8>, Pointer<Pointer<Void>>)
|
||||
>('buzz_signer_create'),
|
||||
_sign = library
|
||||
.lookupFunction<
|
||||
Int32 Function(Pointer<Void>, Pointer<Uint8>, Pointer<Uint8>),
|
||||
int Function(Pointer<Void>, Pointer<Uint8>, Pointer<Uint8>)
|
||||
>('buzz_signer_sign'),
|
||||
_destroy = library
|
||||
.lookupFunction<
|
||||
Void Function(Pointer<Void>),
|
||||
void Function(Pointer<Void>)
|
||||
>('buzz_signer_destroy');
|
||||
|
||||
final int Function(Pointer<Uint8>, Pointer<Uint8>, Pointer<Pointer<Void>>)
|
||||
_create;
|
||||
final int Function(Pointer<Void>, Pointer<Uint8>, Pointer<Uint8>) _sign;
|
||||
final void Function(Pointer<Void>) _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<List<String>> 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<Uint8>(160);
|
||||
final handle = calloc<Pointer<Void>>();
|
||||
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<Uint8> 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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<NostrEvent> 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,
|
||||
|
||||
@@ -370,7 +370,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
|
||||
@@ -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
|
||||
|
||||
115
mobile/test/shared/crypto/event_signer_test.dart
Normal file
115
mobile/test/shared/crypto/event_signer_test.dart
Normal file
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
40
mobile/tool/benchmark_event_signer.dart
Normal file
40
mobile/tool/benchmark_event_signer.dart
Normal file
@@ -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<String> 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 = <int>[];
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
100
mobile/tool/native_signing_probe.dart
Normal file
100
mobile/tool/native_signing_probe.dart
Normal file
@@ -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<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final results = <String>[];
|
||||
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 = <int>[];
|
||||
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 = <Map<String, String>>[];
|
||||
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 = <String>[];
|
||||
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')))),
|
||||
),
|
||||
);
|
||||
}
|
||||
18
scripts/test-mobile-native-signing.sh
Executable file
18
scripts/test-mobile-native-signing.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user