Commit 36e326b1837 for nodejs
commit 36e326b18372a86aed2f5ad37d28b48c497c0bec
Author: Filip Skokan <panva.ip@gmail.com>
Date: Fri Sep 18 11:42:22 2026 +0200
crypto: fetch ciphers for private-key encoding
Resolve provider ciphers before serializing private keys and retain the
fetched implementation across encoding configuration copies and async
key generation. Keep format-specific restrictions in the serializers.
Signed-off-by: Filip Skokan <panva.ip@gmail.com>
Assisted-by: Codex
PR-URL: https://github.com/nodejs/node/pull/66108
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc
index eb5e28154cc..44b56c1b3ef 100644
--- a/deps/ncrypto/ncrypto.cc
+++ b/deps/ncrypto/ncrypto.cc
@@ -2658,10 +2658,6 @@ const EVP_MD* getDigestByName(const char* name) {
return EVP_get_digestbyname(name);
}
-const EVP_CIPHER* getCipherByName(const char* name) {
- return EVP_get_cipherbyname(name);
-}
-
bool checkHkdfLength(const Digest& md, size_t length) {
// HKDF-Expand computes up to 255 HMAC blocks, each having as many bits as
// the output of the hash function. 255 is a hard limit because HKDF appends
@@ -4384,7 +4380,7 @@ Result<BIOPointer, bool> EVPKeyPointer::writePrivateKey(
#if NCRYPTO_USE_OPENSSL3_PROVIDER
const EVP_CIPHER* cipher =
- config.format == PKFormatType::PEM ? config.cipher : nullptr;
+ config.format == PKFormatType::PEM ? config.cipher.get() : nullptr;
if (cipher != nullptr && passphrase.len == 0) {
err =
!WriteEncryptedTraditionalPEM(bio.get(), get(), cipher, passphrase);
@@ -4466,7 +4462,7 @@ Result<BIOPointer, bool> EVPKeyPointer::writePrivateKey(
#if NCRYPTO_USE_OPENSSL3_PROVIDER
const EVP_CIPHER* cipher =
- config.format == PKFormatType::PEM ? config.cipher : nullptr;
+ config.format == PKFormatType::PEM ? config.cipher.get() : nullptr;
err = !WriteEncodedPKey(bio.get(),
get(),
OSSL_KEYMGMT_SELECT_ALL,
@@ -5258,6 +5254,19 @@ const Cipher Cipher::FromName(const char* name, CipherCache* cache) {
#endif
}
+const Cipher Cipher::FromNameForKeyEncoding(const char* name) {
+ // Key serializers have their own cipher restrictions. Preserve their policy
+ // instead of applying the filters used by the general cipher operations.
+#if NCRYPTO_USE_OPENSSL3_PROVIDER
+ MarkPopErrorOnReturn mark_pop_error_on_return;
+ DeleteFnPtr<EVP_CIPHER, EVP_CIPHER_free> fetched(
+ EVP_CIPHER_fetch(nullptr, name, nullptr));
+ if (fetched) return Cipher(std::move(fetched));
+#endif
+ // Preserve serializer errors for known ciphers that cannot be fetched.
+ return Cipher(EVP_get_cipherbyname(name));
+}
+
const Cipher Cipher::FromNid(int nid, CipherCache* cache) {
const EVP_CIPHER* cipher = EVP_get_cipherbynid(nid);
if (cipher != nullptr) {
diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h
index 37a8a6040c2..22948f6e85a 100644
--- a/deps/ncrypto/ncrypto.h
+++ b/deps/ncrypto/ncrypto.h
@@ -542,6 +542,7 @@ class Cipher final {
unsigned char* iv) const;
static const Cipher FromName(const char* name, CipherCache* cache = nullptr);
+ static const Cipher FromNameForKeyEncoding(const char* name);
static const Cipher FromNid(int nid, CipherCache* cache = nullptr);
static const Cipher FromCtx(const CipherCtxPointer& ctx);
@@ -1250,7 +1251,7 @@ class EVPKeyPointer final {
using PublicKeyEncodingConfig = AsymmetricKeyEncodingConfig;
struct PrivateKeyEncodingConfig : public AsymmetricKeyEncodingConfig {
- const EVP_CIPHER* cipher = nullptr;
+ Cipher cipher;
std::optional<DataPointer> passphrase = std::nullopt;
PrivateKeyEncodingConfig() = default;
PrivateKeyEncodingConfig(bool output_key_object,
@@ -2194,7 +2195,6 @@ class KDF final {
#endif
const EVP_MD* getDigestByName(const char* name);
-const EVP_CIPHER* getCipherByName(const char* name);
// Verify that the specified HKDF output length is valid for the given digest.
// The maximum length for HKDF output for a given digest is 255 times the
diff --git a/src/crypto/crypto_keys.cc b/src/crypto/crypto_keys.cc
index 30731fa710e..3b22175862a 100644
--- a/src/crypto/crypto_keys.cc
+++ b/src/crypto/crypto_keys.cc
@@ -538,8 +538,8 @@ KeyObjectData::GetPrivateKeyEncodingFromJs(
if (context != kKeyContextInput) {
if (args[*offset]->IsString()) {
Utf8Value cipher_name(env->isolate(), args[*offset]);
- config.cipher = ncrypto::getCipherByName(*cipher_name);
- if (config.cipher == nullptr) {
+ config.cipher = ncrypto::Cipher::FromNameForKeyEncoding(*cipher_name);
+ if (!config.cipher) {
THROW_ERR_CRYPTO_UNKNOWN_CIPHER(env);
return Nothing<EVPKeyPointer::PrivateKeyEncodingConfig>();
}
@@ -552,7 +552,7 @@ KeyObjectData::GetPrivateKeyEncodingFromJs(
}
if (IsAnyBufferSource(args[*offset])) {
- CHECK_IMPLIES(context != kKeyContextInput, config.cipher != nullptr);
+ CHECK_IMPLIES(context != kKeyContextInput, config.cipher);
ArrayBufferOrViewContents<char> passphrase(args[*offset]);
if (!passphrase.CheckSizeInt32()) [[unlikely]] {
THROW_ERR_OUT_OF_RANGE(env, "passphrase is too big");
diff --git a/test/cctest/test_node_crypto.cc b/test/cctest/test_node_crypto.cc
index 69eb8b89127..56911c69dec 100644
--- a/test/cctest/test_node_crypto.cc
+++ b/test/cctest/test_node_crypto.cc
@@ -673,3 +673,38 @@ TEST(NodeCrypto, UnavailableBoringSSLKeyAlgorithms) {
}
}
#endif
+
+#if NCRYPTO_USE_OPENSSL3_PROVIDER
+TEST(NodeCrypto, PrivateKeyEncodingOwnsFetchedCipher) {
+ ncrypto::ClearErrorOnReturn clear_errors;
+ EVPKeyPointer::PrivateKeyEncodingConfig assigned;
+ {
+ EVPKeyPointer::PrivateKeyEncodingConfig original;
+ original.cipher =
+ ncrypto::Cipher::FromNameForKeyEncoding("2.16.840.1.101.3.4.1.42");
+ ASSERT_TRUE(original.cipher);
+ ASSERT_NE(EVP_CIPHER_get0_provider(original.cipher.get()), nullptr);
+ const auto copied = original;
+ assigned = copied;
+ }
+
+ ASSERT_TRUE(assigned.cipher);
+ EXPECT_NE(EVP_CIPHER_get0_provider(assigned.cipher.get()), nullptr);
+ EXPECT_EQ(EVP_CIPHER_is_a(assigned.cipher.get(), "AES-256-CBC"), 1);
+ auto ctx = ncrypto::CipherCtxPointer::New();
+ const unsigned char key[32] = {};
+ const unsigned char iv[16] = {};
+ EXPECT_TRUE(ctx.init(assigned.cipher, true, key, iv));
+}
+
+TEST(NodeCrypto, PrivateKeyEncodingProviderOnlyCipher) {
+ ncrypto::ClearErrorOnReturn clear_errors;
+ const auto available = ncrypto::Cipher::FromName("AES-128-CBC-CTS");
+ if (!available) GTEST_SKIP();
+ const auto cipher =
+ ncrypto::Cipher::FromNameForKeyEncoding("AES-128-CBC-CTS");
+ ASSERT_TRUE(cipher);
+ EXPECT_NE(EVP_CIPHER_get0_provider(cipher.get()), nullptr);
+ EXPECT_EQ(EVP_CIPHER_is_a(cipher.get(), "AES-128-CBC-CTS"), 1);
+}
+#endif
diff --git a/test/parallel/test-crypto-key-encoding-provider-cipher.js b/test/parallel/test-crypto-key-encoding-provider-cipher.js
new file mode 100644
index 00000000000..3ded8ed0a62
--- /dev/null
+++ b/test/parallel/test-crypto-key-encoding-provider-cipher.js
@@ -0,0 +1,113 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const { hasFIPS, hasOpenSSL, isBoringSSL } = require('../common/crypto');
+if (isBoringSSL || !hasOpenSSL(3))
+ common.skip('OpenSSL providers are required');
+
+const assert = require('assert');
+const {
+ createPrivateKey,
+ createPublicKey,
+ generateKeyPair,
+ generateKeyPairSync,
+ getCiphers,
+ sign,
+ verify,
+} = require('crypto');
+
+// Resolve an AES-256-CBC provider alias and retain the fetched cipher while
+// encoding private keys and running asynchronous key generation jobs.
+const cipher = '2.16.840.1.101.3.4.1.42';
+const passphrase = 'provider cipher passphrase';
+const keyOptions = { namedCurve: 'prime256v1' };
+const { publicKey, privateKey } = generateKeyPairSync('ec', keyOptions);
+const data = Buffer.from('encrypted private key');
+
+function checkPrivateKey(encoded, format, type, expectedPublicKey) {
+ const decrypted = createPrivateKey({
+ key: encoded,
+ format,
+ type,
+ passphrase,
+ });
+ assert(createPublicKey(decrypted).equals(expectedPublicKey));
+ const signature = sign('sha256', data, decrypted);
+ assert(verify('sha256', data, expectedPublicKey, signature));
+}
+
+for (const format of ['pem', 'der']) {
+ const privateKeyEncoding = {
+ format,
+ type: 'pkcs8',
+ cipher,
+ passphrase,
+ };
+
+ const exported = privateKey.export(privateKeyEncoding);
+ if (format === 'pem')
+ assert.match(exported, /^-----BEGIN ENCRYPTED PRIVATE KEY-----/);
+ checkPrivateKey(exported, format, 'pkcs8', publicKey);
+
+ const generated = generateKeyPairSync('ec', {
+ ...keyOptions,
+ privateKeyEncoding,
+ });
+ checkPrivateKey(generated.privateKey, format, 'pkcs8', generated.publicKey);
+
+ // Async jobs copy the encoding configuration. Its fetched cipher must remain
+ // valid after the configuration used to create the job has been destroyed.
+ generateKeyPair('ec', {
+ ...keyOptions,
+ privateKeyEncoding,
+ }, common.mustSucceed((generatedPublicKey, generatedPrivateKey) => {
+ checkPrivateKey(generatedPrivateKey, format, 'pkcs8', generatedPublicKey);
+ }));
+}
+
+// Provider lookup must preserve the restrictions of each key format.
+const sec1 = { format: 'pem', type: 'sec1', cipher, passphrase };
+// Traditional PEM encryption uses MD5 to derive its key.
+if (!hasFIPS())
+ checkPrivateKey(privateKey.export(sec1), 'pem', 'sec1', publicKey);
+assert.throws(() => privateKey.export({ ...sec1, format: 'der' }), {
+ code: 'ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS',
+});
+
+const unknownCipher = {
+ format: 'pem',
+ type: 'pkcs8',
+ cipher: 'unknown-private-key-cipher',
+ passphrase,
+};
+const unknownCipherError = {
+ code: 'ERR_CRYPTO_UNKNOWN_CIPHER',
+ message: 'Unknown cipher',
+};
+assert.throws(() => privateKey.export(unknownCipher), unknownCipherError);
+assert.throws(() => generateKeyPairSync('ec', {
+ ...keyOptions,
+ privateKeyEncoding: unknownCipher,
+}), unknownCipherError);
+assert.throws(() => generateKeyPair('ec', {
+ ...keyOptions,
+ privateKeyEncoding: unknownCipher,
+}, common.mustNotCall()), unknownCipherError);
+
+// Provider-only ciphers must reach the serializer, which still rejects ciphers
+// without an ASN.1 identifier when writing PKCS8.
+if (getCiphers().includes('aes-128-cbc-cts')) {
+ for (const format of ['pem', 'der']) {
+ assert.throws(() => privateKey.export({
+ format,
+ type: 'pkcs8',
+ cipher: 'aes-128-cbc-cts',
+ passphrase,
+ }), {
+ code: 'ERR_OSSL_ASN1_CIPHER_HAS_NO_OBJECT_IDENTIFIER',
+ });
+ }
+}