From 763341d1e89b278b07a1de7418e58501218ed295 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 21 May 2026 10:24:04 -0400 Subject: [PATCH 1/3] fix(keys): plumb passphrase through createPrivateKey/createPublicKey The JS layer of createPrivateKey/createPublicKey advertised a `passphrase` field on KeyInputObject and the native Nitro spec/C++ already supported it, but the TS plumbing dropped the value before reaching handle.init(). Encrypted PEM keys surfaced OpenSSL's "interrupted or cancelled" error and DER keys gave a generic "Failed to read DER private key", matching the report in #1048. - forward passphrase through prepareAsymmetricKey - accept passphrase in KeyObject.createKeyObject and always pass format/encoding/passphrase to handle.init (previously the undefined-format branch dropped them) - surface NEED_PASSPHRASE in the DER private-key path so users get "Passphrase required for encrypted key" instead of the generic error Fixes #1048 --- example/src/tests/keys/create_keys.ts | 147 +++++++++++++++++- .../cpp/keys/KeyObjectData.cpp | 8 + .../src/keys/classes.ts | 8 +- .../src/keys/index.ts | 25 ++- 4 files changed, 176 insertions(+), 12 deletions(-) diff --git a/example/src/tests/keys/create_keys.ts b/example/src/tests/keys/create_keys.ts index 4ad2fcb9..1b065283 100644 --- a/example/src/tests/keys/create_keys.ts +++ b/example/src/tests/keys/create_keys.ts @@ -8,7 +8,7 @@ import { sign, verify, } from 'react-native-quick-crypto'; -import type { JWK } from 'react-native-quick-crypto'; +import type { JWK, KeyObject } from 'react-native-quick-crypto'; import { expect } from 'chai'; import { test, assertThrowsAsync, decodeHex } from '../util'; import { rsaPrivateKeyPem, rsaPublicKeyPem } from './fixtures'; @@ -338,6 +338,151 @@ test(SUITE, 'createPrivateKey Ed25519', async () => { expect(key.asymmetricKeyType).to.equal('ed25519'); }); +// --- Encrypted Private Key (passphrase) Tests --- + +async function generateRsaKeyPair(): Promise<{ + privateKey: KeyObject; + publicKey: KeyObject; +}> { + return new Promise((resolve, reject) => { + generateKeyPair('rsa', { modulusLength: 2048 }, (err, pubKey, privKey) => { + if (err) reject(err); + else + resolve({ + privateKey: privKey as KeyObject, + publicKey: pubKey as KeyObject, + }); + }); + }); +} + +test(SUITE, 'createPrivateKey with passphrase (PEM, PKCS8)', async () => { + const { privateKey, publicKey } = await generateRsaKeyPair(); + const passphrase = 'user-test'; + const exported = privateKey.export({ + format: 'pem', + passphrase, + cipher: 'aes-256-cbc', + type: 'pkcs8', + }) as string; + + const imported = createPrivateKey({ key: exported, passphrase }); + + expect(imported.type).to.equal('private'); + expect(imported.asymmetricKeyType).to.equal('rsa'); + expect(imported.equals(privateKey)).to.equal(true); + expect(createPublicKey(imported).equals(publicKey)).to.equal(true); +}); + +test(SUITE, 'createPrivateKey with passphrase (DER, PKCS8)', async () => { + const { privateKey, publicKey } = await generateRsaKeyPair(); + const passphrase = 'user-test'; + const exported = privateKey.export({ + format: 'der', + passphrase, + cipher: 'aes-256-cbc', + type: 'pkcs8', + }) as Buffer; + + const imported = createPrivateKey({ + key: exported, + format: 'der', + type: 'pkcs8', + passphrase, + }); + + expect(imported.type).to.equal('private'); + expect(imported.asymmetricKeyType).to.equal('rsa'); + expect(imported.equals(privateKey)).to.equal(true); + expect(createPublicKey(imported).equals(publicKey)).to.equal(true); +}); + +test(SUITE, 'createPrivateKey passphrase as Buffer (PEM)', async () => { + const { privateKey } = await generateRsaKeyPair(); + const passphrase = Buffer.from('hunter2', 'utf-8'); + const exported = privateKey.export({ + format: 'pem', + passphrase, + cipher: 'aes-256-cbc', + type: 'pkcs8', + }) as string; + + const imported = createPrivateKey({ key: exported, passphrase }); + + expect(imported.equals(privateKey)).to.equal(true); +}); + +test( + SUITE, + 'createPrivateKey on encrypted PEM without passphrase throws', + async () => { + const { privateKey } = await generateRsaKeyPair(); + const exported = privateKey.export({ + format: 'pem', + passphrase: 'user-test', + cipher: 'aes-256-cbc', + type: 'pkcs8', + }) as string; + + await assertThrowsAsync(async () => { + createPrivateKey(exported); + }, ''); + }, +); + +test( + SUITE, + 'createPrivateKey on encrypted DER without passphrase throws Passphrase required', + async () => { + const { privateKey } = await generateRsaKeyPair(); + const exported = privateKey.export({ + format: 'der', + passphrase: 'user-test', + cipher: 'aes-256-cbc', + type: 'pkcs8', + }) as Buffer; + + await assertThrowsAsync(async () => { + createPrivateKey({ key: exported, format: 'der', type: 'pkcs8' }); + }, 'Passphrase required'); + }, +); + +test(SUITE, 'createPrivateKey with wrong passphrase throws', async () => { + const { privateKey } = await generateRsaKeyPair(); + const exported = privateKey.export({ + format: 'pem', + passphrase: 'correct', + cipher: 'aes-256-cbc', + type: 'pkcs8', + }) as string; + + await assertThrowsAsync(async () => { + createPrivateKey({ key: exported, passphrase: 'wrong' }); + }, ''); +}); + +test( + SUITE, + 'createPublicKey extracts public from passphrase-encrypted private key (PEM)', + async () => { + const { privateKey, publicKey } = await generateRsaKeyPair(); + const passphrase = 'user-test'; + const exported = privateKey.export({ + format: 'pem', + passphrase, + cipher: 'aes-256-cbc', + type: 'pkcs8', + }) as string; + + const pub = createPublicKey({ key: exported, passphrase }); + + expect(pub.type).to.equal('public'); + expect(pub.asymmetricKeyType).to.equal('rsa'); + expect(pub.equals(publicKey)).to.equal(true); + }, +); + // --- Round-Trip Tests --- test(SUITE, 'RSA key round-trip: create -> export -> create', () => { diff --git a/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp b/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp index 43880773..38f99a62 100644 --- a/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp +++ b/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp @@ -242,6 +242,7 @@ KeyObjectData KeyObjectData::GetPrivateKey(std::shared_ptr key, std if (res) { return CreateAsymmetric(KeyType::PRIVATE, std::move(res.value)); } + bool needsPassphrase = res.error.has_value() && res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE; // If no specific encoding was provided, try other encodings as fallback if (!type.has_value()) { @@ -256,8 +257,15 @@ KeyObjectData KeyObjectData::GetPrivateKey(std::shared_ptr key, std if (fallback_res) { return CreateAsymmetric(KeyType::PRIVATE, std::move(fallback_res.value)); } + if (fallback_res.error.has_value() && fallback_res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE) { + needsPassphrase = true; + } } } + + if (needsPassphrase) { + throw std::runtime_error("Passphrase required for encrypted key"); + } throw std::runtime_error("Failed to read DER private key"); } } diff --git a/packages/react-native-quick-crypto/src/keys/classes.ts b/packages/react-native-quick-crypto/src/keys/classes.ts index bbb1f953..c68bcb05 100644 --- a/packages/react-native-quick-crypto/src/keys/classes.ts +++ b/packages/react-native-quick-crypto/src/keys/classes.ts @@ -150,6 +150,7 @@ export class KeyObject { key: ArrayBuffer, format?: KFormatType, encoding?: KeyEncoding, + passphrase?: ArrayBuffer, ): KeyObject { if (type !== 'secret' && type !== 'public' && type !== 'private') throw new Error(`invalid KeyObject type: ${type}`); @@ -172,12 +173,7 @@ export class KeyObject { throw new Error('invalid key type'); } - // If format is provided, use it (encoding is optional) - if (format !== undefined) { - handle.init(keyType, key, format, encoding); - } else { - handle.init(keyType, key); - } + handle.init(keyType, key, format, encoding, passphrase); // For asymmetric keys, return the appropriate subclass if (type === 'public' || type === 'private') { diff --git a/packages/react-native-quick-crypto/src/keys/index.ts b/packages/react-native-quick-crypto/src/keys/index.ts index 0d973ed6..91367668 100644 --- a/packages/react-native-quick-crypto/src/keys/index.ts +++ b/packages/react-native-quick-crypto/src/keys/index.ts @@ -114,6 +114,7 @@ function prepareAsymmetricKey( data: ArrayBuffer; format?: 'pem' | 'der'; type?: 'pkcs1' | 'pkcs8' | 'spki' | 'sec1'; + passphrase?: ArrayBuffer; } { if (key instanceof KeyObject) { if (isPublic) { @@ -147,7 +148,9 @@ function prepareAsymmetricKey( if (typeof key === 'object' && 'key' in key) { const keyObj = key as KeyInputObject; - const { key: data, format, type } = keyObj; + const { key: data, format, type, passphrase } = keyObj; + const passphraseAB = + passphrase !== undefined ? toAB(passphrase) : undefined; if (data instanceof KeyObject) { return prepareAsymmetricKey(data, isPublic); @@ -167,14 +170,24 @@ function prepareAsymmetricKey( (typeof data === 'string' && data.includes('-----BEGIN'))) && typeof data === 'string' ) { - return { data: toAB(data), format: 'pem', type }; + return { + data: toAB(data), + format: 'pem', + type, + passphrase: passphraseAB, + }; } // Filter to only 'pem' or 'der' — JWK and raw formats are handled // separately via dedicated paths. const filteredFormat: 'pem' | 'der' | undefined = format === 'pem' || format === 'der' ? format : undefined; - return { data: toAB(data), format: filteredFormat, type }; + return { + data: toAB(data), + format: filteredFormat, + type, + passphrase: passphraseAB, + }; } throw new Error('Invalid key input'); @@ -212,7 +225,7 @@ function createPublicKey(key: KeyInput): PublicKeyObject { return new PublicKeyObject(handle); } - const { data, format, type } = prepareAsymmetricKey(key, true); + const { data, format, type, passphrase } = prepareAsymmetricKey(key, true); // Map format string to KFormatType enum let kFormat: KFormatType | undefined; @@ -229,6 +242,7 @@ function createPublicKey(key: KeyInput): PublicKeyObject { data, kFormat, kType, + passphrase, ) as PublicKeyObject; } @@ -249,7 +263,7 @@ function createPrivateKey(key: KeyInput): PrivateKeyObject { return new PrivateKeyObject(handle); } - const { data, format, type } = prepareAsymmetricKey(key, false); + const { data, format, type, passphrase } = prepareAsymmetricKey(key, false); // Map format string to KFormatType enum let kFormat: KFormatType | undefined; @@ -267,6 +281,7 @@ function createPrivateKey(key: KeyInput): PrivateKeyObject { data, kFormat, kType, + passphrase, ) as PrivateKeyObject; } From 8b6c002c769e42c4dfcdeeb02c152a7d891c830e Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 21 May 2026 12:31:06 -0400 Subject: [PATCH 2/3] fix(keys): copy passphrase into DataPointer to avoid JS-buffer free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ncrypto::DataPointer is an owning RAII wrapper whose destructor calls OPENSSL_clear_free on the pointer it holds. The createPrivateKey / createPublicKey / exportKey paths were constructing it directly from the JS-owned ArrayBuffer's data pointer, so destruction zeroed and freed memory it did not own. In the "passphrase as Buffer (PEM)" test the same Buffer was reused across export and import — export cleared the bytes, so import saw zeros and OpenSSL surfaced bad decrypt. Use DataPointer::Copy at all 7 sites (KeyObjectData.cpp x6, HybridKeyObjectHandle.cpp x1). This mirrors Node.js's ByteSource::ToDataPointer helper (Alloc + memcpy), giving DataPointer ownership of memory it can safely OPENSSL_clear_free. Adds a DER variant of the createPublicKey passphrase-encrypted test and tightens expected error messages on the throws-on-bad-input cases. --- example/src/tests/keys/create_keys.ts | 46 +++++++++++++++---- .../cpp/keys/HybridKeyObjectHandle.cpp | 3 +- .../cpp/keys/KeyObjectData.cpp | 40 +++++++++------- 3 files changed, 62 insertions(+), 27 deletions(-) diff --git a/example/src/tests/keys/create_keys.ts b/example/src/tests/keys/create_keys.ts index 1b065283..838f9d56 100644 --- a/example/src/tests/keys/create_keys.ts +++ b/example/src/tests/keys/create_keys.ts @@ -346,12 +346,14 @@ async function generateRsaKeyPair(): Promise<{ }> { return new Promise((resolve, reject) => { generateKeyPair('rsa', { modulusLength: 2048 }, (err, pubKey, privKey) => { - if (err) reject(err); - else - resolve({ - privateKey: privKey as KeyObject, - publicKey: pubKey as KeyObject, - }); + if (err) { + reject(err); + return; + } + resolve({ + privateKey: privKey as KeyObject, + publicKey: pubKey as KeyObject, + }); }); }); } @@ -414,7 +416,7 @@ test(SUITE, 'createPrivateKey passphrase as Buffer (PEM)', async () => { test( SUITE, - 'createPrivateKey on encrypted PEM without passphrase throws', + 'createPrivateKey on encrypted PEM without passphrase throws Passphrase required', async () => { const { privateKey } = await generateRsaKeyPair(); const exported = privateKey.export({ @@ -426,7 +428,7 @@ test( await assertThrowsAsync(async () => { createPrivateKey(exported); - }, ''); + }, 'Passphrase required'); }, ); @@ -459,7 +461,7 @@ test(SUITE, 'createPrivateKey with wrong passphrase throws', async () => { await assertThrowsAsync(async () => { createPrivateKey({ key: exported, passphrase: 'wrong' }); - }, ''); + }, 'Failed to read'); }); test( @@ -483,6 +485,32 @@ test( }, ); +test( + SUITE, + 'createPublicKey extracts public from passphrase-encrypted private key (DER)', + async () => { + const { privateKey, publicKey } = await generateRsaKeyPair(); + const passphrase = 'user-test'; + const exported = privateKey.export({ + format: 'der', + passphrase, + cipher: 'aes-256-cbc', + type: 'pkcs8', + }) as Buffer; + + const pub = createPublicKey({ + key: exported, + format: 'der', + type: 'pkcs8', + passphrase, + }); + + expect(pub.type).to.equal('public'); + expect(pub.asymmetricKeyType).to.equal('rsa'); + expect(pub.equals(publicKey)).to.equal(true); + }, +); + // --- Round-Trip Tests --- test(SUITE, 'RSA key round-trip: create -> export -> create', () => { diff --git a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp index 6f4f8f75..1b635bc1 100644 --- a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp +++ b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp @@ -231,7 +231,8 @@ std::shared_ptr HybridKeyObjectHandle::exportKey(std::optionaldata(), passphrase_ptr->size())); + config.passphrase = + std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer{passphrase_ptr->data(), passphrase_ptr->size()})); } auto result = pkey.writePrivateKey(config); diff --git a/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp b/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp index 38f99a62..ab018700 100644 --- a/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp +++ b/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp @@ -29,7 +29,8 @@ KeyObjectData TryParsePrivateKey(std::shared_ptr key, std::optional if (passphrase.has_value()) { auto& passphrase_ptr = passphrase.value(); - config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size())); + config.passphrase = + std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer{passphrase_ptr->data(), passphrase_ptr->size()})); } auto buffer = ncrypto::Buffer{key->data(), key->size()}; @@ -133,7 +134,8 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr auto config = GetPrivateKeyEncodingConfig(actualFormat, type.value()); if (passphrase.has_value()) { auto& passphrase_ptr = passphrase.value(); - config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size())); + config.passphrase = + std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer{passphrase_ptr->data(), passphrase_ptr->size()})); } ERR_clear_error(); auto private_res = ncrypto::EVPKeyPointer::TryParsePrivateKey(config, buffer); @@ -155,7 +157,8 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr auto config = GetPrivateKeyEncodingConfig(actualFormat, actualType); if (passphrase.has_value()) { auto& passphrase_ptr = passphrase.value(); - config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size())); + config.passphrase = + std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer{passphrase_ptr->data(), passphrase_ptr->size()})); } ERR_clear_error(); @@ -181,7 +184,8 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr auto private_config = GetPrivateKeyEncodingConfig(actualFormat, type.value()); if (passphrase.has_value()) { auto& passphrase_ptr = passphrase.value(); - private_config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size())); + private_config.passphrase = + std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer{passphrase_ptr->data(), passphrase_ptr->size()})); } auto res = ncrypto::EVPKeyPointer::TryParsePrivateKey(private_config, buffer); if (res) { @@ -196,10 +200,18 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr } auto private_config = GetPrivateKeyEncodingConfig(actualFormat, KeyEncoding::PKCS8); + if (passphrase.has_value()) { + auto& passphrase_ptr = passphrase.value(); + private_config.passphrase = + std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer{passphrase_ptr->data(), passphrase_ptr->size()})); + } auto private_res = ncrypto::EVPKeyPointer::TryParsePrivateKey(private_config, buffer); if (private_res) { return CreateAsymmetric(KeyType::PRIVATE, std::move(private_res.value)); } + if (private_res.error.has_value() && private_res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE) { + throw std::runtime_error("Passphrase required for encrypted key"); + } } throw std::runtime_error("Failed to read DER asymmetric key"); } @@ -232,7 +244,8 @@ KeyObjectData KeyObjectData::GetPrivateKey(std::shared_ptr key, std auto private_config = GetPrivateKeyEncodingConfig(actualFormat, primaryEncoding); if (passphrase.has_value()) { auto& passphrase_ptr = passphrase.value(); - private_config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size())); + private_config.passphrase = + std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer{passphrase_ptr->data(), passphrase_ptr->size()})); } // Clear any existing OpenSSL errors before parsing @@ -242,30 +255,23 @@ KeyObjectData KeyObjectData::GetPrivateKey(std::shared_ptr key, std if (res) { return CreateAsymmetric(KeyType::PRIVATE, std::move(res.value)); } - bool needsPassphrase = res.error.has_value() && res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE; + if (res.error.has_value() && res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE) { + throw std::runtime_error("Passphrase required for encrypted key"); + } - // If no specific encoding was provided, try other encodings as fallback + // If no specific encoding was provided, try other encodings as fallback. + // SEC1/PKCS1 DER are never encrypted, so passphrase is irrelevant here. if (!type.has_value()) { std::vector fallbackEncodings = {KeyEncoding::SEC1, KeyEncoding::PKCS1}; for (auto encoding : fallbackEncodings) { auto config = GetPrivateKeyEncodingConfig(actualFormat, encoding); - if (passphrase.has_value()) { - auto& passphrase_ptr = passphrase.value(); - config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size())); - } auto fallback_res = ncrypto::EVPKeyPointer::TryParsePrivateKey(config, buffer); if (fallback_res) { return CreateAsymmetric(KeyType::PRIVATE, std::move(fallback_res.value)); } - if (fallback_res.error.has_value() && fallback_res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE) { - needsPassphrase = true; - } } } - if (needsPassphrase) { - throw std::runtime_error("Passphrase required for encrypted key"); - } throw std::runtime_error("Failed to read DER private key"); } } From f621b5b2a9bd02fd0cc28ac101d27c01df008367 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 21 May 2026 12:40:15 -0400 Subject: [PATCH 3/3] fix(keys): handle OpenSSL 3.6 NEED_PASSPHRASE and clear errors between parse attempts Two related issues exposed by the new createPrivateKey/createPublicKey passphrase tests under OpenSSL 3.6: 1. Encrypted PEM without passphrase reported "interrupted or cancelled" instead of "Passphrase required". ncrypto's keyOrError only maps ERR_LIB_PEM/PEM_R_BAD_PASSWORD_READ to NEED_PASSPHRASE, but PEM_read_bio_PrivateKey in OpenSSL 3.6 surfaces a missing-passphrase callback as ERR_R_INTERRUPTED_OR_CANCELLED on ERR_LIB_CRYPTO. Detect that reason code in the rnqc wrapper when no passphrase was supplied. 2. createPublicKey on an encrypted PKCS8 DER buffer failed with "Failed to read DER asymmetric key" even though the parse succeeded. The no-type DER branch tries SPKI first; d2i_PUBKEY fails and leaves an error on OpenSSL's queue, then the follow-up TryParsePrivateKey parses the key correctly but ncrypto's keyOrError peeks the stale error and downgrades the result to FAILED. Clear the OpenSSL error queue between attempts. --- .../cpp/keys/KeyObjectData.cpp | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp b/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp index ab018700..82147cae 100644 --- a/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp +++ b/packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp @@ -45,13 +45,21 @@ KeyObjectData TryParsePrivateKey(std::shared_ptr key, std::optional if (res.error.has_value() && res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE) { throw std::runtime_error("Passphrase required for encrypted key"); - } else { - // Get OpenSSL error details - unsigned long err = ERR_get_error(); - char err_buf[256]; - ERR_error_string_n(err, err_buf, sizeof(err_buf)); - throw std::runtime_error("Failed to read private key: " + std::string(err_buf)); } + + // ncrypto only maps ERR_LIB_PEM/PEM_R_BAD_PASSWORD_READ to NEED_PASSPHRASE. On OpenSSL 3.6+ + // PEM_read_bio_PrivateKey surfaces a missing-passphrase callback as + // ERR_R_INTERRUPTED_OR_CANCELLED on ERR_LIB_CRYPTO instead. + if (!passphrase.has_value() && res.openssl_error.has_value() && + ERR_GET_REASON(res.openssl_error.value()) == ERR_R_INTERRUPTED_OR_CANCELLED) { + throw std::runtime_error("Passphrase required for encrypted key"); + } + + // Get OpenSSL error details + unsigned long err = ERR_get_error(); + char err_buf[256]; + ERR_error_string_n(err, err_buf, sizeof(err_buf)); + throw std::runtime_error("Failed to read private key: " + std::string(err_buf)); } KeyObjectData::KeyObjectData(std::nullptr_t) : key_type_(KeyType::SECRET) {} @@ -192,13 +200,17 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr return CreateAsymmetric(KeyType::PRIVATE, std::move(res.value)); } } else { - // If no encoding type specified, try both SPKI and PKCS8 + // If no encoding type specified, try both SPKI and PKCS8. Clear the OpenSSL error + // queue between attempts so a failed first parse doesn't taint ncrypto's + // post-parse ERR_peek_error() check on the second. + ERR_clear_error(); auto public_config = GetPublicKeyEncodingConfig(actualFormat, KeyEncoding::SPKI); auto public_res = ncrypto::EVPKeyPointer::TryParsePublicKey(public_config, buffer); if (public_res) { return CreateAsymmetric(KeyType::PUBLIC, std::move(public_res.value)); } + ERR_clear_error(); auto private_config = GetPrivateKeyEncodingConfig(actualFormat, KeyEncoding::PKCS8); if (passphrase.has_value()) { auto& passphrase_ptr = passphrase.value(); @@ -212,6 +224,10 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr if (private_res.error.has_value() && private_res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE) { throw std::runtime_error("Passphrase required for encrypted key"); } + if (!passphrase.has_value() && private_res.openssl_error.has_value() && + ERR_GET_REASON(private_res.openssl_error.value()) == ERR_R_INTERRUPTED_OR_CANCELLED) { + throw std::runtime_error("Passphrase required for encrypted key"); + } } throw std::runtime_error("Failed to read DER asymmetric key"); }