crypto: defer raw EC public key derivation · nodejs/node@0ddf3c9 · GitHub
Skip to content

Commit 0ddf3c9

Browse files
committed
crypto: defer raw EC public key derivation
Keep raw EC private keys scalar-only until an operation needs the public point. Cache a separate complete key in the shared backing data without mutating keys used by asynchronous jobs. Eagerly import only when the operation needs the public point right away. Assisted-by: GitHub Copilot Signed-off-by: Filip Skokan <panva.ip@gmail.com>
1 parent 842794d commit 0ddf3c9

18 files changed

Lines changed: 668 additions & 56 deletions
Lines changed: 47 additions & 0 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use strict';
2+
3+
const common = require('../common.js');
4+
const { hasOpenSSL } = require('../../test/common/crypto.js');
5+
const {
6+
createPrivateKey,
7+
generateKeyPairSync,
8+
sign,
9+
verify,
10+
} = require('crypto');
11+
12+
const bench = common.createBenchmark(main, {
13+
algorithm: hasOpenSSL(3, 5) ? ['ec', 'ed25519', 'ml-dsa-44'] : ['ec', 'ed25519'],
14+
format: ['raw', 'der'],
15+
operation: ['import', 'import-sign', 'direct-sign', 'reused-sign'],
16+
n: [1000],
17+
});
18+
19+
function main({ algorithm, format, operation, n }) {
20+
const { privateKey, publicKey } = generateKeyPairSync(algorithm, { namedCurve: 'P-256' });
21+
const rawFormat = algorithm === 'ml-dsa-44' ? 'raw-seed' : 'raw-private';
22+
const options = format === 'raw' ? {
23+
key: privateKey.export({ format: rawFormat }),
24+
format: rawFormat,
25+
asymmetricKeyType: algorithm,
26+
namedCurve: 'P-256',
27+
} : {
28+
key: privateKey.export({ format: 'der', type: 'pkcs8' }),
29+
format: 'der',
30+
type: 'pkcs8',
31+
};
32+
const message = Buffer.alloc(32, 1);
33+
const digest = algorithm === 'ec' ? 'sha256' : null;
34+
const reused = createPrivateKey(options);
35+
if (!verify(digest, message, publicKey, sign(digest, message, reused)))
36+
throw new Error('Invalid signature');
37+
bench.start();
38+
for (let index = 0; index < n; index++) {
39+
switch (operation) {
40+
case 'import': createPrivateKey(options); break;
41+
case 'import-sign': sign(digest, message, createPrivateKey(options)); break;
42+
case 'direct-sign': sign(digest, message, options); break;
43+
case 'reused-sign': sign(digest, message, reused); break;
44+
}
45+
}
46+
bench.end(n);
47+
}

‎deps/ncrypto/ncrypto.cc‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2948,6 +2948,23 @@ EVPKeyPointer EVPKeyPointer::New() {
29482948
return EVPKeyPointer(EVP_PKEY_new());
29492949
}
29502950

2951+
#if NCRYPTO_USE_OPENSSL3_PROVIDER
2952+
EVPKeyPointer Ec::ImportPrivate(int curve, const BignumPointer& priv) {
2953+
const char* name = OBJ_nid2sn(curve);
2954+
OSSLParamBldPointer builder(OSSL_PARAM_BLD_new());
2955+
if (name == nullptr || !priv || !builder ||
2956+
OSSL_PARAM_BLD_push_utf8_string(
2957+
builder.get(), OSSL_PKEY_PARAM_GROUP_NAME, name, 0) != 1 ||
2958+
OSSL_PARAM_BLD_push_BN(
2959+
builder.get(), OSSL_PKEY_PARAM_PRIV_KEY, priv.get()) != 1) {
2960+
return {};
2961+
}
2962+
OSSLParamPointer params(OSSL_PARAM_BLD_to_param(builder.get()));
2963+
if (!params) return {};
2964+
return NewPKeyFromData(EVP_PKEY_EC, EVP_PKEY_KEYPAIR, params.get());
2965+
}
2966+
#endif
2967+
29512968
EVPKeyPointer EVPKeyPointer::NewRawPublic(
29522969
int id, const Buffer<const unsigned char>& data) {
29532970
if (id == 0) return {};

‎deps/ncrypto/ncrypto.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,9 @@ class Ec final {
813813

814814
static int GetCurveIdFromName(const char* name);
815815
static int GetCurveId(const EVPKeyPointer& key);
816+
#if NCRYPTO_USE_OPENSSL3_PROVIDER
817+
static EVPKeyPointer ImportPrivate(int curve, const BignumPointer& priv);
818+
#endif
816819
static DataPointer TryExportPublic(const EVPKeyPointer& key,
817820
point_conversion_form_t form);
818821
static DataPointer ExportPrivate(const EVPKeyPointer& key);

‎doc/api/crypto.md‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,14 @@ See [`keyObject.export()`][], [`crypto.createPublicKey()`][], and
169169

170170
`'raw-public'` is generally the fastest way to import a public key.
171171
`'raw-private'` and `'raw-seed'` are not always faster than other formats
172-
because they only contain the private scalar or seed - importing them requires
173-
deriving the public key component (e.g. elliptic curve point multiplication or
174-
seed expansion), which can be expensive. Other formats include both private
175-
and public components, avoiding that computation.
172+
because deriving a public key or expanding a seed can be expensive. Formats
173+
that include both private and public components can avoid this work.
174+
175+
For EC private keys, the public point is only derived when needed. Signing
176+
and ECDH key agreement do not need the local private key's public point, so
177+
importing `'raw-private'` avoids this computation. Extracting the public key,
178+
exporting JWK, PEM, or DER, or using EC decapsulation derives the point on
179+
first use and caches it for subsequent operations.
176180

177181
### Choosing a key format
178182

@@ -194,10 +198,11 @@ example:
194198

195199
* Public keys - `'raw-public'` is the fastest serialized format because the
196200
raw format skips all ASN.1 and Base64 decoding.
197-
* EC private keys - `'raw-private'` is faster than PEM or DER because it
198-
avoids ASN.1 parsing. However, for larger curves (e.g. P-384, P-521) the
199-
required derivation of the public point from the private scalar becomes
200-
expensive, reducing the advantage.
201+
* EC private keys - `'raw-private'` avoids ASN.1 parsing and deriving the
202+
public point until it is needed. This reduces the work needed to import
203+
a key for signing or ECDH. If the public point is needed immediately,
204+
a format that already
205+
includes it can be faster.
201206
* RSA keys - `'jwk'` is the fastest serialized format. JWK represents RSA
202207
key components as individual Base64url-encoded integers, avoiding the
203208
overhead of ASN.1 parsing entirely.
@@ -210,13 +215,10 @@ accept a PEM string or an object specifying the format and key
210215
material directly. In this case the total cost is the sum of key import and
211216
the cryptographic computation itself.
212217

213-
For operations where the cryptographic computation dominates - such as
214-
signing with RSA or ECDH key agreement with P-384 or P-521 - the
215-
serialization format has negligible impact on overall throughput, so choose
216-
whichever format is most convenient. For lightweight operations like Ed25519
217-
signing or verification, the import cost is a larger fraction of the total,
218-
so a faster format like `'raw-public'` or `'raw-private'` can meaningfully
219-
improve throughput.
218+
When the cryptographic computation dominates, reducing the import cost has
219+
less impact on overall throughput. For lightweight operations, the import
220+
cost is a larger fraction of the total, so a faster import format can
221+
meaningfully improve throughput.
220222

221223
Even if the same key material is used only a few times, it is worth importing it
222224
into a [`KeyObject`][] rather than passing the raw or PEM representation

‎src/crypto/README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,38 @@ slots on first access. For Hybrid KEM `CryptoKey` instances only,
193193
`NativeCryptoKey` may also store a secondary `KeyObjectData` and `seed_data`
194194
used to reconstruct the hybrid key material.
195195

196+
#### EC private keys
197+
198+
An EC key imported from `'raw-private'` can contain just the private scalar,
199+
without the public point. These keys are marked with
200+
`KeyObjectData::PublicKeyState::Deferred`. Signing, ECDH with the local private
201+
key, reading metadata, and exporting the private scalar do not need the
202+
public point and can use `GetAsymmetricKey()` directly. This does not change
203+
how other key types or PEM/DER keys are imported.
204+
205+
Operations that need the public point use `WithPublicKey()`. These include
206+
public key extraction, verification, JWK and PEM/DER exports, key comparison,
207+
ECDH with a peer key, EC KEM operations, and certificate matching.
208+
Call `WithPublicKey()` before locking the key's mutex, check its result, and
209+
keep the returned `KeyObjectData` alive while using the key. The overload
210+
that takes an `Environment*` throws a JavaScript exception if derivation fails.
211+
212+
`WithPublicKey()` creates and caches a separate key containing the public
213+
point. It does not modify the original `EVP_PKEY`, which may be in use by an
214+
asynchronous job. Access to the cache is protected by the shared mutex, and
215+
structured clones use the same cache. A change to the FIPS state causes the
216+
cached key to be replaced on the next call. Previously returned
217+
`KeyObjectData` instances keep the old key alive for operations using it.
218+
When comparing keys, call `WithPublicKey()` on each key separately, without
219+
holding either mutex. Both keys may share the same backing data.
220+
221+
When an operation needs the public point immediately, the import caller
222+
requests `PublicKeyState::Present`. Direct raw input to public key creation
223+
or decapsulation uses this path. Private key creation and signing can leave
224+
the point to be derived later. Conversion to a CryptoKey checks only the
225+
private component for keys marked `Deferred`; other keys still use the full
226+
key check.
227+
196228
### X.509 certificates
197229

198230
The public `X509Certificate` is backed directly by the native

‎src/crypto/crypto_context.cc‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1669,7 +1669,9 @@ Maybe<void> SecureContext::UseKey(Environment* env, const KeyObjectData& key) {
16691669
}
16701670

16711671
ClearErrorOnReturn clear_error_on_return;
1672-
if (!SSL_CTX_use_PrivateKey(ctx_.get(), key.GetAsymmetricKey().get())) {
1672+
auto complete = key.WithPublicKey(env);
1673+
if (!complete) return Nothing<void>();
1674+
if (!SSL_CTX_use_PrivateKey(ctx_.get(), complete.GetAsymmetricKey().get())) {
16731675
ThrowCryptoError(env, ERR_get_error(), "SSL_CTX_use_PrivateKey");
16741676
return Nothing<void>();
16751677
}

‎src/crypto/crypto_dh.cc‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,8 @@ Maybe<void> DHBitsTraits::AdditionalConfig(
488488
if (!private_key) [[unlikely]]
489489
return Nothing<void>();
490490

491-
params->public_key = std::move(public_key);
491+
params->public_key = public_key.WithPublicKey(Environment::GetCurrent(args));
492+
if (!params->public_key) return Nothing<void>();
492493
params->private_key = std::move(private_key);
493494

494495
return JustVoid();

‎src/crypto/crypto_ec.cc‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,10 @@ Maybe<void> EcKeyGenTraits::AdditionalConfig(
484484
bool ExportJWKEcKey(Environment* env,
485485
const KeyObjectData& key,
486486
Local<Object> target) {
487+
if (key.HasDeferredPublicKey()) {
488+
auto complete = key.WithPublicKey(env);
489+
return complete && ExportJWKEcKey(env, complete, target);
490+
}
487491
Mutex::ScopedLock lock(key.mutex());
488492
const auto& m_pkey = key.GetAsymmetricKey();
489493
CHECK_EQ(m_pkey.id(), EVP_PKEY_EC);

‎src/crypto/crypto_kem.cc‎

Lines changed: 6 additions & 4 deletions

0 commit comments

Comments
 (0)