Skip to content

Commit 849029a

Browse files
authored
feat: PQC PKCS#8 seed validation for ML-DSA and ML-KEM (#1017)
1 parent e33f183 commit 849029a

4 files changed

Lines changed: 208 additions & 10 deletions

File tree

example/src/tests/subtle/import_export.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
// createPublicKey, // TODO: for 'bad usages' test
2929
// createPrivateKey, // TODO: for 'bad usages' test
3030
getRandomValues,
31+
PQC_SEEDLESS_PKCS8_LENGTHS,
3132
subtle,
3233
} from 'react-native-quick-crypto';
3334
import { MLKEM_VARIANTS } from './mlkem_constants';
@@ -2283,6 +2284,68 @@ test(SUITE, 'ML-DSA-44 importKey rejects jwk alg mismatch', async () => {
22832284
);
22842285
});
22852286

2287+
// --- ML-DSA seedless PKCS#8 import rejection (PR #997) ---
2288+
2289+
for (const variant of MLDSA_VARIANTS) {
2290+
test(SUITE, `${variant} pkcs8 import rejects seedless key`, async () => {
2291+
const seedless = new Uint8Array(PQC_SEEDLESS_PKCS8_LENGTHS[variant] ?? 0);
2292+
await assertThrowsAsync(
2293+
async () =>
2294+
await subtle.importKey('pkcs8', seedless, { name: variant }, true, [
2295+
'sign',
2296+
]),
2297+
'NotSupportedError',
2298+
);
2299+
});
2300+
2301+
test(
2302+
SUITE,
2303+
`${variant} pkcs8 export produces 54-byte seed-only encoding`,
2304+
async () => {
2305+
const keyPair = (await subtle.generateKey({ name: variant }, true, [
2306+
'sign',
2307+
'verify',
2308+
])) as CryptoKeyPair;
2309+
const exported = (await subtle.exportKey(
2310+
'pkcs8',
2311+
keyPair.privateKey as CryptoKey,
2312+
)) as ArrayBuffer;
2313+
expect(exported.byteLength).to.equal(54);
2314+
},
2315+
);
2316+
}
2317+
2318+
test(SUITE, 'ML-DSA-65 pkcs8 round-trip + sign/verify', async () => {
2319+
const keyPair = (await subtle.generateKey({ name: 'ML-DSA-65' }, true, [
2320+
'sign',
2321+
'verify',
2322+
])) as CryptoKeyPair;
2323+
2324+
const exported = (await subtle.exportKey(
2325+
'pkcs8',
2326+
keyPair.privateKey as CryptoKey,
2327+
)) as ArrayBuffer;
2328+
expect(exported.byteLength).to.equal(54);
2329+
2330+
const imported = await subtle.importKey(
2331+
'pkcs8',
2332+
exported,
2333+
{ name: 'ML-DSA-65' },
2334+
true,
2335+
['sign'],
2336+
);
2337+
2338+
const message = new TextEncoder().encode('round-trip test');
2339+
const signature = await subtle.sign({ name: 'ML-DSA-65' }, imported, message);
2340+
const verified = await subtle.verify(
2341+
{ name: 'ML-DSA-65' },
2342+
keyPair.publicKey as CryptoKey,
2343+
signature,
2344+
message,
2345+
);
2346+
expect(verified).to.equal(true);
2347+
});
2348+
22862349
// --- ML-DSA raw-public and raw-seed export/import tests ---
22872350
// 'raw-public' is normalized to 'raw' internally (public key bytes)
22882351
// 'raw-seed' passes through directly (64-byte private seed)
@@ -2684,6 +2747,69 @@ test(SUITE, 'ML-KEM-768 importKey raw rejects bad usages', async () => {
26842747
);
26852748
});
26862749

2750+
// --- ML-KEM seedless PKCS#8 import rejection + export length (PR #997) ---
2751+
2752+
for (const variant of MLKEM_VARIANTS) {
2753+
test(SUITE, `${variant} pkcs8 import rejects seedless key`, async () => {
2754+
const seedless = new Uint8Array(PQC_SEEDLESS_PKCS8_LENGTHS[variant] ?? 0);
2755+
await assertThrowsAsync(
2756+
async () =>
2757+
await subtle.importKey('pkcs8', seedless, { name: variant }, true, [
2758+
'decapsulateBits',
2759+
]),
2760+
'NotSupportedError',
2761+
);
2762+
});
2763+
2764+
test(
2765+
SUITE,
2766+
`${variant} pkcs8 export produces 86-byte seed-only encoding`,
2767+
async () => {
2768+
const keyPair = (await subtle.generateKey({ name: variant }, true, [
2769+
'encapsulateBits',
2770+
'decapsulateBits',
2771+
])) as CryptoKeyPair;
2772+
const exported = (await subtle.exportKey(
2773+
'pkcs8',
2774+
keyPair.privateKey as CryptoKey,
2775+
)) as ArrayBuffer;
2776+
expect(exported.byteLength).to.equal(86);
2777+
},
2778+
);
2779+
}
2780+
2781+
test(SUITE, 'ML-KEM-768 pkcs8 round-trip + decapsulate', async () => {
2782+
const keyPair = (await subtle.generateKey({ name: 'ML-KEM-768' }, true, [
2783+
'encapsulateBits',
2784+
'decapsulateBits',
2785+
])) as CryptoKeyPair;
2786+
2787+
const exported = (await subtle.exportKey(
2788+
'pkcs8',
2789+
keyPair.privateKey as CryptoKey,
2790+
)) as ArrayBuffer;
2791+
expect(exported.byteLength).to.equal(86);
2792+
2793+
const imported = await subtle.importKey(
2794+
'pkcs8',
2795+
exported,
2796+
{ name: 'ML-KEM-768' },
2797+
true,
2798+
['decapsulateBits'],
2799+
);
2800+
2801+
const { sharedKey, ciphertext } = await subtle.encapsulateBits(
2802+
{ name: 'ML-KEM-768' },
2803+
keyPair.publicKey as CryptoKey,
2804+
);
2805+
const recovered = await subtle.decapsulateBits(
2806+
{ name: 'ML-KEM-768' },
2807+
imported,
2808+
ciphertext,
2809+
);
2810+
expect(new Uint8Array(recovered)).to.deep.equal(new Uint8Array(sharedKey));
2811+
});
2812+
26872813
// --- Ed25519/Ed448 raw import/export Tests ---
26882814

26892815
const edCurves = [

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

Lines changed: 32 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,41 @@
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 the exact-length export check in subtle.ts.
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+
40+
HybridKeyObjectHandle::HybridKeyObjectHandle() : HybridObject(TAG) {
41+
#if OPENSSL_VERSION_NUMBER >= 0x30600000L
42+
// Configure once on first handle construction. Providers are guaranteed
43+
// loaded by this point (any prior crypto op routed through ncrypto), and
44+
// the call_once flag makes subsequent constructions cheap.
45+
configurePqcOutputFormats();
46+
#endif
47+
}
48+
1749
// Helper functions for base64url encoding/decoding with BIGNUMs
1850
static std::string bn_to_base64url(const BIGNUM* bn, size_t expected_size = 0) {
1951
if (!bn)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespace margelo::nitro::crypto {
1515

1616
class HybridKeyObjectHandle : public HybridKeyObjectHandleSpec {
1717
public:
18-
HybridKeyObjectHandle() : HybridObject(TAG) {}
18+
HybridKeyObjectHandle();
1919

2020
public:
2121
std::shared_ptr<ArrayBuffer> exportKey(std::optional<KFormatType> format, std::optional<KeyEncoding> type,

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

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,31 @@ 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: node lib/internal/crypto/ml_dsa.js (mlDsaImportKey, pkcs8 case)
1285+
// node lib/internal/crypto/ml_kem.js (mlKemImportKey, pkcs8 case)
1286+
export const PQC_SEEDLESS_PKCS8_LENGTHS: Readonly<Record<string, number>> = {
1287+
'ML-DSA-44': 2588,
1288+
'ML-DSA-65': 4060,
1289+
'ML-DSA-87': 4924,
1290+
'ML-KEM-512': 1660,
1291+
'ML-KEM-768': 2428,
1292+
'ML-KEM-1024': 3196,
1293+
};
1294+
1295+
// Map from PQC algorithm name to display family. Used to render the
1296+
// import-rejection error message in the same form Node emits.
1297+
const PQC_FAMILY: Readonly<Record<string, 'ML-DSA' | 'ML-KEM'>> = {
1298+
'ML-DSA-44': 'ML-DSA',
1299+
'ML-DSA-65': 'ML-DSA',
1300+
'ML-DSA-87': 'ML-DSA',
1301+
'ML-KEM-512': 'ML-KEM',
1302+
'ML-KEM-768': 'ML-KEM',
1303+
'ML-KEM-1024': 'ML-KEM',
1304+
};
1305+
12811306
function pqcImportKeyObject(
12821307
format: ImportFormat,
12831308
data: BufferLike | JWK,
@@ -1294,10 +1319,21 @@ function pqcImportKeyObject(
12941319
isPublic: true,
12951320
};
12961321
} else if (format === 'pkcs8') {
1322+
const ab = bufferLikeToArrayBuffer(data as BufferLike);
1323+
const family = PQC_FAMILY[name];
1324+
if (
1325+
family !== undefined &&
1326+
ab.byteLength === PQC_SEEDLESS_PKCS8_LENGTHS[name]
1327+
) {
1328+
throw lazyDOMException(
1329+
`Importing an ${family} PKCS#8 key without a seed is not supported`,
1330+
'NotSupportedError',
1331+
);
1332+
}
12971333
return {
12981334
keyObject: KeyObject.createKeyObject(
12991335
'private',
1300-
bufferLikeToArrayBuffer(data as BufferLike),
1336+
ab,
13011337
KFormatType.DER,
13021338
KeyEncoding.PKCS8,
13031339
),
@@ -1551,22 +1587,26 @@ const exportKeyPkcs8 = async (
15511587
case 'ML-DSA-65':
15521588
// Fall through
15531589
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;
1590+
// Fall through
15611591
case 'ML-KEM-512':
15621592
// Fall through
15631593
case 'ML-KEM-768':
15641594
// Fall through
15651595
case 'ML-KEM-1024':
15661596
if (key.type === 'private') {
1567-
return bufferLikeToArrayBuffer(
1597+
const ab = bufferLikeToArrayBuffer(
15681598
key.keyObject.handle.exportKey(KFormatType.DER, KeyEncoding.PKCS8),
15691599
);
1600+
// 22 bytes of PKCS#8 ASN.1 + seed (32 ML-DSA, 64 ML-KEM). Guards
1601+
// against a seedless KeyObject that was wrapped via toCryptoKey.
1602+
const expected = key.algorithm.name.startsWith('ML-DSA') ? 54 : 86;
1603+
if (ab.byteLength !== expected) {
1604+
throw lazyDOMException(
1605+
'The operation failed for an operation-specific reason',
1606+
'OperationError',
1607+
);
1608+
}
1609+
return ab;
15701610
}
15711611
break;
15721612
}

0 commit comments

Comments
 (0)