From f8d68d30ae0bc7ba72e65518c511283b3a259afd Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 20 Aug 2025 16:30:58 +0200 Subject: [PATCH] crypto: support ML-KEM, DHKEM, and RSASVE key encapsulation mechanisms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-URL: https://github.com/nodejs/node/pull/59491 Reviewed-By: Yagiz Nizipli Reviewed-By: Tobias Nießen Reviewed-By: Rafael Gonzaga --- benchmark/crypto/kem.js | 140 ++++++++++++ deps/ncrypto/ncrypto.cc | 121 +++++++++++ deps/ncrypto/ncrypto.h | 34 +++ doc/api/crypto.md | 75 +++++++ doc/api/errors.md | 11 + lib/crypto.js | 6 + lib/internal/crypto/kem.js | 112 ++++++++++ lib/internal/errors.js | 1 + node.gyp | 1 + src/crypto/crypto_kem.cc | 262 +++++++++++++++++++++++ src/crypto/crypto_kem.h | 113 ++++++++++ src/node_crypto.cc | 8 + src/node_crypto.h | 3 + test/parallel/test-crypto-encap-decap.js | 211 ++++++++++++++++++ 14 files changed, 1098 insertions(+) create mode 100644 benchmark/crypto/kem.js create mode 100644 lib/internal/crypto/kem.js create mode 100644 src/crypto/crypto_kem.cc create mode 100644 src/crypto/crypto_kem.h create mode 100644 test/parallel/test-crypto-encap-decap.js diff --git a/benchmark/crypto/kem.js b/benchmark/crypto/kem.js new file mode 100644 index 0000000000..c36e79957a --- /dev/null +++ b/benchmark/crypto/kem.js @@ -0,0 +1,140 @@ +'use strict'; + +const common = require('../common.js'); +const { hasOpenSSL } = require('../../test/common/crypto.js'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const fixtures_keydir = path.resolve(__dirname, '../../test/fixtures/keys/'); + +function readKey(name) { + return fs.readFileSync(`${fixtures_keydir}/${name}.pem`, 'utf8'); +} + +const keyFixtures = {}; + +if (hasOpenSSL(3, 5)) { + keyFixtures['ml-kem-512'] = readKey('ml_kem_512_private'); + keyFixtures['ml-kem-768'] = readKey('ml_kem_768_private'); + keyFixtures['ml-kem-1024'] = readKey('ml_kem_1024_private'); +} +if (hasOpenSSL(3, 2)) { + keyFixtures['p-256'] = readKey('ec_p256_private'); + keyFixtures['p-384'] = readKey('ec_p384_private'); + keyFixtures['p-521'] = readKey('ec_p521_private'); + keyFixtures.x25519 = readKey('x25519_private'); + keyFixtures.x448 = readKey('x448_private'); +} +if (hasOpenSSL(3, 0)) { + keyFixtures.rsa = readKey('rsa_private_2048'); +} + +if (Object.keys(keyFixtures).length === 0) { + console.log('no supported key types available for this OpenSSL version'); + process.exit(0); +} + +const bench = common.createBenchmark(main, { + keyType: Object.keys(keyFixtures), + mode: ['sync', 'async', 'async-parallel'], + keyFormat: ['keyObject', 'keyObject.unique'], + op: ['encapsulate', 'decapsulate'], + n: [1e3], +}, { + combinationFilter(p) { + // "keyObject.unique" allows to compare the result with "keyObject" to + // assess whether mutexes over the key material impact the operation + return p.keyFormat !== 'keyObject.unique' || + (p.keyFormat === 'keyObject.unique' && p.mode === 'async-parallel'); + }, +}); + +function measureSync(n, op, privateKey, keys, ciphertexts) { + bench.start(); + for (let i = 0; i < n; ++i) { + const key = privateKey || keys[i]; + if (op === 'encapsulate') { + crypto.encapsulate(key); + } else { + crypto.decapsulate(key, ciphertexts[i]); + } + } + bench.end(n); +} + +function measureAsync(n, op, privateKey, keys, ciphertexts) { + let remaining = n; + function done() { + if (--remaining === 0) + bench.end(n); + else + one(); + } + + function one() { + const key = privateKey || keys[n - remaining]; + if (op === 'encapsulate') { + crypto.encapsulate(key, done); + } else { + crypto.decapsulate(key, ciphertexts[n - remaining], done); + } + } + bench.start(); + one(); +} + +function measureAsyncParallel(n, op, privateKey, keys, ciphertexts) { + let remaining = n; + function done() { + if (--remaining === 0) + bench.end(n); + } + bench.start(); + for (let i = 0; i < n; ++i) { + const key = privateKey || keys[i]; + if (op === 'encapsulate') { + crypto.encapsulate(key, done); + } else { + crypto.decapsulate(key, ciphertexts[i], done); + } + } +} + +function main({ n, mode, keyFormat, keyType, op }) { + const pems = [...Buffer.alloc(n)].map(() => keyFixtures[keyType]); + const keyObjects = pems.map(crypto.createPrivateKey); + + let privateKey, keys, ciphertexts; + + switch (keyFormat) { + case 'keyObject': + privateKey = keyObjects[0]; + break; + case 'keyObject.unique': + keys = keyObjects; + break; + default: + throw new Error('not implemented'); + } + + // Pre-generate ciphertexts for decapsulate operations + if (op === 'decapsulate') { + if (privateKey) { + ciphertexts = [...Buffer.alloc(n)].map(() => crypto.encapsulate(privateKey).ciphertext); + } else { + ciphertexts = keys.map((key) => crypto.encapsulate(key).ciphertext); + } + } + + switch (mode) { + case 'sync': + measureSync(n, op, privateKey, keys, ciphertexts); + break; + case 'async': + measureAsync(n, op, privateKey, keys, ciphertexts); + break; + case 'async-parallel': + measureAsyncParallel(n, op, privateKey, keys, ciphertexts); + break; + } +} diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index d451a20c28..4b7a5bf5f2 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -4510,4 +4510,125 @@ const Digest Digest::FromName(const char* name) { return ncrypto::getDigestByName(name); } +// ============================================================================ +// KEM Implementation +#if OPENSSL_VERSION_MAJOR >= 3 +#if !OPENSSL_VERSION_PREREQ(3, 5) +bool KEM::SetOperationParameter(EVP_PKEY_CTX* ctx, const EVPKeyPointer& key) { + const char* operation = nullptr; + + switch (EVP_PKEY_id(key.get())) { + case EVP_PKEY_RSA: + operation = OSSL_KEM_PARAM_OPERATION_RSASVE; + break; +#if OPENSSL_VERSION_PREREQ(3, 2) + case EVP_PKEY_EC: + case EVP_PKEY_X25519: + case EVP_PKEY_X448: + operation = OSSL_KEM_PARAM_OPERATION_DHKEM; + break; +#endif + default: + unreachable(); + } + + if (operation != nullptr) { + OSSL_PARAM params[] = { + OSSL_PARAM_utf8_string( + OSSL_KEM_PARAM_OPERATION, const_cast(operation), 0), + OSSL_PARAM_END}; + + if (EVP_PKEY_CTX_set_params(ctx, params) <= 0) { + return false; + } + } + + return true; +} +#endif + +std::optional KEM::Encapsulate( + const EVPKeyPointer& public_key) { + ClearErrorOnReturn clear_error_on_return; + + auto ctx = public_key.newCtx(); + if (!ctx) return std::nullopt; + + if (EVP_PKEY_encapsulate_init(ctx.get(), nullptr) <= 0) { + return std::nullopt; + } + +#if !OPENSSL_VERSION_PREREQ(3, 5) + if (!SetOperationParameter(ctx.get(), public_key)) { + return std::nullopt; + } +#endif + + // Determine output buffer sizes + size_t ciphertext_len = 0; + size_t shared_key_len = 0; + + if (EVP_PKEY_encapsulate( + ctx.get(), nullptr, &ciphertext_len, nullptr, &shared_key_len) <= 0) { + return std::nullopt; + } + + auto ciphertext = DataPointer::Alloc(ciphertext_len); + auto shared_key = DataPointer::Alloc(shared_key_len); + if (!ciphertext || !shared_key) return std::nullopt; + + if (EVP_PKEY_encapsulate(ctx.get(), + static_cast(ciphertext.get()), + &ciphertext_len, + static_cast(shared_key.get()), + &shared_key_len) <= 0) { + return std::nullopt; + } + + return EncapsulateResult(std::move(ciphertext), std::move(shared_key)); +} + +DataPointer KEM::Decapsulate(const EVPKeyPointer& private_key, + const Buffer& ciphertext) { + ClearErrorOnReturn clear_error_on_return; + + auto ctx = private_key.newCtx(); + if (!ctx) return {}; + + if (EVP_PKEY_decapsulate_init(ctx.get(), nullptr) <= 0) { + return {}; + } + +#if !OPENSSL_VERSION_PREREQ(3, 5) + if (!SetOperationParameter(ctx.get(), private_key)) { + return {}; + } +#endif + + // First pass: determine shared secret size + size_t shared_key_len = 0; + if (EVP_PKEY_decapsulate(ctx.get(), + nullptr, + &shared_key_len, + static_cast(ciphertext.data), + ciphertext.len) <= 0) { + return {}; + } + + auto shared_key = DataPointer::Alloc(shared_key_len); + if (!shared_key) return {}; + + if (EVP_PKEY_decapsulate(ctx.get(), + static_cast(shared_key.get()), + &shared_key_len, + static_cast(ciphertext.data), + ciphertext.len) <= 0) { + return {}; + } + + return shared_key; +} + +#endif // OPENSSL_VERSION_MAJOR >= 3 + } // namespace ncrypto diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index b2af688de3..303de3cc9d 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -1574,6 +1574,40 @@ DataPointer argon2(const Buffer& pass, #endif #endif +// ============================================================================ +// KEM (Key Encapsulation Mechanism) +#if OPENSSL_VERSION_MAJOR >= 3 + +class KEM final { + public: + struct EncapsulateResult { + DataPointer ciphertext; + DataPointer shared_key; + + EncapsulateResult() = default; + EncapsulateResult(DataPointer ct, DataPointer sk) + : ciphertext(std::move(ct)), shared_key(std::move(sk)) {} + }; + + // Encapsulate a shared secret using KEM with a public key. + // Returns both the ciphertext and shared secret. + static std::optional Encapsulate( + const EVPKeyPointer& public_key); + + // Decapsulate a shared secret using KEM with a private key and ciphertext. + // Returns the shared secret. + static DataPointer Decapsulate(const EVPKeyPointer& private_key, + const Buffer& ciphertext); + + private: +#if !OPENSSL_VERSION_PREREQ(3, 5) + static bool SetOperationParameter(EVP_PKEY_CTX* ctx, + const EVPKeyPointer& key); +#endif +}; + +#endif // OPENSSL_VERSION_MAJOR >= 3 + // ============================================================================ // Version metadata #define NCRYPTO_VERSION "0.0.1" diff --git a/doc/api/crypto.md b/doc/api/crypto.md index 85c8af86e1..21579cd719 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -3741,6 +3741,40 @@ the corresponding digest algorithm. This does not work for all signature algorithms, such as `'ecdsa-with-SHA256'`, so it is best to always use digest algorithm names. +### `crypto.decapsulate(key, ciphertext[, callback])` + + + +> Stability: 1.2 - Release candidate + +* `key` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} Private Key +* `ciphertext` {ArrayBuffer|Buffer|TypedArray|DataView} +* `callback` {Function} + * `err` {Error} + * `sharedKey` {Buffer} +* Returns: {Buffer} if the `callback` function is not provided. + + + +Key decapsulation using a KEM algorithm with a private key. + +Supported key types and their KEM algorithms are: + +* `'rsa'`[^openssl30] RSA Secret Value Encapsulation +* `'ec'`[^openssl32] DHKEM(P-256, HKDF-SHA256), DHKEM(P-384, HKDF-SHA256), DHKEM(P-521, HKDF-SHA256) +* `'x25519'`[^openssl32] DHKEM(X25519, HKDF-SHA256) +* `'x448'`[^openssl32] DHKEM(X448, HKDF-SHA512) +* `'ml-kem-512'`[^openssl35] ML-KEM +* `'ml-kem-768'`[^openssl35] ML-KEM +* `'ml-kem-1024'`[^openssl35] ML-KEM + +If `key` is not a [`KeyObject`][], this function behaves as if `key` had been +passed to [`crypto.createPrivateKey()`][]. + +If the `callback` function is provided this function uses libuv's threadpool. + ### `crypto.diffieHellman(options[, callback])` + +> Stability: 1.2 - Release candidate + +* `key` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} Public Key +* `callback` {Function} + * `err` {Error} + * `result` {Object} + * `sharedKey` {Buffer} + * `ciphertext` {Buffer} +* Returns: {Object} if the `callback` function is not provided. + * `sharedKey` {Buffer} + * `ciphertext` {Buffer} + + + +Key encapsulation using a KEM algorithm with a public key. + +Supported key types and their KEM algorithms are: + +* `'rsa'`[^openssl30] RSA Secret Value Encapsulation +* `'ec'`[^openssl32] DHKEM(P-256, HKDF-SHA256), DHKEM(P-384, HKDF-SHA256), DHKEM(P-521, HKDF-SHA256) +* `'x25519'`[^openssl32] DHKEM(X25519, HKDF-SHA256) +* `'x448'`[^openssl32] DHKEM(X448, HKDF-SHA512) +* `'ml-kem-512'`[^openssl35] ML-KEM +* `'ml-kem-768'`[^openssl35] ML-KEM +* `'ml-kem-1024'`[^openssl35] ML-KEM + +If `key` is not a [`KeyObject`][], this function behaves as if `key` had been +passed to [`crypto.createPublicKey()`][]. + +If the `callback` function is provided this function uses libuv's threadpool. + ### `crypto.fips` + +Attempted to use KEM operations while Node.js was not compiled with +OpenSSL with KEM support. + ### `ERR_CRYPTO_OPERATION_FAILED` diff --git a/lib/crypto.js b/lib/crypto.js index cfd41832cb..4290989f3c 100644 --- a/lib/crypto.js +++ b/lib/crypto.js @@ -122,6 +122,10 @@ const { secureHeapUsed, } = require('internal/crypto/util'); const Certificate = require('internal/crypto/certificate'); +const { + encapsulate, + decapsulate, +} = require('internal/crypto/kem'); let webcrypto; function lazyWebCrypto() { @@ -225,6 +229,8 @@ module.exports = { setFips, verify: verifyOneShot, hash, + encapsulate, + decapsulate, // Classes Certificate, diff --git a/lib/internal/crypto/kem.js b/lib/internal/crypto/kem.js new file mode 100644 index 0000000000..43c7bde52e --- /dev/null +++ b/lib/internal/crypto/kem.js @@ -0,0 +1,112 @@ +'use strict'; + +const { + FunctionPrototypeCall, +} = primordials; + +const { + codes: { + ERR_CRYPTO_KEM_NOT_SUPPORTED, + }, +} = require('internal/errors'); + +const { + validateFunction, +} = require('internal/validators'); + +const { + kCryptoJobAsync, + kCryptoJobSync, + KEMDecapsulateJob, + KEMEncapsulateJob, +} = internalBinding('crypto'); + +const { + preparePrivateKey, + preparePublicOrPrivateKey, +} = require('internal/crypto/keys'); + +const { + getArrayBufferOrView, +} = require('internal/crypto/util'); + +function encapsulate(key, callback) { + if (!KEMEncapsulateJob) + throw new ERR_CRYPTO_KEM_NOT_SUPPORTED(); + + if (callback !== undefined) + validateFunction(callback, 'callback'); + + const { + data: keyData, + format: keyFormat, + type: keyType, + passphrase: keyPassphrase, + } = preparePublicOrPrivateKey(key); + + const job = new KEMEncapsulateJob( + callback ? kCryptoJobAsync : kCryptoJobSync, + keyData, + keyFormat, + keyType, + keyPassphrase); + + if (!callback) { + const { 0: err, 1: result } = job.run(); + if (err !== undefined) + throw err; + const { 0: sharedKey, 1: ciphertext } = result; + return { sharedKey, ciphertext }; + } + + job.ondone = (error, result) => { + if (error) return FunctionPrototypeCall(callback, job, error); + const { 0: sharedKey, 1: ciphertext } = result; + FunctionPrototypeCall(callback, job, null, { sharedKey, ciphertext }); + }; + job.run(); +} + +function decapsulate(key, ciphertext, callback) { + if (!KEMDecapsulateJob) + throw new ERR_CRYPTO_KEM_NOT_SUPPORTED(); + + if (callback !== undefined) + validateFunction(callback, 'callback'); + + const { + data: keyData, + format: keyFormat, + type: keyType, + passphrase: keyPassphrase, + } = preparePrivateKey(key); + + ciphertext = getArrayBufferOrView(ciphertext, 'ciphertext'); + + const job = new KEMDecapsulateJob( + callback ? kCryptoJobAsync : kCryptoJobSync, + keyData, + keyFormat, + keyType, + keyPassphrase, + ciphertext); + + if (!callback) { + const { 0: err, 1: result } = job.run(); + if (err !== undefined) + throw err; + + return result; + } + + job.ondone = (error, result) => { + if (error) return FunctionPrototypeCall(callback, job, error); + FunctionPrototypeCall(callback, job, null, result); + }; + job.run(); +} + +module.exports = { + encapsulate, + decapsulate, +}; diff --git a/lib/internal/errors.js b/lib/internal/errors.js index d57f47fa7b..a4a670797a 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1184,6 +1184,7 @@ E('ERR_CRYPTO_INVALID_JWK', 'Invalid JWK data', TypeError); E('ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE', 'Invalid key object type %s, expected %s.', TypeError); E('ERR_CRYPTO_INVALID_STATE', 'Invalid state for operation %s', Error); +E('ERR_CRYPTO_KEM_NOT_SUPPORTED', 'KEM is not supported', Error); E('ERR_CRYPTO_PBKDF2_ERROR', 'PBKDF2 error', Error); E('ERR_CRYPTO_SCRYPT_NOT_SUPPORTED', 'Scrypt algorithm not supported', Error); // Switch to TypeError. The current implementation does not seem right. diff --git a/node.gyp b/node.gyp index 0c83e68083..b0b6edffc9 100644 --- a/node.gyp +++ b/node.gyp @@ -345,6 +345,7 @@ 'src/crypto/crypto_context.cc', 'src/crypto/crypto_ec.cc', 'src/crypto/crypto_ml_dsa.cc', + 'src/crypto/crypto_kem.cc', 'src/crypto/crypto_hmac.cc', 'src/crypto/crypto_random.cc', 'src/crypto/crypto_rsa.cc', diff --git a/src/crypto/crypto_kem.cc b/src/crypto/crypto_kem.cc new file mode 100644 index 0000000000..d6227bb66c --- /dev/null +++ b/src/crypto/crypto_kem.cc @@ -0,0 +1,262 @@ +#include "crypto/crypto_kem.h" + +#if OPENSSL_VERSION_MAJOR >= 3 + +#include "async_wrap-inl.h" +#include "base_object-inl.h" +#include "crypto/crypto_keys.h" +#include "crypto/crypto_util.h" +#include "env-inl.h" +#include "memory_tracker-inl.h" +#include "node_buffer.h" +#include "threadpoolwork-inl.h" +#include "v8.h" + +namespace node { + +using ncrypto::EVPKeyPointer; +using v8::Array; +using v8::FunctionCallbackInfo; +using v8::Local; +using v8::Maybe; +using v8::MaybeLocal; +using v8::Nothing; +using v8::Object; +using v8::Value; + +namespace crypto { + +KEMConfiguration::KEMConfiguration(KEMConfiguration&& other) noexcept + : job_mode(other.job_mode), + mode(other.mode), + key(std::move(other.key)), + ciphertext(std::move(other.ciphertext)) {} + +KEMConfiguration& KEMConfiguration::operator=( + KEMConfiguration&& other) noexcept { + if (&other == this) return *this; + this->~KEMConfiguration(); + return *new (this) KEMConfiguration(std::move(other)); +} + +void KEMConfiguration::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackField("key", key); + if (job_mode == kCryptoJobAsync) { + tracker->TrackFieldWithSize("ciphertext", ciphertext.size()); + } +} + +namespace { + +bool DoKEMEncapsulate(Environment* env, + const EVPKeyPointer& public_key, + ByteSource* out, + CryptoJobMode mode) { + auto result = ncrypto::KEM::Encapsulate(public_key); + if (!result) { + if (mode == kCryptoJobSync) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to perform encapsulation"); + } + return false; + } + + // Pack the result: [ciphertext_len][shared_key_len][ciphertext][shared_key] + size_t ciphertext_len = result->ciphertext.size(); + size_t shared_key_len = result->shared_key.size(); + size_t total_len = + sizeof(uint32_t) + sizeof(uint32_t) + ciphertext_len + shared_key_len; + + auto data = ncrypto::DataPointer::Alloc(total_len); + if (!data) { + if (mode == kCryptoJobSync) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to allocate output buffer"); + } + return false; + } + + unsigned char* ptr = static_cast(data.get()); + + // Write size headers + *reinterpret_cast(ptr) = static_cast(ciphertext_len); + *reinterpret_cast(ptr + sizeof(uint32_t)) = + static_cast(shared_key_len); + + // Write ciphertext and shared key data + unsigned char* ciphertext_ptr = ptr + 2 * sizeof(uint32_t); + unsigned char* shared_key_ptr = ciphertext_ptr + ciphertext_len; + + std::memcpy(ciphertext_ptr, result->ciphertext.get(), ciphertext_len); + std::memcpy(shared_key_ptr, result->shared_key.get(), shared_key_len); + + *out = ByteSource::Allocated(data.release()); + return true; +} + +bool DoKEMDecapsulate(Environment* env, + const EVPKeyPointer& private_key, + const ByteSource& ciphertext, + ByteSource* out, + CryptoJobMode mode) { + ncrypto::Buffer ciphertext_buf{ciphertext.data(), + ciphertext.size()}; + auto shared_key = ncrypto::KEM::Decapsulate(private_key, ciphertext_buf); + if (!shared_key) { + if (mode == kCryptoJobSync) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to perform decapsulation"); + } + return false; + } + + *out = ByteSource::Allocated(shared_key.release()); + return true; +} + +} // anonymous namespace + +// KEMEncapsulateTraits implementation +Maybe KEMEncapsulateTraits::AdditionalConfig( + CryptoJobMode mode, + const FunctionCallbackInfo& args, + unsigned int offset, + KEMConfiguration* params) { + params->job_mode = mode; + params->mode = KEMMode::Encapsulate; + + unsigned int key_offset = offset; + auto public_key_data = + KeyObjectData::GetPublicOrPrivateKeyFromJs(args, &key_offset); + if (!public_key_data) { + return Nothing(); + } + params->key = std::move(public_key_data); + + return v8::JustVoid(); +} + +bool KEMEncapsulateTraits::DeriveBits(Environment* env, + const KEMConfiguration& params, + ByteSource* out, + CryptoJobMode mode) { + Mutex::ScopedLock lock(params.key.mutex()); + const auto& public_key = params.key.GetAsymmetricKey(); + + return DoKEMEncapsulate(env, public_key, out, mode); +} + +MaybeLocal KEMEncapsulateTraits::EncodeOutput( + Environment* env, const KEMConfiguration& params, ByteSource* out) { + // The output contains: + // [ciphertext_len][shared_key_len][ciphertext][shared_key] + const unsigned char* data = out->data(); + + uint32_t ciphertext_len = *reinterpret_cast(data); + uint32_t shared_key_len = + *reinterpret_cast(data + sizeof(uint32_t)); + + const unsigned char* ciphertext_ptr = data + 2 * sizeof(uint32_t); + const unsigned char* shared_key_ptr = ciphertext_ptr + ciphertext_len; + + MaybeLocal ciphertext_buf = + node::Buffer::Copy(env->isolate(), + reinterpret_cast(ciphertext_ptr), + ciphertext_len); + + MaybeLocal shared_key_buf = + node::Buffer::Copy(env->isolate(), + reinterpret_cast(shared_key_ptr), + shared_key_len); + + Local ciphertext_obj; + Local shared_key_obj; + if (!ciphertext_buf.ToLocal(&ciphertext_obj) || + !shared_key_buf.ToLocal(&shared_key_obj)) { + return MaybeLocal(); + } + + // Return an array [sharedKey, ciphertext]. + Local result = Array::New(env->isolate(), 2); + if (result->Set(env->context(), 0, shared_key_obj).IsNothing() || + result->Set(env->context(), 1, ciphertext_obj).IsNothing()) { + return MaybeLocal(); + } + + return result; +} + +// KEMDecapsulateTraits implementation +Maybe KEMDecapsulateTraits::AdditionalConfig( + CryptoJobMode mode, + const FunctionCallbackInfo& args, + unsigned int offset, + KEMConfiguration* params) { + Environment* env = Environment::GetCurrent(args); + + params->job_mode = mode; + params->mode = KEMMode::Decapsulate; + + unsigned int key_offset = offset; + auto private_key_data = + KeyObjectData::GetPrivateKeyFromJs(args, &key_offset, true); + if (!private_key_data) { + return Nothing(); + } + params->key = std::move(private_key_data); + + ArrayBufferOrViewContents ciphertext(args[key_offset]); + if (!ciphertext.CheckSizeInt32()) { + THROW_ERR_OUT_OF_RANGE(env, "ciphertext is too big"); + return Nothing(); + } + + params->ciphertext = + mode == kCryptoJobAsync ? ciphertext.ToCopy() : ciphertext.ToByteSource(); + + return v8::JustVoid(); +} + +bool KEMDecapsulateTraits::DeriveBits(Environment* env, + const KEMConfiguration& params, + ByteSource* out, + CryptoJobMode mode) { + Mutex::ScopedLock lock(params.key.mutex()); + const auto& private_key = params.key.GetAsymmetricKey(); + + return DoKEMDecapsulate(env, private_key, params.ciphertext, out, mode); +} + +MaybeLocal KEMDecapsulateTraits::EncodeOutput( + Environment* env, const KEMConfiguration& params, ByteSource* out) { + return out->ToBuffer(env); +} + +void InitializeKEM(Environment* env, Local target) { + KEMEncapsulateJob::Initialize(env, target); + KEMDecapsulateJob::Initialize(env, target); + + constexpr int kKEMEncapsulate = static_cast(KEMMode::Encapsulate); + constexpr int kKEMDecapsulate = static_cast(KEMMode::Decapsulate); + + NODE_DEFINE_CONSTANT(target, kKEMEncapsulate); + NODE_DEFINE_CONSTANT(target, kKEMDecapsulate); +} + +void RegisterKEMExternalReferences(ExternalReferenceRegistry* registry) { + KEMEncapsulateJob::RegisterExternalReferences(registry); + KEMDecapsulateJob::RegisterExternalReferences(registry); +} + +namespace KEM { +void Initialize(Environment* env, Local target) { + InitializeKEM(env, target); +} + +void RegisterExternalReferences(ExternalReferenceRegistry* registry) { + RegisterKEMExternalReferences(registry); +} +} // namespace KEM + +} // namespace crypto +} // namespace node + +#endif diff --git a/src/crypto/crypto_kem.h b/src/crypto/crypto_kem.h new file mode 100644 index 0000000000..02cef53c27 --- /dev/null +++ b/src/crypto/crypto_kem.h @@ -0,0 +1,113 @@ +#ifndef SRC_CRYPTO_CRYPTO_KEM_H_ +#define SRC_CRYPTO_CRYPTO_KEM_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include "base_object.h" +#include "crypto/crypto_keys.h" +#include "crypto/crypto_util.h" +#include "env.h" +#include "memory_tracker.h" +#include "node_external_reference.h" + +#if OPENSSL_VERSION_MAJOR >= 3 + +namespace node { +namespace crypto { + +enum class KEMMode { Encapsulate, Decapsulate }; + +struct KEMConfiguration final : public MemoryRetainer { + CryptoJobMode job_mode; + KEMMode mode; + KeyObjectData key; + ByteSource ciphertext; + + KEMConfiguration() = default; + explicit KEMConfiguration(KEMConfiguration&& other) noexcept; + KEMConfiguration& operator=(KEMConfiguration&& other) noexcept; + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_MEMORY_INFO_NAME(KEMConfiguration) + SET_SELF_SIZE(KEMConfiguration) +}; + +struct KEMEncapsulateTraits final { + using AdditionalParameters = KEMConfiguration; + static constexpr const char* JobName = "KEMEncapsulateJob"; + + static constexpr AsyncWrap::ProviderType Provider = + AsyncWrap::PROVIDER_DERIVEBITSREQUEST; + + static v8::Maybe AdditionalConfig( + CryptoJobMode mode, + const v8::FunctionCallbackInfo& args, + unsigned int offset, + KEMConfiguration* params); + + static bool DeriveBits(Environment* env, + const KEMConfiguration& params, + ByteSource* out, + CryptoJobMode mode); + + static v8::MaybeLocal EncodeOutput(Environment* env, + const KEMConfiguration& params, + ByteSource* out); +}; + +struct KEMDecapsulateTraits final { + using AdditionalParameters = KEMConfiguration; + static constexpr const char* JobName = "KEMDecapsulateJob"; + + static constexpr AsyncWrap::ProviderType Provider = + AsyncWrap::PROVIDER_DERIVEBITSREQUEST; + + static v8::Maybe AdditionalConfig( + CryptoJobMode mode, + const v8::FunctionCallbackInfo& args, + unsigned int offset, + KEMConfiguration* params); + + static bool DeriveBits(Environment* env, + const KEMConfiguration& params, + ByteSource* out, + CryptoJobMode mode); + + static v8::MaybeLocal EncodeOutput(Environment* env, + const KEMConfiguration& params, + ByteSource* out); +}; + +using KEMEncapsulateJob = DeriveBitsJob; +using KEMDecapsulateJob = DeriveBitsJob; + +void InitializeKEM(Environment* env, v8::Local target); +void RegisterKEMExternalReferences(ExternalReferenceRegistry* registry); + +namespace KEM { +void Initialize(Environment* env, v8::Local target); +void RegisterExternalReferences(ExternalReferenceRegistry* registry); +} // namespace KEM + +} // namespace crypto +} // namespace node + +#else + +// Provide stub implementations when OpenSSL < 3.0 +namespace node { +namespace crypto { +namespace KEM { +inline void Initialize(Environment* env, v8::Local target) { + // No-op when OpenSSL < 3.0 +} +inline void RegisterExternalReferences(ExternalReferenceRegistry* registry) { + // No-op when OpenSSL < 3.0 +} +} // namespace KEM +} // namespace crypto +} // namespace node + +#endif +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#endif // SRC_CRYPTO_CRYPTO_KEM_H_ diff --git a/src/node_crypto.cc b/src/node_crypto.cc index 6af94c9598..9bdb720147 100644 --- a/src/node_crypto.cc +++ b/src/node_crypto.cc @@ -66,6 +66,13 @@ namespace crypto { #define ARGON2_NAMESPACE_LIST(V) #endif // !OPENSSL_NO_ARGON2 && OpenSSL >= 3.2 +// KEM functionality requires OpenSSL 3.0.0 or later +#if OPENSSL_VERSION_MAJOR >= 3 +#define KEM_NAMESPACE_LIST(V) V(KEM) +#else +#define KEM_NAMESPACE_LIST(V) +#endif + #ifdef OPENSSL_NO_SCRYPT #define SCRYPT_NAMESPACE_LIST(V) #else @@ -75,6 +82,7 @@ namespace crypto { #define CRYPTO_NAMESPACE_LIST(V) \ CRYPTO_NAMESPACE_LIST_BASE(V) \ ARGON2_NAMESPACE_LIST(V) \ + KEM_NAMESPACE_LIST(V) \ SCRYPT_NAMESPACE_LIST(V) void Initialize(Local target, diff --git a/src/node_crypto.h b/src/node_crypto.h index 1c493db810..21380d304f 100644 --- a/src/node_crypto.h +++ b/src/node_crypto.h @@ -40,6 +40,9 @@ #include "crypto/crypto_hash.h" #include "crypto/crypto_hkdf.h" #include "crypto/crypto_hmac.h" +#if OPENSSL_VERSION_MAJOR >= 3 +#include "crypto/crypto_kem.h" +#endif #include "crypto/crypto_keygen.h" #include "crypto/crypto_keys.h" #include "crypto/crypto_ml_dsa.h" diff --git a/test/parallel/test-crypto-encap-decap.js b/test/parallel/test-crypto-encap-decap.js new file mode 100644 index 0000000000..2c2ccd42ca --- /dev/null +++ b/test/parallel/test-crypto-encap-decap.js @@ -0,0 +1,211 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const crypto = require('crypto'); +const fixtures = require('../common/fixtures'); +const { hasOpenSSL } = require('../common/crypto'); +const { promisify } = require('util'); + +if (!hasOpenSSL(3)) { + assert.throws(() => crypto.encapsulate(), { code: 'ERR_CRYPTO_KEM_NOT_SUPPORTED' }); + return; +} + +assert.throws(() => crypto.encapsulate(), { code: 'ERR_INVALID_ARG_TYPE', + message: /The "key" argument must be of type/ }); +assert.throws(() => crypto.decapsulate(), { code: 'ERR_INVALID_ARG_TYPE', + message: /The "key" argument must be of type/ }); + +const keys = { + 'rsa': { + supported: hasOpenSSL(3), // RSASVE was added in 3.0 + publicKey: fixtures.readKey('rsa_public_2048.pem', 'ascii'), + privateKey: fixtures.readKey('rsa_private_2048.pem', 'ascii'), + sharedSecretLength: 256, + ciphertextLength: 256, + }, + 'rsa-pss': { + supported: false, // Only raw RSA is supported + publicKey: fixtures.readKey('rsa_pss_public_2048.pem', 'ascii'), + privateKey: fixtures.readKey('rsa_pss_private_2048.pem', 'ascii'), + }, + 'p-256': { + supported: hasOpenSSL(3, 2), // DHKEM was added in 3.2 + publicKey: fixtures.readKey('ec_p256_public.pem', 'ascii'), + privateKey: fixtures.readKey('ec_p256_private.pem', 'ascii'), + sharedSecretLength: 32, + ciphertextLength: 65, + }, + 'p-384': { + supported: hasOpenSSL(3, 2), // DHKEM was added in 3.2 + publicKey: fixtures.readKey('ec_p384_public.pem', 'ascii'), + privateKey: fixtures.readKey('ec_p384_private.pem', 'ascii'), + sharedSecretLength: 48, + ciphertextLength: 97, + }, + 'p-521': { + supported: hasOpenSSL(3, 2), // DHKEM was added in 3.2 + publicKey: fixtures.readKey('ec_p521_public.pem', 'ascii'), + privateKey: fixtures.readKey('ec_p521_private.pem', 'ascii'), + sharedSecretLength: 64, + ciphertextLength: 133, + }, + 'secp256k1': { + supported: false, // only P-256, P-384, and P-521 are supported + publicKey: fixtures.readKey('ec_secp256k1_public.pem', 'ascii'), + privateKey: fixtures.readKey('ec_secp256k1_private.pem', 'ascii'), + }, + 'x25519': { + supported: hasOpenSSL(3, 2), // DHKEM was added in 3.2 + publicKey: fixtures.readKey('x25519_public.pem', 'ascii'), + privateKey: fixtures.readKey('x25519_private.pem', 'ascii'), + sharedSecretLength: 32, + ciphertextLength: 32, + }, + 'x448': { + supported: hasOpenSSL(3, 2), // DHKEM was added in 3.2 + publicKey: fixtures.readKey('x448_public.pem', 'ascii'), + privateKey: fixtures.readKey('x448_private.pem', 'ascii'), + sharedSecretLength: 64, + ciphertextLength: 56, + }, + 'ml-kem-512': { + supported: hasOpenSSL(3, 5), + publicKey: fixtures.readKey('ml_kem_512_public.pem', 'ascii'), + privateKey: fixtures.readKey('ml_kem_512_private.pem', 'ascii'), + sharedSecretLength: 32, + ciphertextLength: 768, + }, + 'ml-kem-768': { + supported: hasOpenSSL(3, 5), + publicKey: fixtures.readKey('ml_kem_768_public.pem', 'ascii'), + privateKey: fixtures.readKey('ml_kem_768_private.pem', 'ascii'), + sharedSecretLength: 32, + ciphertextLength: 1088, + }, + 'ml-kem-1024': { + supported: hasOpenSSL(3, 5), + publicKey: fixtures.readKey('ml_kem_1024_public.pem', 'ascii'), + privateKey: fixtures.readKey('ml_kem_1024_private.pem', 'ascii'), + sharedSecretLength: 32, + ciphertextLength: 1568, + }, +}; + +for (const [name, { supported, publicKey, privateKey, sharedSecretLength, ciphertextLength }] of Object.entries(keys)) { + if (!supported) { + assert.throws(() => crypto.encapsulate(publicKey), + { code: /ERR_OSSL_EVP_DECODE_ERROR|ERR_CRYPTO_OPERATION_FAILED/ }); + continue; + } + + { + assert.throws(() => crypto.decapsulate(privateKey, null), + { + code: 'ERR_INVALID_ARG_TYPE', + message: /instance of ArrayBuffer, Buffer, TypedArray, or DataView\. Received null/ + }); + } + + function formatKeyAs(key, params) { + return { ...params, key: key.export(params) }; + } + + const keyObjects = { + publicKey: crypto.createPublicKey(publicKey), + privateKey: crypto.createPrivateKey(privateKey), + }; + + const keyPairs = [ + keyObjects, + { publicKey, privateKey }, + { + publicKey: formatKeyAs(keyObjects.publicKey, { format: 'der', type: 'spki' }), + privateKey: formatKeyAs(keyObjects.privateKey, { format: 'der', type: 'pkcs8' }) + }, + ]; + + // TODO(@panva): ML-KEM does not have a JWK format defined yet, add once standardized + if (!keyObjects.privateKey.asymmetricKeyType.startsWith('ml')) { + keyPairs.push({ + publicKey: formatKeyAs(keyObjects.publicKey, { format: 'jwk' }), + privateKey: formatKeyAs(keyObjects.privateKey, { format: 'jwk' }) + }); + } + + for (const kp of keyPairs) { + // sync + { + const { sharedKey, ciphertext } = crypto.encapsulate(kp.publicKey); + assert(Buffer.isBuffer(sharedKey)); + assert.strictEqual(sharedKey.byteLength, sharedSecretLength); + assert(Buffer.isBuffer(ciphertext)); + assert.strictEqual(ciphertext.byteLength, ciphertextLength); + const sharedKey2 = crypto.decapsulate(kp.privateKey, ciphertext); + assert(Buffer.isBuffer(sharedKey2)); + assert.strictEqual(sharedKey2.byteLength, sharedSecretLength); + assert(sharedKey.equals(sharedKey2)); + } + + // async + { + crypto.encapsulate(kp.publicKey, common.mustSucceed(({ sharedKey, ciphertext }) => { + assert(Buffer.isBuffer(sharedKey)); + assert.strictEqual(sharedKey.byteLength, sharedSecretLength); + assert(Buffer.isBuffer(ciphertext)); + assert.strictEqual(ciphertext.byteLength, ciphertextLength); + crypto.decapsulate(kp.privateKey, ciphertext, common.mustSucceed((sharedKey2) => { + assert(Buffer.isBuffer(sharedKey2)); + assert.strictEqual(sharedKey2.byteLength, sharedSecretLength); + assert(sharedKey.equals(sharedKey2)); + })); + })); + } + + // promisified + (async () => { + const { sharedKey, ciphertext } = await promisify(crypto.encapsulate)(kp.publicKey); + assert(Buffer.isBuffer(sharedKey)); + assert.strictEqual(sharedKey.byteLength, sharedSecretLength); + assert(Buffer.isBuffer(ciphertext)); + assert.strictEqual(ciphertext.byteLength, ciphertextLength); + const sharedKey2 = await promisify(crypto.decapsulate)(kp.privateKey, ciphertext); + assert(Buffer.isBuffer(sharedKey2)); + assert.strictEqual(sharedKey2.byteLength, sharedSecretLength); + assert(sharedKey.equals(sharedKey2)); + })().then(common.mustCall()); + } + + let wrongPrivateKey; + if (name.startsWith('x')) { + wrongPrivateKey = name === 'x448' ? keys.x25519.privateKey : keys.x448.privateKey; + } else if (name.startsWith('p-')) { + wrongPrivateKey = name === 'p-256' ? keys['p-384'].privateKey : keys['p-256'].privateKey; + } else if (name.startsWith('ml-')) { + wrongPrivateKey = name === 'ml-kem-512' ? keys['ml-kem-768'].privateKey : keys['ml-kem-512'].privateKey; + } else { + wrongPrivateKey = keys.x25519.privateKey; + } + + // sync errors + { + const { ciphertext } = crypto.encapsulate(publicKey); + assert.throws(() => crypto.decapsulate(wrongPrivateKey, ciphertext), { + message: /Failed to (initialize|perform) decapsulation/, + code: 'ERR_CRYPTO_OPERATION_FAILED', + }); + } + + // async errors + { + crypto.encapsulate(publicKey, common.mustSucceed(({ ciphertext }) => { + crypto.decapsulate(wrongPrivateKey, ciphertext, common.mustCall((err) => { + assert(err); + assert.strictEqual(err.message, 'Deriving bits failed'); + })); + })); + } +}