Skip to content
Open
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
14 changes: 14 additions & 0 deletions .github/workflows/_ci-clients.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
brow marked this conversation as resolved.
- name: Test native pagination geometry
if: matrix.lane == 'native'
run: /bin/bash scripts/test-mobile-pagination.sh
Expand Down Expand Up @@ -245,11 +248,22 @@ jobs:
tail -n 5 "$build_log"
"${xcode[@]}" -parallel-testing-enabled NO -only-testing:RunnerTests \
test-without-building
/bin/bash scripts/test-mobile-linked-signing.sh "$device"
- name: Build complete unsigned iOS release
if: matrix.lane == 'release'
run: |
/bin/bash scripts/test-mobile-gateway-recipes.sh
cd mobile && flutter build ios --release --no-codesign --no-pub
# Flutter does not support Release mode on the simulator. Check the
# actual device Release executable's exported symbols separately.
# Archive applies install-time stripping that a plain build omits.
xcodebuild -workspace ios/Runner.xcworkspace -scheme Runner \
-configuration Release -destination 'generic/platform=iOS' \
-archivePath /tmp/BuzzSigning.xcarchive CODE_SIGNING_ALLOWED=NO archive
nm -gU /tmp/BuzzSigning.xcarchive/Products/Applications/Buzz.app/Buzz > /tmp/buzz-release-symbols
for symbol in buzz_signer_create buzz_signer_sign buzz_signer_destroy; do
grep -Eq "[[:space:]]_${symbol}$" /tmp/buzz-release-symbols
done

results:
name: Results
Expand Down
23 changes: 23 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,29 @@ 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. This change preserves existing call scheduling; worker offloading is a
separate change. 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.
`tool/compare_event_signers.dart` compares the existing Dart signer (with a
cached public key) and native signing in the same iOS process, on the UI isolate,
with identical inputs and alternating order. Seven warm samples on a Debug
iPhone 17 Pro Simulator measured median 64-event bursts of 209.7 ms (Dart) and
50.2 ms (native), a 76% reduction. These are synchronous event-construction
timings, not UIKit main-thread CPU measurements or rendered-frame timings.
Neither benchmark represents physical-device release performance.

## Run

```bash
Expand Down
2 changes: 1 addition & 1 deletion mobile/ios/BuzzPushKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
),
Expand Down
74 changes: 74 additions & 0 deletions mobile/ios/BuzzPushKit/Sources/CSecp256k1/buzz_signer.c
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
3 changes: 3 additions & 0 deletions mobile/ios/Flutter/Debug.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions mobile/ios/Flutter/Release.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P1] Preserve the FFI exports through archive stripping

These -u flags force the three functions into the linked executable, but do not preserve them through the subsequent archive/install strip step. Runner’s checked-in scheme archives Release, and its tracked configuration leaves STRIP_STYLE at Xcode’s default All Symbols. For a normal archive without an external override, the runtime-only buzz_signer_* exports can therefore be stripped even though the ordinary device build passes nm. The first DynamicLibrary.process() lookup in event_signer.dart then fails, breaking the migrated iOS authentication, media and send paths.

Set STRIP_STYLE = non-global for Runner Release (or explicitly preserve these exported symbols), as prescribed by Flutter’s iOS FFI stripping guidance, and check all three exports in the postprocessed archive/IPA executable. The current CI check at _ci-clients.yml:256–262 inspects flutter build ios --release --no-codesign, so it misses archive stripping. This is a source/configuration finding; I did not build an archive or observe an installed-app failure, and external release overrides were not inspected.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Addressed in 7aa63ef. Runner Release now uses non-global stripping, and CI inspects the postprocessed archive executable. A local unsigned device Release archive succeeded; its log confirms strip -x and nm -gU confirms all three buzz_signer exports remain. This validates archive retention without claiming installed physical-device execution.


// Preserve runtime-looked-up FFI exports during archive/install stripping.
STRIP_STYLE = non-global
1 change: 1 addition & 0 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:buzz/shared/crypto/event_signer.dart';
import 'composer_send_origin.dart';
import 'dart:async';
import 'dart:collection';
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/features/channels/compose_bar/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ void _sendTypingIndicator(
['e', threadHeadId, '', 'reply'],
];

final event = nostr.Event.from(
final event = signEvent(
kind: EventKind.typingIndicator,
content: '',
tags: tags,
Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/features/pairing/pairing_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions mobile/lib/features/pairing/pairing_socket.dart
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -212,7 +212,7 @@ class PairingSocket {
['challenge', challenge],
];

final event = nostr.Event.from(
final event = signEvent(
kind: EventKind.auth,
content: '',
tags: tags,
Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/features/profile/user_status_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading