From 69e51745ae599c079e6acd09d1a93d2a5550f1e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 29 Jun 2026 19:56:06 -0700 Subject: [PATCH] crypto: support derandomized ML-KEM encapsulation Add an `entropy` option to `crypto.encapsulate()` that injects the 32-byte message m as OSSL_KEM_PARAM_IKME, selecting FIPS 203 (6.2) Encaps_internal derandomized encapsulation. The same entropy, public key, and algorithm then deterministically produce the same ciphertext and shared key, which is required for known-answer testing and for protocols such as X-Wing. The existing randomized `encapsulate(key)` and `encapsulate(key, callback)` forms are unchanged; the new shape is `encapsulate(key, { entropy })`. The buffer is threaded through KEMEncapsulateJob into ncrypto::KEM::Encapsulate and set on the EVP_PKEY_CTX before EVP_PKEY_encapsulate. It is gated on OpenSSL >= 3.5 (OPENSSL_WITH_KEM_IKME) and is not supported for RSA, EC, X25519, or X448 keys. The binding rejects an entropy buffer that is not exactly 32 bytes. Fixes: https://github.com/nodejs/node/issues/64206 Signed-off-by: dotCooCoo --- deps/ncrypto/ncrypto.cc | 20 +++++++++- deps/ncrypto/ncrypto.h | 13 ++++++- doc/api/crypto.md | 19 +++++++++- lib/internal/crypto/kem.js | 22 ++++++++++- src/crypto/crypto_kem.cc | 36 ++++++++++++++++-- src/crypto/crypto_kem.h | 1 + test/parallel/test-crypto-encap-decap.js | 47 ++++++++++++++++++++++++ typings/internalBinding/crypto.d.ts | 10 ++++- 8 files changed, 158 insertions(+), 10 deletions(-) diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 81af3ded563777..54da70d45d7ef6 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -5058,7 +5058,7 @@ bool KEM::SetOperationParameter(EVP_PKEY_CTX* ctx, const EVPKeyPointer& key) { #endif std::optional KEM::Encapsulate( - const EVPKeyPointer& public_key) { + const EVPKeyPointer& public_key, const Buffer& entropy) { ClearErrorOnReturn clear_error_on_return; auto ctx = public_key.newCtx(); @@ -5074,6 +5074,24 @@ std::optional KEM::Encapsulate( } #endif +#if OPENSSL_WITH_KEM_IKME + // Derandomized (deterministic) encapsulation: inject the message m as + // OSSL_KEM_PARAM_IKME (FIPS 203, 6.2 Encaps_internal). An empty buffer + // leaves OpenSSL's internal CSPRNG in charge (randomized encapsulation). + if (entropy.data != nullptr && entropy.len != 0) { + OSSL_PARAM params[] = { + OSSL_PARAM_construct_octet_string( + OSSL_KEM_PARAM_IKME, + const_cast(entropy.data), + entropy.len), + OSSL_PARAM_END}; + + if (EVP_PKEY_CTX_set_params(ctx.get(), params) <= 0) { + return std::nullopt; + } + } +#endif + // Determine output buffer sizes size_t ciphertext_len = 0; size_t shared_key_len = 0; diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index a6befbf7cf6794..ceadb9c4576a75 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -89,6 +89,13 @@ #define OPENSSL_WITH_KEM_OPERATION_PARAM 0 #endif +#if OPENSSL_WITH_KEM && !defined(OPENSSL_IS_BORINGSSL) && \ + OPENSSL_VERSION_PREREQ(3, 5) +#define OPENSSL_WITH_KEM_IKME 1 +#else +#define OPENSSL_WITH_KEM_IKME 0 +#endif + // Post-quantum cryptography support. Keep these explicit so code can // distinguish provider API shape from the available algorithm set. #if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(3, 5) @@ -1779,8 +1786,12 @@ class KEM final { // Encapsulate a shared secret using KEM with a public key. // Returns both the ciphertext and shared secret. + // When `entropy` is non-empty it is injected as OSSL_KEM_PARAM_IKME for + // derandomized (FIPS 203, 6.2 Encaps_internal) encapsulation. Requires + // OpenSSL >= 3.5; ignored on builds without OPENSSL_WITH_KEM_IKME. static std::optional Encapsulate( - const EVPKeyPointer& public_key); + const EVPKeyPointer& public_key, + const Buffer& entropy = {}); // Decapsulate a shared secret using KEM with a private key and ciphertext. // Returns the shared secret. diff --git a/doc/api/crypto.md b/doc/api/crypto.md index b73093c648a90d..609d286e6cd927 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -4214,13 +4214,22 @@ If `options.publicKey` is not a [`KeyObject`][], this function behaves as if If the `callback` function is provided this function uses libuv's threadpool. -### `crypto.encapsulate(key[, callback])` +### `crypto.encapsulate(key[, options][, callback])` * `key` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} Public Key +* `options` {Object} + * `entropy` {ArrayBuffer|Buffer|TypedArray|DataView} For ML-KEM keys only, a + 32-byte value used to derandomize encapsulation (FIPS 203, section 6.2). + When omitted, a random value is generated internally. * `callback` {Function} * `err` {Error} * `result` {Object} @@ -4247,6 +4256,14 @@ Supported key types and their KEM algorithms are: If `key` is not a [`KeyObject`][], this function behaves as if `key` had been passed to [`crypto.createPublicKey()`][]. +When `options.entropy` is provided for an ML-KEM key, encapsulation is +deterministic: the same `entropy`, public key, and algorithm always produce the +same `ciphertext` and `sharedKey`. The `entropy` must be a cryptographically +secure 32-byte value; reusing it across encapsulations forfeits the secrecy of +the shared key. It is intended for known-answer testing and protocols such as +X-Wing that require derandomized encapsulation. `entropy` is not supported for +RSA, EC, X25519, or X448 keys. + If the `callback` function is provided this function uses libuv's threadpool. ### `crypto.fips` diff --git a/lib/internal/crypto/kem.js b/lib/internal/crypto/kem.js index d38f8b1c59a050..d05377805c1803 100644 --- a/lib/internal/crypto/kem.js +++ b/lib/internal/crypto/kem.js @@ -12,8 +12,13 @@ const { const { validateFunction, + validateObject, } = require('internal/validators'); +const { + kEmptyObject, +} = require('internal/util'); + const { kCryptoJobAsync, kCryptoJobSync, @@ -30,13 +35,25 @@ const { getArrayBufferOrView, } = require('internal/crypto/util'); -function encapsulate(key, callback) { +function encapsulate(key, options = kEmptyObject, callback) { if (!KEMEncapsulateJob) throw new ERR_CRYPTO_KEM_NOT_SUPPORTED(); + if (typeof options === 'function') { + callback = options; + options = kEmptyObject; + } + if (callback !== undefined) validateFunction(callback, 'callback'); + validateObject(options, 'options'); + const { entropy } = options; + + let ikme; + if (entropy !== undefined) + ikme = getArrayBufferOrView(entropy, 'options.entropy'); + const { data: keyData, format: keyFormat, @@ -51,7 +68,8 @@ function encapsulate(key, callback) { keyFormat, keyType, keyPassphrase, - keyNamedCurve); + keyNamedCurve, + ikme); if (!callback) { const { 0: err, 1: result } = job.run(); diff --git a/src/crypto/crypto_kem.cc b/src/crypto/crypto_kem.cc index 09fbf0844f48f2..795af4b30beaa0 100644 --- a/src/crypto/crypto_kem.cc +++ b/src/crypto/crypto_kem.cc @@ -31,7 +31,8 @@ KEMConfiguration::KEMConfiguration(KEMConfiguration&& other) noexcept : job_mode(other.job_mode), mode(other.mode), key(std::move(other.key)), - ciphertext(std::move(other.ciphertext)) {} + ciphertext(std::move(other.ciphertext)), + entropy(std::move(other.entropy)) {} KEMConfiguration& KEMConfiguration::operator=( KEMConfiguration&& other) noexcept { @@ -44,6 +45,7 @@ void KEMConfiguration::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackField("key", key); if (IsCryptoJobAsync(job_mode)) { tracker->TrackFieldWithSize("ciphertext", ciphertext.size()); + tracker->TrackFieldWithSize("entropy", entropy.size()); } } @@ -51,10 +53,13 @@ namespace { bool DoKEMEncapsulate(Environment* env, const EVPKeyPointer& public_key, + const ByteSource& entropy, ByteSource* out, CryptoJobMode mode, CryptoErrorStore* errors) { - auto result = ncrypto::KEM::Encapsulate(public_key); + ncrypto::Buffer entropy_buf{ + entropy.data(), entropy.size()}; + auto result = ncrypto::KEM::Encapsulate(public_key, entropy_buf); if (!result) { errors->Insert(NodeCryptoError::ENCAPSULATION_FAILED); errors->SetNodeErrorCode("ERR_CRYPTO_OPERATION_FAILED"); @@ -119,6 +124,8 @@ Maybe KEMEncapsulateTraits::AdditionalConfig( const FunctionCallbackInfo& args, unsigned int offset, KEMConfiguration* params) { + Environment* env = Environment::GetCurrent(args); + params->job_mode = mode; params->mode = KEMMode::Encapsulate; @@ -130,6 +137,29 @@ Maybe KEMEncapsulateTraits::AdditionalConfig( } params->key = std::move(public_key_data); + // Optional `entropy` (ML-KEM derandomized encapsulation). It is only read + // when a buffer is actually supplied: the randomized regular path passes + // `undefined` for the 7th argument and the WebCrypto path omits it + // entirely, so guard with IsUndefined() before constructing the contents + // (ArrayBufferOrViewContents CHECK()s IsAnyBufferSource, which aborts on a + // non-buffer value such as undefined). When supplied, the byte length is + // bounded both ways: CheckSizeInt32() rejects anything above INT_MAX before + // the FIPS 203 (6.2) exact 32-byte `m` length is enforced, so OpenSSL never + // reads past, or short of, the caller's buffer. + if (!args[key_offset]->IsUndefined()) { + ArrayBufferOrViewContents entropy(args[key_offset]); + if (!entropy.CheckSizeInt32()) { + THROW_ERR_OUT_OF_RANGE(env, "entropy is too big"); + return Nothing(); + } + if (entropy.size() != 32) { + THROW_ERR_OUT_OF_RANGE(env, "entropy must be 32 bytes"); + return Nothing(); + } + params->entropy = + IsCryptoJobAsync(mode) ? entropy.ToCopy() : entropy.ToByteSource(); + } + return v8::JustVoid(); } @@ -141,7 +171,7 @@ bool KEMEncapsulateTraits::DeriveBits(Environment* env, Mutex::ScopedLock lock(params.key.mutex()); const auto& public_key = params.key.GetAsymmetricKey(); - return DoKEMEncapsulate(env, public_key, out, mode, errors); + return DoKEMEncapsulate(env, public_key, params.entropy, out, mode, errors); } MaybeLocal KEMEncapsulateTraits::EncodeOutput( diff --git a/src/crypto/crypto_kem.h b/src/crypto/crypto_kem.h index e00aa04baa897e..b12756df437d4a 100644 --- a/src/crypto/crypto_kem.h +++ b/src/crypto/crypto_kem.h @@ -22,6 +22,7 @@ struct KEMConfiguration final : public MemoryRetainer { KEMMode mode; KeyObjectData key; ByteSource ciphertext; + ByteSource entropy; KEMConfiguration() = default; explicit KEMConfiguration(KEMConfiguration&& other) noexcept; diff --git a/test/parallel/test-crypto-encap-decap.js b/test/parallel/test-crypto-encap-decap.js index f2259194a9e15d..6edd5e63c7b6e9 100644 --- a/test/parallel/test-crypto-encap-decap.js +++ b/test/parallel/test-crypto-encap-decap.js @@ -123,6 +123,53 @@ for (const [name, { }); } + // Derandomized (FIPS 203, 6.2) encapsulation: same `entropy` => same output, + // and the result round-trips back to the same shared secret. + if (name.startsWith('ml-')) { + const entropy = Buffer.alloc(32, 0x42); + const r1 = crypto.encapsulate(publicKey, { entropy }); + const r2 = crypto.encapsulate(publicKey, { entropy }); + assert(r1.ciphertext.equals(r2.ciphertext)); + assert(r1.sharedKey.equals(r2.sharedKey)); + assert.strictEqual(r1.ciphertext.byteLength, ciphertextLength); + assert.strictEqual(r1.sharedKey.byteLength, sharedSecretLength); + const sk = crypto.decapsulate(privateKey, r1.ciphertext); + assert(sk.equals(r1.sharedKey)); + + // A different `entropy` yields a different ciphertext. + const r3 = crypto.encapsulate(publicKey, { entropy: Buffer.alloc(32, 0x24) }); + assert(!r3.ciphertext.equals(r1.ciphertext)); + + // entropy must be exactly 32 bytes (FIPS 203, 6.2 message length) — both + // bounds, and an explicit empty buffer is an invalid length, not the + // randomized path. + assert.throws(() => crypto.encapsulate(publicKey, { entropy: Buffer.alloc(31) }), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => crypto.encapsulate(publicKey, { entropy: Buffer.alloc(33) }), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => crypto.encapsulate(publicKey, { entropy: Buffer.alloc(0) }), + { code: 'ERR_OUT_OF_RANGE' }); + + // An absent or undefined entropy selects the randomized path and must not + // throw or abort (the 7th binding argument is undefined here). + assert.strictEqual( + crypto.encapsulate(publicKey, { entropy: undefined }).ciphertext.byteLength, + ciphertextLength); + assert.strictEqual( + crypto.encapsulate(publicKey).ciphertext.byteLength, ciphertextLength); + + // Non-byte-source entropy is rejected by getArrayBufferOrView. + assert.throws(() => crypto.encapsulate(publicKey, { entropy: null }), + { + code: 'ERR_INVALID_ARG_TYPE', + message: /instance of ArrayBuffer, Buffer, TypedArray, or DataView\. Received null/ + }); + + // options must be an object. + assert.throws(() => crypto.encapsulate(publicKey, 'nope'), + { code: 'ERR_INVALID_ARG_TYPE' }); + } + function formatKeyAs(key, params) { return { ...params, key: key.export(params) }; } diff --git a/typings/internalBinding/crypto.d.ts b/typings/internalBinding/crypto.d.ts index d91c5018ba688a..b98e62b33ac513 100644 --- a/typings/internalBinding/crypto.d.ts +++ b/typings/internalBinding/crypto.d.ts @@ -321,11 +321,17 @@ declare namespace InternalCryptoBinding { interface KEMEncapsulateJobConstructor { new( mode: M, - ...key: PreparedAsymmetricKeyArgs + ...args: [ + ...key: PreparedAsymmetricKeyArgs, + entropy: OptionalByteSource, + ] ): CryptoJobForMode; new( mode: CryptoJobWebCryptoMode, - ...key: PreparedAsymmetricKeyArgs + ...args: [ + ...key: PreparedAsymmetricKeyArgs, + entropy: OptionalByteSource, + ] ): CryptoJobWebCrypto; }