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',
+    });
+  }
+}