From da1190e2c978646d8cac06f2f38ae58dab4c97a1 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 28 Oct 2026 13:54:45 -0700 Subject: [PATCH] perf(mobile): compute NIP-44 ECDH with native libsecp256k1 ecdhSharedSecret used pointycastle's BigInt point multiplication, which is slow (about 5.9 ms on an iPhone SE) and not constant time. It now calls libsecp256k1's secp256k1_ecdh with a raw-x hash callback, wiping the decoded secret key afterwards. Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tom Brow --- 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', + ); + } + }); +}