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>
This commit is contained in:
Filip Skokan authored and Antoine du Hamel committed 2026-09-20 23:14:22 +02:00
1 parent ca8ee04594
commit 3de05da569
5 files changed
+168 -11

No files matched your search

+15 -6
View File
@@ -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) {
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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");
+35
View File
@@ -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
@@ -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',
});
}
}