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 <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-28 13:54:45 -07:00
parent 8161a7cad0
commit da1190e2c9
2 changed files with 93 additions and 29 deletions

View File

@@ -1,38 +1,28 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; 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). /// Compute secp256k1 ECDH shared secret (raw x-coordinate, unhashed).
/// ///
/// Both [privateKeyHex] and [publicKeyHex] are 64-char lowercase hex strings /// 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) { Uint8List ecdhSharedSecret(String privateKeyHex, String publicKeyHex) {
final params = ECCurve_secp256k1(); if (privateKeyHex.length != 64 || publicKeyHex.length != 64) {
throw ArgumentError('ECDH keys must be 64 hex characters');
// 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');
} }
final secretKey = hexToBytes(privateKeyHex);
// Scalar multiplication: shared = d * Q try {
final shared = pubPoint * d; return secp256k1.ecdhX(
if (shared == null || shared.isInfinity) { secretKey: secretKey,
throw StateError('ECDH produced point at infinity'); 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. /// Generate secure random bytes.
@@ -58,8 +48,3 @@ Uint8List hexToBytes(String hex) {
String bytesToHex(Uint8List bytes) { String bytesToHex(Uint8List bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); 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);
}

View File

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