diff --git a/example/src/tests/keys/create_keys.ts b/example/src/tests/keys/create_keys.ts index 4ad2fcb9..838f9d56 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,179 @@ 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); + return; + } + 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 Passphrase required', + 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); + }, 'Passphrase required'); + }, +); + +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' }); + }, 'Failed to read'); +}); + +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); + }, +); + +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 43880773..82147cae 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()}; @@ -44,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) {} @@ -133,7 +142,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 +165,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,25 +192,42 @@ 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) { 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(); + 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"); + } + 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"); } @@ -232,7 +260,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,22 +271,23 @@ KeyObjectData KeyObjectData::GetPrivateKey(std::shared_ptr key, std if (res) { return CreateAsymmetric(KeyType::PRIVATE, std::move(res.value)); } + 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)); } } } + 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; }