mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
79
mobile/test/shared/crypto/ecdh_test.dart
Normal file
79
mobile/test/shared/crypto/ecdh_test.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user