Skip to content

Commit b30fc08

Browse files
committed
feat: PQC PKCS#8 seed validation for ML-DSA and ML-KEM (#997)
Reject ML-DSA / ML-KEM PKCS#8 imports that contain only the expanded private key (seedless), and validate exported PKCS#8 length against 22 + seed_size to catch the toCryptoKey-on-seedless-KeyObject edge case. Also configures OpenSSL providers to prefer seed-only PKCS#8 output for ML-DSA / ML-KEM (seed-only,priv-only), mirroring Node's src/crypto/crypto_util.cc. Without this, OpenSSL defaults to seed-priv, which silently produces a longer encoding and breaks cross-implementation interop.
1 parent e33f183 commit b30fc08

3 files changed

Lines changed: 178 additions & 9 deletions

File tree

example/src/tests/subtle/import_export.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2283,6 +2283,43 @@ test(SUITE, 'ML-DSA-44 importKey rejects jwk alg mismatch', async () => {
22832283
);
22842284
});
22852285

2286+
// --- ML-DSA seedless PKCS#8 import rejection (issue #997) ---
2287+
2288+
const MLDSA_SEEDLESS_PKCS8_LENGTHS: Record<MlDsaVariant, number> = {
2289+
'ML-DSA-44': 2588,
2290+
'ML-DSA-65': 4060,
2291+
'ML-DSA-87': 4924,
2292+
};
2293+
2294+
for (const variant of MLDSA_VARIANTS) {
2295+
test(SUITE, `${variant} pkcs8 import rejects seedless key`, async () => {
2296+
const seedless = new Uint8Array(MLDSA_SEEDLESS_PKCS8_LENGTHS[variant]);
2297+
await assertThrowsAsync(
2298+
async () =>
2299+
await subtle.importKey('pkcs8', seedless, { name: variant }, true, [
2300+
'sign',
2301+
]),
2302+
'NotSupportedError',
2303+
);
2304+
});
2305+
2306+
test(
2307+
SUITE,
2308+
`${variant} pkcs8 export produces 54-byte seed-only encoding`,
2309+
async () => {
2310+
const keyPair = (await subtle.generateKey({ name: variant }, true, [
2311+
'sign',
2312+
'verify',
2313+
])) as CryptoKeyPair;
2314+
const exported = (await subtle.exportKey(
2315+
'pkcs8',
2316+
keyPair.privateKey as CryptoKey,
2317+
)) as ArrayBuffer;
2318+
expect(exported.byteLength).to.equal(54);
2319+
},
2320+
);
2321+
}
2322+
22862323
// --- ML-DSA raw-public and raw-seed export/import tests ---
22872324
// 'raw-public' is normalized to 'raw' internally (public key bytes)
22882325
// 'raw-seed' passes through directly (64-byte private seed)
@@ -2684,6 +2721,78 @@ test(SUITE, 'ML-KEM-768 importKey raw rejects bad usages', async () => {
26842721
);
26852722
});
26862723

2724+
// --- ML-KEM seedless PKCS#8 import rejection + export length (issue #997) ---
2725+
2726+
const MLKEM_SEEDLESS_PKCS8_LENGTHS: Record<
2727+
(typeof MLKEM_VARIANTS)[number],
2728+
number
2729+
> = {
2730+
'ML-KEM-512': 1660,
2731+
'ML-KEM-768': 2428,
2732+
'ML-KEM-1024': 3196,
2733+
};
2734+
2735+
for (const variant of MLKEM_VARIANTS) {
2736+
test(SUITE, `${variant} pkcs8 import rejects seedless key`, async () => {
2737+
const seedless = new Uint8Array(MLKEM_SEEDLESS_PKCS8_LENGTHS[variant]);
2738+
await assertThrowsAsync(
2739+
async () =>
2740+
await subtle.importKey('pkcs8', seedless, { name: variant }, true, [
2741+
'decapsulateBits',
2742+
]),
2743+
'NotSupportedError',
2744+
);
2745+
});
2746+
2747+
test(
2748+
SUITE,
2749+
`${variant} pkcs8 export produces 86-byte seed-only encoding`,
2750+
async () => {
2751+
const keyPair = (await subtle.generateKey({ name: variant }, true, [
2752+
'encapsulateBits',
2753+
'decapsulateBits',
2754+
])) as CryptoKeyPair;
2755+
const exported = (await subtle.exportKey(
2756+
'pkcs8',
2757+
keyPair.privateKey as CryptoKey,
2758+
)) as ArrayBuffer;
2759+
expect(exported.byteLength).to.equal(86);
2760+
},
2761+
);
2762+
}
2763+
2764+
test(SUITE, 'ML-KEM-768 pkcs8 round-trip + decapsulate', async () => {
2765+
const keyPair = (await subtle.generateKey({ name: 'ML-KEM-768' }, true, [
2766+
'encapsulateBits',
2767+
'decapsulateBits',
2768+
])) as CryptoKeyPair;
2769+
2770+
const exported = (await subtle.exportKey(
2771+
'pkcs8',
2772+
keyPair.privateKey as CryptoKey,
2773+
)) as ArrayBuffer;
2774+
expect(exported.byteLength).to.equal(86);
2775+
2776+
const imported = await subtle.importKey(
2777+
'pkcs8',
2778+
exported,
2779+
{ name: 'ML-KEM-768' },
2780+
true,
2781+
['decapsulateBits'],
2782+
);
2783+
2784+
const { sharedKey, ciphertext } = await subtle.encapsulateBits(
2785+
{ name: 'ML-KEM-768' },
2786+
keyPair.publicKey as CryptoKey,
2787+
);
2788+
const recovered = await subtle.decapsulateBits(
2789+
{ name: 'ML-KEM-768' },
2790+
imported,
2791+
ciphertext,
2792+
);
2793+
expect(new Uint8Array(recovered)).to.deep.equal(new Uint8Array(sharedKey));
2794+
});
2795+
26872796
// --- Ed25519/Ed448 raw import/export Tests ---
26882797

26892798
const edCurves = [

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include <cstdio>
2+
#include <mutex>
23
#include <stdexcept>
34

45
#include "../utils/base64.h"
@@ -10,10 +11,32 @@
1011
#include <openssl/ec.h>
1112
#include <openssl/evp.h>
1213
#include <openssl/obj_mac.h>
14+
#include <openssl/provider.h>
1315
#include <openssl/rsa.h>
1416

1517
namespace margelo::nitro::crypto {
1618

19+
#if OPENSSL_VERSION_NUMBER >= 0x30600000L
20+
// Configure loaded providers to prefer seed-only PKCS#8 output for ML-DSA /
21+
// ML-KEM, falling back to priv-only when no seed is available. Without this,
22+
// OpenSSL defaults to "seed-priv" — a longer encoding that bundles both —
23+
// which breaks interop with Node and PR #997's exact-length export check.
24+
// Mirrors src/crypto/crypto_util.cc in Node.
25+
static void configurePqcOutputFormats() {
26+
static std::once_flag once;
27+
std::call_once(once, []() {
28+
OSSL_PROVIDER_do_all(
29+
nullptr,
30+
[](OSSL_PROVIDER* provider, void*) -> int {
31+
OSSL_PROVIDER_add_conf_parameter(provider, "ml-kem.output_formats", "seed-only,priv-only");
32+
OSSL_PROVIDER_add_conf_parameter(provider, "ml-dsa.output_formats", "seed-only,priv-only");
33+
return 1;
34+
},
35+
nullptr);
36+
});
37+
}
38+
#endif
39+
1740
// Helper functions for base64url encoding/decoding with BIGNUMs
1841
static std::string bn_to_base64url(const BIGNUM* bn, size_t expected_size = 0) {
1942
if (!bn)
@@ -170,6 +193,18 @@ std::shared_ptr<ArrayBuffer> HybridKeyObjectHandle::exportKey(std::optional<KFor
170193
// This allows extracting the public key from a private key
171194
bool exportAsPublic = (exportType == KeyEncoding::SPKI) || (keyType == KeyType::PUBLIC);
172195

196+
#if OPENSSL_VERSION_NUMBER >= 0x30600000L
197+
if (!exportAsPublic && exportType == KeyEncoding::PKCS8) {
198+
const char* typeName = EVP_PKEY_get0_type_name(pkey.get());
199+
if (typeName != nullptr) {
200+
std::string name(typeName);
201+
if (name.starts_with("ML-KEM-") || name.starts_with("ML-DSA-")) {
202+
configurePqcOutputFormats();
203+
}
204+
}
205+
}
206+
#endif
207+
173208
// Create encoding config
174209
if (exportAsPublic) {
175210
ncrypto::EVPKeyPointer::PublicKeyEncodingConfig config(false, static_cast<ncrypto::EVPKeyPointer::PKFormatType>(exportFormat),

packages/react-native-quick-crypto/src/subtle.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,19 @@ function edImportKey(
12781278
return new CryptoKey(keyObject, { name }, keyUsages, extractable);
12791279
}
12801280

1281+
// Lengths (in bytes) of seedless ML-DSA / ML-KEM PKCS#8 encodings. A PKCS#8
1282+
// blob of exactly this length contains only the expanded private key with no
1283+
// seed; Node rejects these to keep cross-implementation interop intact.
1284+
// Refs: ~/dev/node/lib/internal/crypto/ml_dsa.js:158-174, ml_kem.js:157-173.
1285+
const PQC_SEEDLESS_PKCS8_LENGTHS: Readonly<Record<string, number>> = {
1286+
'ML-DSA-44': 2588,
1287+
'ML-DSA-65': 4060,
1288+
'ML-DSA-87': 4924,
1289+
'ML-KEM-512': 1660,
1290+
'ML-KEM-768': 2428,
1291+
'ML-KEM-1024': 3196,
1292+
};
1293+
12811294
function pqcImportKeyObject(
12821295
format: ImportFormat,
12831296
data: BufferLike | JWK,
@@ -1294,10 +1307,18 @@ function pqcImportKeyObject(
12941307
isPublic: true,
12951308
};
12961309
} else if (format === 'pkcs8') {
1310+
const ab = bufferLikeToArrayBuffer(data as BufferLike);
1311+
if (ab.byteLength === PQC_SEEDLESS_PKCS8_LENGTHS[name]) {
1312+
const family = name.startsWith('ML-DSA') ? 'ML-DSA' : 'ML-KEM';
1313+
throw lazyDOMException(
1314+
`Importing an ${family} PKCS#8 key without a seed is not supported`,
1315+
'NotSupportedError',
1316+
);
1317+
}
12971318
return {
12981319
keyObject: KeyObject.createKeyObject(
12991320
'private',
1300-
bufferLikeToArrayBuffer(data as BufferLike),
1321+
ab,
13011322
KFormatType.DER,
13021323
KeyEncoding.PKCS8,
13031324
),
@@ -1551,22 +1572,26 @@ const exportKeyPkcs8 = async (
15511572
case 'ML-DSA-65':
15521573
// Fall through
15531574
case 'ML-DSA-87':
1554-
if (key.type === 'private') {
1555-
// Export ML-DSA key in PKCS8 DER format
1556-
return bufferLikeToArrayBuffer(
1557-
key.keyObject.handle.exportKey(KFormatType.DER, KeyEncoding.PKCS8),
1558-
);
1559-
}
1560-
break;
1575+
// Fall through
15611576
case 'ML-KEM-512':
15621577
// Fall through
15631578
case 'ML-KEM-768':
15641579
// Fall through
15651580
case 'ML-KEM-1024':
15661581
if (key.type === 'private') {
1567-
return bufferLikeToArrayBuffer(
1582+
const ab = bufferLikeToArrayBuffer(
15681583
key.keyObject.handle.exportKey(KFormatType.DER, KeyEncoding.PKCS8),
15691584
);
1585+
// 22 bytes of PKCS#8 ASN.1 + seed (32 ML-DSA, 64 ML-KEM). Guards
1586+
// against a seedless KeyObject that was wrapped via toCryptoKey.
1587+
const expected = key.algorithm.name.startsWith('ML-DSA') ? 54 : 86;
1588+
if (ab.byteLength !== expected) {
1589+
throw lazyDOMException(
1590+
'The operation failed for an operation-specific reason',
1591+
'OperationError',
1592+
);
1593+
}
1594+
return ab;
15701595
}
15711596
break;
15721597
}

0 commit comments

Comments
 (0)