Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 174 additions & 1 deletion example/src/tests/keys/create_keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,8 @@ std::shared_ptr<ArrayBuffer> HybridKeyObjectHandle::exportKey(std::optional<KFor

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<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
}

auto result = pkey.writePrivateKey(config);
Expand Down
64 changes: 47 additions & 17 deletions packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ KeyObjectData TryParsePrivateKey(std::shared_ptr<ArrayBuffer> 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<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
}

auto buffer = ncrypto::Buffer<const unsigned char>{key->data(), key->size()};
Expand All @@ -44,13 +45,21 @@ KeyObjectData TryParsePrivateKey(std::shared_ptr<ArrayBuffer> 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) {}
Expand Down Expand Up @@ -133,7 +142,8 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr<ArrayBuffer>
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<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
}
ERR_clear_error();
auto private_res = ncrypto::EVPKeyPointer::TryParsePrivateKey(config, buffer);
Expand All @@ -155,7 +165,8 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr<ArrayBuffer>
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<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
}

ERR_clear_error();
Expand All @@ -181,25 +192,42 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr<ArrayBuffer>
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<const void>{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<const void>{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");
}
Expand Down Expand Up @@ -232,7 +260,8 @@ KeyObjectData KeyObjectData::GetPrivateKey(std::shared_ptr<ArrayBuffer> 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<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
}

// Clear any existing OpenSSL errors before parsing
Expand All @@ -242,22 +271,23 @@ KeyObjectData KeyObjectData::GetPrivateKey(std::shared_ptr<ArrayBuffer> 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<KeyEncoding> 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");
}
}
Expand Down
8 changes: 2 additions & 6 deletions packages/react-native-quick-crypto/src/keys/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -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') {
Expand Down
Loading
Loading