mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
perf(mobile): compute NIP-44 ECDH with native libsecp256k1 (#8656)
`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 <tomb@block.xyz>
Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
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