From 3dbc95c3f668547e501c7d5f306d85ca5dcd055e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Oct 2026 10:58:25 -0700 Subject: [PATCH] perf(mobile): compute NIP-44 ECDH with native libsecp256k1 (#8656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ecdhSharedSecret`, the input to every NIP-44 conversation key (preferences, read state, reminders, themes, mutes, stars, sections, sort, pairing, agent observer), used pointycastle's BigInt point multiplication. That costs about 5.9 ms per call on an iPhone SE (2nd gen, Profile) and is variable time in the secret scalar. It now calls libsecp256k1's constant-time `secp256k1_ecdh` through `ecdhX` from #8654 (39.8 µs on the same device), returning the same raw x-coordinate NIP-44 requires. The decoded secret key bytes are zeroed after the call. Invalid keys now throw `ArgumentError` instead of pointycastle's errors; callers already treat any throw as a failed derivation. ### Validation - Added tests: `ecdh_test.dart` compares the native result with the previous pointycastle algorithm for 32 random key pairs, checks the NIP-44 v2 `get_conversation_key` vector (secret 1, peer pubkey of secret 2), and checks rejection of a zero secret, an off-curve public key and wrong lengths. - Full mobile analyze and test suite pass after rebasing onto main at da1190e2c, including the desktop nostr-rs NIP-44 interop vector. - On a device: an iPhone 12 mini Profile build of the full native stack, connected to the internal relay. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> --- mobile/lib/shared/crypto/ecdh.dart | 43 +++++-------- mobile/test/shared/crypto/ecdh_test.dart | 79 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 29 deletions(-) create mode 100644 mobile/test/shared/crypto/ecdh_test.dart diff --git a/mobile/lib/shared/crypto/ecdh.dart b/mobile/lib/shared/crypto/ecdh.dart index 493d09a676..0a26a80026 100644 --- a/mobile/lib/shared/crypto/ecdh.dart +++ b/mobile/lib/shared/crypto/ecdh.dart @@ -1,38 +1,28 @@ import 'dart:math' as math; import 'dart:typed_data'; -import 'package:pointycastle/ecc/curves/secp256k1.dart'; +import 'package:buzz_secp256k1/buzz_secp256k1.dart' as secp256k1; /// Compute secp256k1 ECDH shared secret (raw x-coordinate, unhashed). /// /// Both [privateKeyHex] and [publicKeyHex] are 64-char lowercase hex strings -/// (32 bytes each). The public key is an x-only BIP-340 key. +/// (32 bytes each). The public key is an x-only BIP-340 key, lifted to even Y. /// -/// Returns the 32-byte x-coordinate of the shared point. +/// Returns the 32-byte x-coordinate of the shared point, computed in constant +/// time by libsecp256k1. Throws [ArgumentError] when either key is invalid. Uint8List ecdhSharedSecret(String privateKeyHex, String publicKeyHex) { - final params = ECCurve_secp256k1(); - - // Parse private key scalar. - final d = BigInt.parse(privateKeyHex, radix: 16); - - // Lift x-only public key to a full curve point (assume even y / prefix 02). - final xBytes = hexToBytes(publicKeyHex); - final compressed = Uint8List(33); - compressed[0] = 0x02; // even y - compressed.setRange(1, 33, xBytes); - final pubPoint = params.curve.decodePoint(compressed); - if (pubPoint == null) { - throw ArgumentError('Invalid public key: cannot decode point'); + if (privateKeyHex.length != 64 || publicKeyHex.length != 64) { + throw ArgumentError('ECDH keys must be 64 hex characters'); } - - // Scalar multiplication: shared = d * Q - final shared = pubPoint * d; - if (shared == null || shared.isInfinity) { - throw StateError('ECDH produced point at infinity'); + final secretKey = hexToBytes(privateKeyHex); + try { + return secp256k1.ecdhX( + secretKey: secretKey, + publicKey: hexToBytes(publicKeyHex), + ); + } finally { + secretKey.fillRange(0, secretKey.length, 0); } - - // Extract 32-byte x-coordinate (big-endian, zero-padded). - return bigIntTo32Bytes(shared.x!.toBigInteger()!); } /// Generate secure random bytes. @@ -58,8 +48,3 @@ Uint8List hexToBytes(String hex) { String bytesToHex(Uint8List bytes) { return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); } - -Uint8List bigIntTo32Bytes(BigInt value) { - final hexStr = value.toRadixString(16).padLeft(64, '0'); - return hexToBytes(hexStr); -} diff --git a/mobile/test/shared/crypto/ecdh_test.dart b/mobile/test/shared/crypto/ecdh_test.dart new file mode 100644 index 0000000000..ae4e844f0f --- /dev/null +++ b/mobile/test/shared/crypto/ecdh_test.dart @@ -0,0 +1,79 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:buzz/shared/crypto/ecdh.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/ecc/curves/secp256k1.dart'; + +/// The previous pure-Dart ECDH, kept as a reference implementation. +Uint8List _referenceEcdh(String privateKeyHex, String publicKeyHex) { + final params = ECCurve_secp256k1(); + final compressed = Uint8List(33) + ..[0] = 0x02 + ..setRange(1, 33, hexToBytes(publicKeyHex)); + final shared = + params.curve.decodePoint(compressed)! * + BigInt.parse(privateKeyHex, radix: 16); + return hexToBytes( + shared!.x!.toBigInteger()!.toRadixString(16).padLeft(64, '0'), + ); +} + +String _randomSecretKey(Random rng) { + while (true) { + final hex = List.generate( + 32, + (_) => rng.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + try { + nostr.Keys(hex); + return hex; + } on Object { + // Out of range for secp256k1; draw again. + } + } +} + +void main() { + test('matches the previous pure-Dart ECDH for random key pairs', () { + final rng = Random(44); + for (var i = 0; i < 32; i++) { + final secretKey = _randomSecretKey(rng); + final peer = nostr.Keys(_randomSecretKey(rng)).public; + expect( + ecdhSharedSecret(secretKey, peer), + _referenceEcdh(secretKey, peer), + ); + } + }); + + test('NIP-44 v2 conversation key vector', () { + expect( + bytesToHex( + getConversationKey( + '0000000000000000000000000000000000000000000000000000000000000001', + 'c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5', + ), + ), + 'c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d', + ); + }); + + test('rejects invalid keys', () { + final valid = nostr.Keys('1' * 64).public; + for (final (secretKey, publicKey) in [ + ('0' * 64, valid), + ('1' * 64, '0' * 64), + ('1' * 62, valid), + ('1' * 64, valid.substring(2)), + ]) { + expect( + () => ecdhSharedSecret(secretKey, publicKey), + throwsArgumentError, + reason: '$secretKey / $publicKey', + ); + } + }); +}