perf(mobile): sign iOS events with native secp256k1

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-18 18:10:41 -07:00
parent 0b53fc699d
commit 46a98f759c
28 changed files with 586 additions and 15 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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")]
),

View 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);
}

View File

@@ -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);

View File

@@ -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))
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +1,4 @@
import 'package:buzz/shared/crypto/event_signer.dart';
import 'composer_send_origin.dart';
import 'dart:async';
import 'dart:collection';

View File

@@ -396,7 +396,7 @@ void _sendTypingIndicator(
['e', threadHeadId, '', 'reply'],
];
final event = nostr.Event.from(
final event = signEvent(
kind: EventKind.typingIndicator,
content: '',
tags: tags,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View 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)');
}
}
}

View File

@@ -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: [

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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';

View File

@@ -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: [

View File

@@ -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,

View File

@@ -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,

View File

@@ -370,7 +370,7 @@ packages:
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
dependency: "direct main"
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"

View File

@@ -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

View 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);
},
);
},
);
}

View 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',
);
}
}

View 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')))),
),
);
}

View 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