From cb2d4b1d01b87f63ec08010c09a882f056a8c938 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 28 Aug 2026 10:24:06 +0200 Subject: [PATCH 1/2] crypto: cache valid ECDH key pairs Avoid repeating EC key-pair validation after a pair has already been established or validated. Invalidate the positive-only cache whenever public-key mutation can make the pair inconsistent. Signed-off-by: Filip Skokan --- benchmark/crypto/ecdh-compute-secret.js | 117 ++++++++++++++++++++++++ src/crypto/crypto_ec.cc | 20 +++- src/crypto/crypto_ec.h | 3 + test/parallel/test-crypto-dh-curves.js | 36 ++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 benchmark/crypto/ecdh-compute-secret.js diff --git a/benchmark/crypto/ecdh-compute-secret.js b/benchmark/crypto/ecdh-compute-secret.js new file mode 100644 index 000000000000..3ce39f0cc6e9 --- /dev/null +++ b/benchmark/crypto/ecdh-compute-secret.js @@ -0,0 +1,117 @@ +'use strict'; + +const common = require('../common.js'); +const assert = require('node:assert'); +const crypto = require('node:crypto'); + +const kCurve = 'prime256v1'; +const kPeerPoolSize = 32; +const scenarios = [ + 'first-after-generate', + 'full-lifecycle', + 'reused-local-same-peer', + 'reused-local-peer-pool', +]; + +const bench = common.createBenchmark(main, { + scenario: scenarios, + n: [5_000], +}, { + test: { scenario: scenarios, n: 1 }, +}); + +function generateContext() { + const context = crypto.createECDH(kCurve); + context.generateKeys(); + return context; +} + +function verifySecret(secret, local, peer) { + assert.deepStrictEqual(secret, peer.computeSecret(local.getPublicKey())); +} + +function firstAfterGenerate(n) { + const peer = generateContext(); + const peerPublicKey = peer.getPublicKey(); + const warmup = generateContext(); + warmup.computeSecret(peerPublicKey); + + const locals = Array.from({ length: n }, generateContext); + const secrets = new Array(n); + + bench.start(); + for (let i = 0; i < n; i++) + secrets[i] = locals[i].computeSecret(peerPublicKey); + bench.end(n); + + verifySecret(secrets[n - 1], locals[n - 1], peer); +} + +function fullLifecycle(n) { + const peer = generateContext(); + const peerPublicKey = peer.getPublicKey(); + const warmup = generateContext(); + warmup.computeSecret(peerPublicKey); + + const locals = new Array(n); + const secrets = new Array(n); + + bench.start(); + for (let i = 0; i < n; i++) { + const local = locals[i] = generateContext(); + secrets[i] = local.computeSecret(peerPublicKey); + } + bench.end(n); + + verifySecret(secrets[n - 1], locals[n - 1], peer); +} + +function reusedLocalSamePeer(n) { + const local = generateContext(); + const peer = generateContext(); + const peerPublicKey = peer.getPublicKey(); + local.computeSecret(peerPublicKey); + + const secrets = new Array(n); + + bench.start(); + for (let i = 0; i < n; i++) + secrets[i] = local.computeSecret(peerPublicKey); + bench.end(n); + + verifySecret(secrets[n - 1], local, peer); +} + +function reusedLocalPeerPool(n) { + const local = generateContext(); + const peers = Array.from( + { length: Math.min(n, kPeerPoolSize) }, + generateContext); + const peerPublicKeys = peers.map((peer) => peer.getPublicKey()); + local.computeSecret(peerPublicKeys[0]); + + const secrets = new Array(n); + + bench.start(); + for (let i = 0; i < n; i++) + secrets[i] = local.computeSecret(peerPublicKeys[i % peers.length]); + bench.end(n); + + const lastPeer = peers[(n - 1) % peers.length]; + verifySecret(secrets[n - 1], local, lastPeer); +} + +function main({ scenario, n }) { + switch (scenario) { + case 'first-after-generate': + return firstAfterGenerate(n); + case 'full-lifecycle': + return fullLifecycle(n); + case 'reused-local-same-peer': + return reusedLocalSamePeer(n); + case 'reused-local-peer-pool': + return reusedLocalPeerPool(n); + default: + throw new Error(`Unsupported scenario: ${scenario}`); + } +} diff --git a/src/crypto/crypto_ec.cc b/src/crypto/crypto_ec.cc index b08d4b7a9106..d6920c8b30e2 100644 --- a/src/crypto/crypto_ec.cc +++ b/src/crypto/crypto_ec.cc @@ -134,9 +134,12 @@ void ECDH::GenerateKeys(const FunctionCallbackInfo& args) { ECDH* ecdh; ASSIGN_OR_RETURN_UNWRAP(&ecdh, args.This()); + const uint64_t generation = ncrypto::getFipsStateGeneration(); + ecdh->has_valid_key_pair_ = false; if (!ecdh->key_.generate()) { return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to generate key"); } + ecdh->MaybeCacheValidKeyPair(generation); } ECPointPointer ECDH::BufferToPoint(Environment* env, @@ -307,6 +310,7 @@ void ECDH::SetPrivateKey(const FunctionCallbackInfo& args) { ecdh->key_ = std::move(new_key); ecdh->group_ = ecdh->key_.getGroup(); + ecdh->has_valid_key_pair_ = false; } void ECDH::SetPublicKey(const FunctionCallbackInfo& args) { @@ -325,6 +329,7 @@ void ECDH::SetPublicKey(const FunctionCallbackInfo& args) { "Failed to convert Buffer to EC_POINT"); } + ecdh->has_valid_key_pair_ = false; if (!ecdh->key_.setPublicKey(pub)) { return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to set EC_POINT as the public key"); @@ -345,9 +350,22 @@ bool ECDH::IsKeyValidForCurve(const BignumPointer& private_key) { private_key < order; } +void ECDH::MaybeCacheValidKeyPair(uint64_t generation) { + has_valid_key_pair_ = generation == ncrypto::getFipsStateGeneration(); + if (has_valid_key_pair_) valid_key_pair_generation_ = generation; +} + bool ECDH::IsKeyPairValid() { + const uint64_t generation = ncrypto::getFipsStateGeneration(); + if (has_valid_key_pair_ && valid_key_pair_generation_ == generation) { + return true; + } + has_valid_key_pair_ = false; + MarkPopErrorOnReturn mark_pop_error_on_return; - return key_.checkKey(); + const bool is_valid = key_.checkKey(); + if (is_valid) MaybeCacheValidKeyPair(generation); + return is_valid; } // Convert the input public key to compressed, uncompressed, or hybrid formats. diff --git a/src/crypto/crypto_ec.h b/src/crypto/crypto_ec.h index 9bc817f3d7f5..3e83d541d7ae 100644 --- a/src/crypto/crypto_ec.h +++ b/src/crypto/crypto_ec.h @@ -48,11 +48,14 @@ class ECDH final : public BaseObject { static void GetPublicKey(const v8::FunctionCallbackInfo& args); static void SetPublicKey(const v8::FunctionCallbackInfo& args); + void MaybeCacheValidKeyPair(uint64_t generation); bool IsKeyPairValid(); bool IsKeyValidForCurve(const ncrypto::BignumPointer& private_key); ncrypto::ECKeyPointer key_; const EC_GROUP* group_; + bool has_valid_key_pair_ = false; + uint64_t valid_key_pair_generation_ = 0; }; struct EcKeyPairParams final : public MemoryRetainer { diff --git a/test/parallel/test-crypto-dh-curves.js b/test/parallel/test-crypto-dh-curves.js index ee8849163ae8..f7530aecfc5d 100644 --- a/test/parallel/test-crypto-dh-curves.js +++ b/test/parallel/test-crypto-dh-curves.js @@ -144,9 +144,15 @@ if (availableCurves.has('prime256v1') && availableCurves.has('secp256k1')) { ecdh4.setPrivateKey(ecdh1.getPrivateKey()); ecdh4.setPublicKey(ecdh1.getPublicKey()); + const ecdh4Secret = ecdh4.computeSecret(ecdh2.getPublicKey()); + assert.deepStrictEqual(ecdh4.computeSecret(ecdh2.getPublicKey()), + ecdh4Secret); + assert.throws(() => { ecdh4.setPublicKey(ecdh3.getPublicKey()); }, { message: 'Failed to convert Buffer to EC_POINT' }); + assert.deepStrictEqual(ecdh4.computeSecret(ecdh2.getPublicKey()), + ecdh4Secret); // Verify that we can use ECDH without having to use newly generated keys. const ecdh5 = crypto.createECDH('secp256k1'); @@ -190,6 +196,8 @@ if (availableCurves.has('prime256v1') && availableCurves.has('secp256k1')) { sharedSecret); assert.strictEqual(ecdh5.computeSecret(peerPubPtUnComp, 'hex', 'hex'), sharedSecret); + assert.strictEqual(ecdh5.computeSecret(peerPubPtComp, 'hex', 'hex'), + sharedSecret); // Verify that we still have the same key pair as before the computation. assert.strictEqual(ecdh5.getPrivateKey('hex'), cafebabeKey); @@ -260,3 +268,31 @@ if (availableCurves.has('prime256v1') && availableHashes.has('sha256')) { '-----END EC PRIVATE KEY-----'; crypto.createSign('SHA256').sign(ecPrivateKey); } + +if (hasFIPS(3) && availableCurves.has('secp256k1')) { + const originalFips = crypto.getFips(); + + try { + crypto.setFips(0); + const local = crypto.createECDH('secp256k1'); + const peer = crypto.createECDH('secp256k1'); + local.generateKeys(); + const peerPublicKey = peer.generateKeys(); + + local.computeSecret(peerPublicKey); + crypto.setFips(1); + assert.throws(() => local.computeSecret(peerPublicKey), { + code: 'ERR_CRYPTO_INVALID_KEYPAIR', + name: 'RangeError', + }); + + const installed = crypto.createECDH('secp256k1'); + installed.setPrivateKey(Buffer.from('cafebabe'.repeat(8), 'hex')); + assert.throws(() => installed.computeSecret(peerPublicKey), { + code: 'ERR_CRYPTO_INVALID_KEYPAIR', + name: 'RangeError', + }); + } finally { + crypto.setFips(originalFips); + } +} From 2b1f95ea041b71254c730277858bd4e585ec3621 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 28 Aug 2026 12:26:10 +0200 Subject: [PATCH 2/2] fixup! crypto: cache valid ECDH key pairs --- benchmark/crypto/ecdh-compute-secret.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmark/crypto/ecdh-compute-secret.js b/benchmark/crypto/ecdh-compute-secret.js index 3ce39f0cc6e9..3061c5b2cc36 100644 --- a/benchmark/crypto/ecdh-compute-secret.js +++ b/benchmark/crypto/ecdh-compute-secret.js @@ -17,7 +17,7 @@ const bench = common.createBenchmark(main, { scenario: scenarios, n: [5_000], }, { - test: { scenario: scenarios, n: 1 }, + test: { scenario: 'first-after-generate', n: 1 }, }); function generateContext() {