Skip to content

Commit 00a3ccd

Browse files
committed
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: #64206 Signed-off-by: dotCooCoo <robertleelw@gmail.com>
1 parent 4ad6ee5 commit 00a3ccd

8 files changed

Lines changed: 158 additions & 10 deletions

File tree

deps/ncrypto/ncrypto.cc

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5058,7 +5058,7 @@ bool KEM::SetOperationParameter(EVP_PKEY_CTX* ctx, const EVPKeyPointer& key) {
50585058
#endif
50595059

50605060
std::optional<KEM::EncapsulateResult> KEM::Encapsulate(
5061-
const EVPKeyPointer& public_key) {
5061+
const EVPKeyPointer& public_key, const Buffer<const unsigned char>& entropy) {
50625062
ClearErrorOnReturn clear_error_on_return;
50635063

50645064
auto ctx = public_key.newCtx();
@@ -5074,6 +5074,24 @@ std::optional<KEM::EncapsulateResult> KEM::Encapsulate(
50745074
}
50755075
#endif
50765076

5077+
#if OPENSSL_WITH_KEM_IKME
5078+
// Derandomized (deterministic) encapsulation: inject the message m as
5079+
// OSSL_KEM_PARAM_IKME (FIPS 203, 6.2 Encaps_internal). An empty buffer
5080+
// leaves OpenSSL's internal CSPRNG in charge (randomized encapsulation).
5081+
if (entropy.data != nullptr && entropy.len != 0) {
5082+
OSSL_PARAM params[] = {
5083+
OSSL_PARAM_construct_octet_string(
5084+
OSSL_KEM_PARAM_IKME,
5085+
const_cast<unsigned char*>(entropy.data),
5086+
entropy.len),
5087+
OSSL_PARAM_END};
5088+
5089+
if (EVP_PKEY_CTX_set_params(ctx.get(), params) <= 0) {
5090+
return std::nullopt;
5091+
}
5092+
}
5093+
#endif
5094+
50775095
// Determine output buffer sizes
50785096
size_t ciphertext_len = 0;
50795097
size_t shared_key_len = 0;

deps/ncrypto/ncrypto.h

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@
8989
#define OPENSSL_WITH_KEM_OPERATION_PARAM 0
9090
#endif
9191

92+
#if OPENSSL_WITH_KEM && !defined(OPENSSL_IS_BORINGSSL) && \
93+
OPENSSL_VERSION_PREREQ(3, 5)
94+
#define OPENSSL_WITH_KEM_IKME 1
95+
#else
96+
#define OPENSSL_WITH_KEM_IKME 0
97+
#endif
98+
9299
// Post-quantum cryptography support. Keep these explicit so code can
93100
// distinguish provider API shape from the available algorithm set.
94101
#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(3, 5)
@@ -1779,8 +1786,12 @@ class KEM final {
17791786

17801787
// Encapsulate a shared secret using KEM with a public key.
17811788
// Returns both the ciphertext and shared secret.
1789+
// When `entropy` is non-empty it is injected as OSSL_KEM_PARAM_IKME for
1790+
// derandomized (FIPS 203, 6.2 Encaps_internal) encapsulation. Requires
1791+
// OpenSSL >= 3.5; ignored on builds without OPENSSL_WITH_KEM_IKME.
17821792
static std::optional<EncapsulateResult> Encapsulate(
1783-
const EVPKeyPointer& public_key);
1793+
const EVPKeyPointer& public_key,
1794+
const Buffer<const unsigned char>& entropy = {});
17841795

17851796
// Decapsulate a shared secret using KEM with a private key and ciphertext.
17861797
// Returns the shared secret.

doc/api/crypto.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4214,13 +4214,22 @@ If `options.publicKey` is not a [`KeyObject`][], this function behaves as if
42144214

42154215
If the `callback` function is provided this function uses libuv's threadpool.
42164216

4217-
### `crypto.encapsulate(key[, callback])`
4217+
### `crypto.encapsulate(key[, options][, callback])`
42184218

42194219
<!-- YAML
42204220
added: v24.7.0
4221+
changes:
4222+
- version: REPLACEME
4223+
pr-url: https://github.com/nodejs/node/pull/REPLACEME
4224+
description: Added the `options.entropy` argument for derandomized
4225+
ML-KEM encapsulation.
42214226
-->
42224227

42234228
* `key` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} Public Key
4229+
* `options` {Object}
4230+
* `entropy` {ArrayBuffer|Buffer|TypedArray|DataView} For ML-KEM keys only, a
4231+
32-byte value used to derandomize encapsulation (FIPS 203, section 6.2).
4232+
When omitted, a random value is generated internally.
42244233
* `callback` {Function}
42254234
* `err` {Error}
42264235
* `result` {Object}
@@ -4247,6 +4256,14 @@ Supported key types and their KEM algorithms are:
42474256
If `key` is not a [`KeyObject`][], this function behaves as if `key` had been
42484257
passed to [`crypto.createPublicKey()`][].
42494258

4259+
When `options.entropy` is provided for an ML-KEM key, encapsulation is
4260+
deterministic: the same `entropy`, public key, and algorithm always produce the
4261+
same `ciphertext` and `sharedKey`. The `entropy` must be a cryptographically
4262+
secure 32-byte value; reusing it across encapsulations forfeits the secrecy of
4263+
the shared key. It is intended for known-answer testing and protocols such as
4264+
X-Wing that require derandomized encapsulation. `entropy` is not supported for
4265+
RSA, EC, X25519, or X448 keys.
4266+
42504267
If the `callback` function is provided this function uses libuv's threadpool.
42514268

42524269
### `crypto.fips`

lib/internal/crypto/kem.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ const {
1212

1313
const {
1414
validateFunction,
15+
validateObject,
1516
} = require('internal/validators');
1617

18+
const {
19+
kEmptyObject,
20+
} = require('internal/util');
21+
1722
const {
1823
kCryptoJobAsync,
1924
kCryptoJobSync,
@@ -30,13 +35,25 @@ const {
3035
getArrayBufferOrView,
3136
} = require('internal/crypto/util');
3237

33-
function encapsulate(key, callback) {
38+
function encapsulate(key, options = kEmptyObject, callback) {
3439
if (!KEMEncapsulateJob)
3540
throw new ERR_CRYPTO_KEM_NOT_SUPPORTED();
3641

42+
if (typeof options === 'function') {
43+
callback = options;
44+
options = kEmptyObject;
45+
}
46+
3747
if (callback !== undefined)
3848
validateFunction(callback, 'callback');
3949

50+
validateObject(options, 'options');
51+
const { entropy } = options;
52+
53+
let ikme;
54+
if (entropy !== undefined)
55+
ikme = getArrayBufferOrView(entropy, 'options.entropy');
56+
4057
const {
4158
data: keyData,
4259
format: keyFormat,
@@ -51,7 +68,8 @@ function encapsulate(key, callback) {
5168
keyFormat,
5269
keyType,
5370
keyPassphrase,
54-
keyNamedCurve);
71+
keyNamedCurve,
72+
ikme);
5573

5674
if (!callback) {
5775
const { 0: err, 1: result } = job.run();

src/crypto/crypto_kem.cc

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ KEMConfiguration::KEMConfiguration(KEMConfiguration&& other) noexcept
3131
: job_mode(other.job_mode),
3232
mode(other.mode),
3333
key(std::move(other.key)),
34-
ciphertext(std::move(other.ciphertext)) {}
34+
ciphertext(std::move(other.ciphertext)),
35+
entropy(std::move(other.entropy)) {}
3536

3637
KEMConfiguration& KEMConfiguration::operator=(
3738
KEMConfiguration&& other) noexcept {
@@ -44,17 +45,21 @@ void KEMConfiguration::MemoryInfo(MemoryTracker* tracker) const {
4445
tracker->TrackField("key", key);
4546
if (IsCryptoJobAsync(job_mode)) {
4647
tracker->TrackFieldWithSize("ciphertext", ciphertext.size());
48+
tracker->TrackFieldWithSize("entropy", entropy.size());
4749
}
4850
}
4951

5052
namespace {
5153

5254
bool DoKEMEncapsulate(Environment* env,
5355
const EVPKeyPointer& public_key,
56+
const ByteSource& entropy,
5457
ByteSource* out,
5558
CryptoJobMode mode,
5659
CryptoErrorStore* errors) {
57-
auto result = ncrypto::KEM::Encapsulate(public_key);
60+
ncrypto::Buffer<const unsigned char> entropy_buf{
61+
entropy.data<unsigned char>(), entropy.size()};
62+
auto result = ncrypto::KEM::Encapsulate(public_key, entropy_buf);
5863
if (!result) {
5964
errors->Insert(NodeCryptoError::ENCAPSULATION_FAILED);
6065
errors->SetNodeErrorCode("ERR_CRYPTO_OPERATION_FAILED");
@@ -119,6 +124,8 @@ Maybe<void> KEMEncapsulateTraits::AdditionalConfig(
119124
const FunctionCallbackInfo<Value>& args,
120125
unsigned int offset,
121126
KEMConfiguration* params) {
127+
Environment* env = Environment::GetCurrent(args);
128+
122129
params->job_mode = mode;
123130
params->mode = KEMMode::Encapsulate;
124131

@@ -130,6 +137,29 @@ Maybe<void> KEMEncapsulateTraits::AdditionalConfig(
130137
}
131138
params->key = std::move(public_key_data);
132139

140+
// Optional `entropy` (ML-KEM derandomized encapsulation). It is only read
141+
// when a buffer is actually supplied: the randomized regular path passes
142+
// `undefined` for the 7th argument and the WebCrypto path omits it
143+
// entirely, so guard with IsUndefined() before constructing the contents
144+
// (ArrayBufferOrViewContents CHECK()s IsAnyBufferSource, which aborts on a
145+
// non-buffer value such as undefined). When supplied, the byte length is
146+
// bounded both ways: CheckSizeInt32() rejects anything above INT_MAX before
147+
// the FIPS 203 (6.2) exact 32-byte `m` length is enforced, so OpenSSL never
148+
// reads past, or short of, the caller's buffer.
149+
if (!args[key_offset]->IsUndefined()) {
150+
ArrayBufferOrViewContents<unsigned char> entropy(args[key_offset]);
151+
if (!entropy.CheckSizeInt32()) {
152+
THROW_ERR_OUT_OF_RANGE(env, "entropy is too big");
153+
return Nothing<void>();
154+
}
155+
if (entropy.size() != 32) {
156+
THROW_ERR_OUT_OF_RANGE(env, "entropy must be 32 bytes");
157+
return Nothing<void>();
158+
}
159+
params->entropy =
160+
IsCryptoJobAsync(mode) ? entropy.ToCopy() : entropy.ToByteSource();
161+
}
162+
133163
return v8::JustVoid();
134164
}
135165

@@ -141,7 +171,7 @@ bool KEMEncapsulateTraits::DeriveBits(Environment* env,
141171
Mutex::ScopedLock lock(params.key.mutex());
142172
const auto& public_key = params.key.GetAsymmetricKey();
143173

144-
return DoKEMEncapsulate(env, public_key, out, mode, errors);
174+
return DoKEMEncapsulate(env, public_key, params.entropy, out, mode, errors);
145175
}
146176

147177
MaybeLocal<Value> KEMEncapsulateTraits::EncodeOutput(

src/crypto/crypto_kem.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ struct KEMConfiguration final : public MemoryRetainer {
2222
KEMMode mode;
2323
KeyObjectData key;
2424
ByteSource ciphertext;
25+
ByteSource entropy;
2526

2627
KEMConfiguration() = default;
2728
explicit KEMConfiguration(KEMConfiguration&& other) noexcept;

test/parallel/test-crypto-encap-decap.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,53 @@ for (const [name, {
123123
});
124124
}
125125

126+
// Derandomized (FIPS 203, 6.2) encapsulation: same `entropy` => same output,
127+
// and the result round-trips back to the same shared secret.
128+
if (name.startsWith('ml-')) {
129+
const entropy = Buffer.alloc(32, 0x42);
130+
const r1 = crypto.encapsulate(publicKey, { entropy });
131+
const r2 = crypto.encapsulate(publicKey, { entropy });
132+
assert(r1.ciphertext.equals(r2.ciphertext));
133+
assert(r1.sharedKey.equals(r2.sharedKey));
134+
assert.strictEqual(r1.ciphertext.byteLength, ciphertextLength);
135+
assert.strictEqual(r1.sharedKey.byteLength, sharedSecretLength);
136+
const sk = crypto.decapsulate(privateKey, r1.ciphertext);
137+
assert(sk.equals(r1.sharedKey));
138+
139+
// A different `entropy` yields a different ciphertext.
140+
const r3 = crypto.encapsulate(publicKey, { entropy: Buffer.alloc(32, 0x24) });
141+
assert(!r3.ciphertext.equals(r1.ciphertext));
142+
143+
// entropy must be exactly 32 bytes (FIPS 203, 6.2 message length) — both
144+
// bounds, and an explicit empty buffer is an invalid length, not the
145+
// randomized path.
146+
assert.throws(() => crypto.encapsulate(publicKey, { entropy: Buffer.alloc(31) }),
147+
{ code: 'ERR_OUT_OF_RANGE' });
148+
assert.throws(() => crypto.encapsulate(publicKey, { entropy: Buffer.alloc(33) }),
149+
{ code: 'ERR_OUT_OF_RANGE' });
150+
assert.throws(() => crypto.encapsulate(publicKey, { entropy: Buffer.alloc(0) }),
151+
{ code: 'ERR_OUT_OF_RANGE' });
152+
153+
// An absent or undefined entropy selects the randomized path and must not
154+
// throw or abort (the 7th binding argument is undefined here).
155+
assert.strictEqual(
156+
crypto.encapsulate(publicKey, { entropy: undefined }).ciphertext.byteLength,
157+
ciphertextLength);
158+
assert.strictEqual(
159+
crypto.encapsulate(publicKey).ciphertext.byteLength, ciphertextLength);
160+
161+
// Non-byte-source entropy is rejected by getArrayBufferOrView.
162+
assert.throws(() => crypto.encapsulate(publicKey, { entropy: null }),
163+
{
164+
code: 'ERR_INVALID_ARG_TYPE',
165+
message: /instance of ArrayBuffer, Buffer, TypedArray, or DataView\. Received null/
166+
});
167+
168+
// options must be an object.
169+
assert.throws(() => crypto.encapsulate(publicKey, 'nope'),
170+
{ code: 'ERR_INVALID_ARG_TYPE' });
171+
}
172+
126173
function formatKeyAs(key, params) {
127174
return { ...params, key: key.export(params) };
128175
}

typings/internalBinding/crypto.d.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,11 +321,17 @@ declare namespace InternalCryptoBinding {
321321
interface KEMEncapsulateJobConstructor {
322322
new<M extends CryptoJobRegularMode>(
323323
mode: M,
324-
...key: PreparedAsymmetricKeyArgs
324+
...args: [
325+
...key: PreparedAsymmetricKeyArgs,
326+
entropy: OptionalByteSource,
327+
]
325328
): CryptoJobForMode<M, KemEncapsulateTuple>;
326329
new(
327330
mode: CryptoJobWebCryptoMode,
328-
...key: PreparedAsymmetricKeyArgs
331+
...args: [
332+
...key: PreparedAsymmetricKeyArgs,
333+
entropy: OptionalByteSource,
334+
]
329335
): CryptoJobWebCrypto<EncapsulateResult>;
330336
}
331337

0 commit comments

Comments
 (0)