Skip to content

Commit 0ea34e5

Browse files
authored
fix(keys): support passphrase-encrypted PEM/DER in createPrivateKey/createPublicKey (#1050)
1 parent 9e11f31 commit 0ea34e5

5 files changed

Lines changed: 245 additions & 30 deletions

File tree

example/src/tests/keys/create_keys.ts

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
sign,
99
verify,
1010
} from 'react-native-quick-crypto';
11-
import type { JWK } from 'react-native-quick-crypto';
11+
import type { JWK, KeyObject } from 'react-native-quick-crypto';
1212
import { expect } from 'chai';
1313
import { test, assertThrowsAsync, decodeHex } from '../util';
1414
import { rsaPrivateKeyPem, rsaPublicKeyPem } from './fixtures';
@@ -338,6 +338,179 @@ test(SUITE, 'createPrivateKey Ed25519', async () => {
338338
expect(key.asymmetricKeyType).to.equal('ed25519');
339339
});
340340

341+
// --- Encrypted Private Key (passphrase) Tests ---
342+
343+
async function generateRsaKeyPair(): Promise<{
344+
privateKey: KeyObject;
345+
publicKey: KeyObject;
346+
}> {
347+
return new Promise((resolve, reject) => {
348+
generateKeyPair('rsa', { modulusLength: 2048 }, (err, pubKey, privKey) => {
349+
if (err) {
350+
reject(err);
351+
return;
352+
}
353+
resolve({
354+
privateKey: privKey as KeyObject,
355+
publicKey: pubKey as KeyObject,
356+
});
357+
});
358+
});
359+
}
360+
361+
test(SUITE, 'createPrivateKey with passphrase (PEM, PKCS8)', async () => {
362+
const { privateKey, publicKey } = await generateRsaKeyPair();
363+
const passphrase = 'user-test';
364+
const exported = privateKey.export({
365+
format: 'pem',
366+
passphrase,
367+
cipher: 'aes-256-cbc',
368+
type: 'pkcs8',
369+
}) as string;
370+
371+
const imported = createPrivateKey({ key: exported, passphrase });
372+
373+
expect(imported.type).to.equal('private');
374+
expect(imported.asymmetricKeyType).to.equal('rsa');
375+
expect(imported.equals(privateKey)).to.equal(true);
376+
expect(createPublicKey(imported).equals(publicKey)).to.equal(true);
377+
});
378+
379+
test(SUITE, 'createPrivateKey with passphrase (DER, PKCS8)', async () => {
380+
const { privateKey, publicKey } = await generateRsaKeyPair();
381+
const passphrase = 'user-test';
382+
const exported = privateKey.export({
383+
format: 'der',
384+
passphrase,
385+
cipher: 'aes-256-cbc',
386+
type: 'pkcs8',
387+
}) as Buffer;
388+
389+
const imported = createPrivateKey({
390+
key: exported,
391+
format: 'der',
392+
type: 'pkcs8',
393+
passphrase,
394+
});
395+
396+
expect(imported.type).to.equal('private');
397+
expect(imported.asymmetricKeyType).to.equal('rsa');
398+
expect(imported.equals(privateKey)).to.equal(true);
399+
expect(createPublicKey(imported).equals(publicKey)).to.equal(true);
400+
});
401+
402+
test(SUITE, 'createPrivateKey passphrase as Buffer (PEM)', async () => {
403+
const { privateKey } = await generateRsaKeyPair();
404+
const passphrase = Buffer.from('hunter2', 'utf-8');
405+
const exported = privateKey.export({
406+
format: 'pem',
407+
passphrase,
408+
cipher: 'aes-256-cbc',
409+
type: 'pkcs8',
410+
}) as string;
411+
412+
const imported = createPrivateKey({ key: exported, passphrase });
413+
414+
expect(imported.equals(privateKey)).to.equal(true);
415+
});
416+
417+
test(
418+
SUITE,
419+
'createPrivateKey on encrypted PEM without passphrase throws Passphrase required',
420+
async () => {
421+
const { privateKey } = await generateRsaKeyPair();
422+
const exported = privateKey.export({
423+
format: 'pem',
424+
passphrase: 'user-test',
425+
cipher: 'aes-256-cbc',
426+
type: 'pkcs8',
427+
}) as string;
428+
429+
await assertThrowsAsync(async () => {
430+
createPrivateKey(exported);
431+
}, 'Passphrase required');
432+
},
433+
);
434+
435+
test(
436+
SUITE,
437+
'createPrivateKey on encrypted DER without passphrase throws Passphrase required',
438+
async () => {
439+
const { privateKey } = await generateRsaKeyPair();
440+
const exported = privateKey.export({
441+
format: 'der',
442+
passphrase: 'user-test',
443+
cipher: 'aes-256-cbc',
444+
type: 'pkcs8',
445+
}) as Buffer;
446+
447+
await assertThrowsAsync(async () => {
448+
createPrivateKey({ key: exported, format: 'der', type: 'pkcs8' });
449+
}, 'Passphrase required');
450+
},
451+
);
452+
453+
test(SUITE, 'createPrivateKey with wrong passphrase throws', async () => {
454+
const { privateKey } = await generateRsaKeyPair();
455+
const exported = privateKey.export({
456+
format: 'pem',
457+
passphrase: 'correct',
458+
cipher: 'aes-256-cbc',
459+
type: 'pkcs8',
460+
}) as string;
461+
462+
await assertThrowsAsync(async () => {
463+
createPrivateKey({ key: exported, passphrase: 'wrong' });
464+
}, 'Failed to read');
465+
});
466+
467+
test(
468+
SUITE,
469+
'createPublicKey extracts public from passphrase-encrypted private key (PEM)',
470+
async () => {
471+
const { privateKey, publicKey } = await generateRsaKeyPair();
472+
const passphrase = 'user-test';
473+
const exported = privateKey.export({
474+
format: 'pem',
475+
passphrase,
476+
cipher: 'aes-256-cbc',
477+
type: 'pkcs8',
478+
}) as string;
479+
480+
const pub = createPublicKey({ key: exported, passphrase });
481+
482+
expect(pub.type).to.equal('public');
483+
expect(pub.asymmetricKeyType).to.equal('rsa');
484+
expect(pub.equals(publicKey)).to.equal(true);
485+
},
486+
);
487+
488+
test(
489+
SUITE,
490+
'createPublicKey extracts public from passphrase-encrypted private key (DER)',
491+
async () => {
492+
const { privateKey, publicKey } = await generateRsaKeyPair();
493+
const passphrase = 'user-test';
494+
const exported = privateKey.export({
495+
format: 'der',
496+
passphrase,
497+
cipher: 'aes-256-cbc',
498+
type: 'pkcs8',
499+
}) as Buffer;
500+
501+
const pub = createPublicKey({
502+
key: exported,
503+
format: 'der',
504+
type: 'pkcs8',
505+
passphrase,
506+
});
507+
508+
expect(pub.type).to.equal('public');
509+
expect(pub.asymmetricKeyType).to.equal('rsa');
510+
expect(pub.equals(publicKey)).to.equal(true);
511+
},
512+
);
513+
341514
// --- Round-Trip Tests ---
342515

343516
test(SUITE, 'RSA key round-trip: create -> export -> create', () => {

packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@ std::shared_ptr<ArrayBuffer> HybridKeyObjectHandle::exportKey(std::optional<KFor
231231

232232
if (passphrase.has_value()) {
233233
auto& passphrase_ptr = passphrase.value();
234-
config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size()));
234+
config.passphrase =
235+
std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
235236
}
236237

237238
auto result = pkey.writePrivateKey(config);

packages/react-native-quick-crypto/cpp/keys/KeyObjectData.cpp

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ KeyObjectData TryParsePrivateKey(std::shared_ptr<ArrayBuffer> key, std::optional
2929

3030
if (passphrase.has_value()) {
3131
auto& passphrase_ptr = passphrase.value();
32-
config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size()));
32+
config.passphrase =
33+
std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
3334
}
3435

3536
auto buffer = ncrypto::Buffer<const unsigned char>{key->data(), key->size()};
@@ -44,13 +45,21 @@ KeyObjectData TryParsePrivateKey(std::shared_ptr<ArrayBuffer> key, std::optional
4445

4546
if (res.error.has_value() && res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE) {
4647
throw std::runtime_error("Passphrase required for encrypted key");
47-
} else {
48-
// Get OpenSSL error details
49-
unsigned long err = ERR_get_error();
50-
char err_buf[256];
51-
ERR_error_string_n(err, err_buf, sizeof(err_buf));
52-
throw std::runtime_error("Failed to read private key: " + std::string(err_buf));
5348
}
49+
50+
// ncrypto only maps ERR_LIB_PEM/PEM_R_BAD_PASSWORD_READ to NEED_PASSPHRASE. On OpenSSL 3.6+
51+
// PEM_read_bio_PrivateKey surfaces a missing-passphrase callback as
52+
// ERR_R_INTERRUPTED_OR_CANCELLED on ERR_LIB_CRYPTO instead.
53+
if (!passphrase.has_value() && res.openssl_error.has_value() &&
54+
ERR_GET_REASON(res.openssl_error.value()) == ERR_R_INTERRUPTED_OR_CANCELLED) {
55+
throw std::runtime_error("Passphrase required for encrypted key");
56+
}
57+
58+
// Get OpenSSL error details
59+
unsigned long err = ERR_get_error();
60+
char err_buf[256];
61+
ERR_error_string_n(err, err_buf, sizeof(err_buf));
62+
throw std::runtime_error("Failed to read private key: " + std::string(err_buf));
5463
}
5564

5665
KeyObjectData::KeyObjectData(std::nullptr_t) : key_type_(KeyType::SECRET) {}
@@ -133,7 +142,8 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr<ArrayBuffer>
133142
auto config = GetPrivateKeyEncodingConfig(actualFormat, type.value());
134143
if (passphrase.has_value()) {
135144
auto& passphrase_ptr = passphrase.value();
136-
config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size()));
145+
config.passphrase =
146+
std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
137147
}
138148
ERR_clear_error();
139149
auto private_res = ncrypto::EVPKeyPointer::TryParsePrivateKey(config, buffer);
@@ -155,7 +165,8 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr<ArrayBuffer>
155165
auto config = GetPrivateKeyEncodingConfig(actualFormat, actualType);
156166
if (passphrase.has_value()) {
157167
auto& passphrase_ptr = passphrase.value();
158-
config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size()));
168+
config.passphrase =
169+
std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
159170
}
160171

161172
ERR_clear_error();
@@ -181,25 +192,42 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKey(std::shared_ptr<ArrayBuffer>
181192
auto private_config = GetPrivateKeyEncodingConfig(actualFormat, type.value());
182193
if (passphrase.has_value()) {
183194
auto& passphrase_ptr = passphrase.value();
184-
private_config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size()));
195+
private_config.passphrase =
196+
std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
185197
}
186198
auto res = ncrypto::EVPKeyPointer::TryParsePrivateKey(private_config, buffer);
187199
if (res) {
188200
return CreateAsymmetric(KeyType::PRIVATE, std::move(res.value));
189201
}
190202
} else {
191-
// If no encoding type specified, try both SPKI and PKCS8
203+
// If no encoding type specified, try both SPKI and PKCS8. Clear the OpenSSL error
204+
// queue between attempts so a failed first parse doesn't taint ncrypto's
205+
// post-parse ERR_peek_error() check on the second.
206+
ERR_clear_error();
192207
auto public_config = GetPublicKeyEncodingConfig(actualFormat, KeyEncoding::SPKI);
193208
auto public_res = ncrypto::EVPKeyPointer::TryParsePublicKey(public_config, buffer);
194209
if (public_res) {
195210
return CreateAsymmetric(KeyType::PUBLIC, std::move(public_res.value));
196211
}
197212

213+
ERR_clear_error();
198214
auto private_config = GetPrivateKeyEncodingConfig(actualFormat, KeyEncoding::PKCS8);
215+
if (passphrase.has_value()) {
216+
auto& passphrase_ptr = passphrase.value();
217+
private_config.passphrase =
218+
std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
219+
}
199220
auto private_res = ncrypto::EVPKeyPointer::TryParsePrivateKey(private_config, buffer);
200221
if (private_res) {
201222
return CreateAsymmetric(KeyType::PRIVATE, std::move(private_res.value));
202223
}
224+
if (private_res.error.has_value() && private_res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE) {
225+
throw std::runtime_error("Passphrase required for encrypted key");
226+
}
227+
if (!passphrase.has_value() && private_res.openssl_error.has_value() &&
228+
ERR_GET_REASON(private_res.openssl_error.value()) == ERR_R_INTERRUPTED_OR_CANCELLED) {
229+
throw std::runtime_error("Passphrase required for encrypted key");
230+
}
203231
}
204232
throw std::runtime_error("Failed to read DER asymmetric key");
205233
}
@@ -232,7 +260,8 @@ KeyObjectData KeyObjectData::GetPrivateKey(std::shared_ptr<ArrayBuffer> key, std
232260
auto private_config = GetPrivateKeyEncodingConfig(actualFormat, primaryEncoding);
233261
if (passphrase.has_value()) {
234262
auto& passphrase_ptr = passphrase.value();
235-
private_config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size()));
263+
private_config.passphrase =
264+
std::make_optional(ncrypto::DataPointer::Copy(ncrypto::Buffer<const void>{passphrase_ptr->data(), passphrase_ptr->size()}));
236265
}
237266

238267
// Clear any existing OpenSSL errors before parsing
@@ -242,22 +271,23 @@ KeyObjectData KeyObjectData::GetPrivateKey(std::shared_ptr<ArrayBuffer> key, std
242271
if (res) {
243272
return CreateAsymmetric(KeyType::PRIVATE, std::move(res.value));
244273
}
274+
if (res.error.has_value() && res.error.value() == ncrypto::EVPKeyPointer::PKParseError::NEED_PASSPHRASE) {
275+
throw std::runtime_error("Passphrase required for encrypted key");
276+
}
245277

246-
// If no specific encoding was provided, try other encodings as fallback
278+
// If no specific encoding was provided, try other encodings as fallback.
279+
// SEC1/PKCS1 DER are never encrypted, so passphrase is irrelevant here.
247280
if (!type.has_value()) {
248281
std::vector<KeyEncoding> fallbackEncodings = {KeyEncoding::SEC1, KeyEncoding::PKCS1};
249282
for (auto encoding : fallbackEncodings) {
250283
auto config = GetPrivateKeyEncodingConfig(actualFormat, encoding);
251-
if (passphrase.has_value()) {
252-
auto& passphrase_ptr = passphrase.value();
253-
config.passphrase = std::make_optional(ncrypto::DataPointer(passphrase_ptr->data(), passphrase_ptr->size()));
254-
}
255284
auto fallback_res = ncrypto::EVPKeyPointer::TryParsePrivateKey(config, buffer);
256285
if (fallback_res) {
257286
return CreateAsymmetric(KeyType::PRIVATE, std::move(fallback_res.value));
258287
}
259288
}
260289
}
290+
261291
throw std::runtime_error("Failed to read DER private key");
262292
}
263293
}

packages/react-native-quick-crypto/src/keys/classes.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export class KeyObject {
150150
key: ArrayBuffer,
151151
format?: KFormatType,
152152
encoding?: KeyEncoding,
153+
passphrase?: ArrayBuffer,
153154
): KeyObject {
154155
if (type !== 'secret' && type !== 'public' && type !== 'private')
155156
throw new Error(`invalid KeyObject type: ${type}`);
@@ -172,12 +173,7 @@ export class KeyObject {
172173
throw new Error('invalid key type');
173174
}
174175

175-
// If format is provided, use it (encoding is optional)
176-
if (format !== undefined) {
177-
handle.init(keyType, key, format, encoding);
178-
} else {
179-
handle.init(keyType, key);
180-
}
176+
handle.init(keyType, key, format, encoding, passphrase);
181177

182178
// For asymmetric keys, return the appropriate subclass
183179
if (type === 'public' || type === 'private') {

0 commit comments

Comments
 (0)