diff --git a/example/src/tests/subtle/import_export.ts b/example/src/tests/subtle/import_export.ts index e89d013f..4ec61af3 100644 --- a/example/src/tests/subtle/import_export.ts +++ b/example/src/tests/subtle/import_export.ts @@ -28,6 +28,7 @@ import { // createPublicKey, // TODO: for 'bad usages' test // createPrivateKey, // TODO: for 'bad usages' test getRandomValues, + PQC_SEEDLESS_PKCS8_LENGTHS, subtle, } from 'react-native-quick-crypto'; import { MLKEM_VARIANTS } from './mlkem_constants'; @@ -2283,6 +2284,68 @@ test(SUITE, 'ML-DSA-44 importKey rejects jwk alg mismatch', async () => { ); }); +// --- ML-DSA seedless PKCS#8 import rejection (PR #997) --- + +for (const variant of MLDSA_VARIANTS) { + test(SUITE, `${variant} pkcs8 import rejects seedless key`, async () => { + const seedless = new Uint8Array(PQC_SEEDLESS_PKCS8_LENGTHS[variant] ?? 0); + await assertThrowsAsync( + async () => + await subtle.importKey('pkcs8', seedless, { name: variant }, true, [ + 'sign', + ]), + 'NotSupportedError', + ); + }); + + test( + SUITE, + `${variant} pkcs8 export produces 54-byte seed-only encoding`, + async () => { + const keyPair = (await subtle.generateKey({ name: variant }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + const exported = (await subtle.exportKey( + 'pkcs8', + keyPair.privateKey as CryptoKey, + )) as ArrayBuffer; + expect(exported.byteLength).to.equal(54); + }, + ); +} + +test(SUITE, 'ML-DSA-65 pkcs8 round-trip + sign/verify', async () => { + const keyPair = (await subtle.generateKey({ name: 'ML-DSA-65' }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + + const exported = (await subtle.exportKey( + 'pkcs8', + keyPair.privateKey as CryptoKey, + )) as ArrayBuffer; + expect(exported.byteLength).to.equal(54); + + const imported = await subtle.importKey( + 'pkcs8', + exported, + { name: 'ML-DSA-65' }, + true, + ['sign'], + ); + + const message = new TextEncoder().encode('round-trip test'); + const signature = await subtle.sign({ name: 'ML-DSA-65' }, imported, message); + const verified = await subtle.verify( + { name: 'ML-DSA-65' }, + keyPair.publicKey as CryptoKey, + signature, + message, + ); + expect(verified).to.equal(true); +}); + // --- ML-DSA raw-public and raw-seed export/import tests --- // 'raw-public' is normalized to 'raw' internally (public key bytes) // 'raw-seed' passes through directly (64-byte private seed) @@ -2684,6 +2747,69 @@ test(SUITE, 'ML-KEM-768 importKey raw rejects bad usages', async () => { ); }); +// --- ML-KEM seedless PKCS#8 import rejection + export length (PR #997) --- + +for (const variant of MLKEM_VARIANTS) { + test(SUITE, `${variant} pkcs8 import rejects seedless key`, async () => { + const seedless = new Uint8Array(PQC_SEEDLESS_PKCS8_LENGTHS[variant] ?? 0); + await assertThrowsAsync( + async () => + await subtle.importKey('pkcs8', seedless, { name: variant }, true, [ + 'decapsulateBits', + ]), + 'NotSupportedError', + ); + }); + + test( + SUITE, + `${variant} pkcs8 export produces 86-byte seed-only encoding`, + async () => { + const keyPair = (await subtle.generateKey({ name: variant }, true, [ + 'encapsulateBits', + 'decapsulateBits', + ])) as CryptoKeyPair; + const exported = (await subtle.exportKey( + 'pkcs8', + keyPair.privateKey as CryptoKey, + )) as ArrayBuffer; + expect(exported.byteLength).to.equal(86); + }, + ); +} + +test(SUITE, 'ML-KEM-768 pkcs8 round-trip + decapsulate', async () => { + const keyPair = (await subtle.generateKey({ name: 'ML-KEM-768' }, true, [ + 'encapsulateBits', + 'decapsulateBits', + ])) as CryptoKeyPair; + + const exported = (await subtle.exportKey( + 'pkcs8', + keyPair.privateKey as CryptoKey, + )) as ArrayBuffer; + expect(exported.byteLength).to.equal(86); + + const imported = await subtle.importKey( + 'pkcs8', + exported, + { name: 'ML-KEM-768' }, + true, + ['decapsulateBits'], + ); + + const { sharedKey, ciphertext } = await subtle.encapsulateBits( + { name: 'ML-KEM-768' }, + keyPair.publicKey as CryptoKey, + ); + const recovered = await subtle.decapsulateBits( + { name: 'ML-KEM-768' }, + imported, + ciphertext, + ); + expect(new Uint8Array(recovered)).to.deep.equal(new Uint8Array(sharedKey)); +}); + // --- Ed25519/Ed448 raw import/export Tests --- const edCurves = [ diff --git a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp index 71a8ff4d..98095418 100644 --- a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp +++ b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp @@ -1,4 +1,5 @@ #include +#include #include #include "../utils/base64.h" @@ -10,10 +11,41 @@ #include #include #include +#include #include namespace margelo::nitro::crypto { +#if OPENSSL_VERSION_NUMBER >= 0x30600000L +// Configure loaded providers to prefer seed-only PKCS#8 output for ML-DSA / +// ML-KEM, falling back to priv-only when no seed is available. Without this, +// OpenSSL defaults to "seed-priv" — a longer encoding that bundles both — +// which breaks interop with Node and the exact-length export check in subtle.ts. +// Mirrors src/crypto/crypto_util.cc in Node. +static void configurePqcOutputFormats() { + static std::once_flag once; + std::call_once(once, []() { + OSSL_PROVIDER_do_all( + nullptr, + [](OSSL_PROVIDER* provider, void*) -> int { + OSSL_PROVIDER_add_conf_parameter(provider, "ml-kem.output_formats", "seed-only,priv-only"); + OSSL_PROVIDER_add_conf_parameter(provider, "ml-dsa.output_formats", "seed-only,priv-only"); + return 1; + }, + nullptr); + }); +} +#endif + +HybridKeyObjectHandle::HybridKeyObjectHandle() : HybridObject(TAG) { +#if OPENSSL_VERSION_NUMBER >= 0x30600000L + // Configure once on first handle construction. Providers are guaranteed + // loaded by this point (any prior crypto op routed through ncrypto), and + // the call_once flag makes subsequent constructions cheap. + configurePqcOutputFormats(); +#endif +} + // Helper functions for base64url encoding/decoding with BIGNUMs static std::string bn_to_base64url(const BIGNUM* bn, size_t expected_size = 0) { if (!bn) diff --git a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.hpp b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.hpp index c45c860f..b2eafc6d 100644 --- a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.hpp +++ b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.hpp @@ -15,7 +15,7 @@ namespace margelo::nitro::crypto { class HybridKeyObjectHandle : public HybridKeyObjectHandleSpec { public: - HybridKeyObjectHandle() : HybridObject(TAG) {} + HybridKeyObjectHandle(); public: std::shared_ptr exportKey(std::optional format, std::optional type, diff --git a/packages/react-native-quick-crypto/src/subtle.ts b/packages/react-native-quick-crypto/src/subtle.ts index b158fe7d..aeb5888b 100644 --- a/packages/react-native-quick-crypto/src/subtle.ts +++ b/packages/react-native-quick-crypto/src/subtle.ts @@ -1278,6 +1278,31 @@ function edImportKey( return new CryptoKey(keyObject, { name }, keyUsages, extractable); } +// Lengths (in bytes) of seedless ML-DSA / ML-KEM PKCS#8 encodings. A PKCS#8 +// blob of exactly this length contains only the expanded private key with no +// seed; Node rejects these to keep cross-implementation interop intact. +// Refs: node lib/internal/crypto/ml_dsa.js (mlDsaImportKey, pkcs8 case) +// node lib/internal/crypto/ml_kem.js (mlKemImportKey, pkcs8 case) +export const PQC_SEEDLESS_PKCS8_LENGTHS: Readonly> = { + 'ML-DSA-44': 2588, + 'ML-DSA-65': 4060, + 'ML-DSA-87': 4924, + 'ML-KEM-512': 1660, + 'ML-KEM-768': 2428, + 'ML-KEM-1024': 3196, +}; + +// Map from PQC algorithm name to display family. Used to render the +// import-rejection error message in the same form Node emits. +const PQC_FAMILY: Readonly> = { + 'ML-DSA-44': 'ML-DSA', + 'ML-DSA-65': 'ML-DSA', + 'ML-DSA-87': 'ML-DSA', + 'ML-KEM-512': 'ML-KEM', + 'ML-KEM-768': 'ML-KEM', + 'ML-KEM-1024': 'ML-KEM', +}; + function pqcImportKeyObject( format: ImportFormat, data: BufferLike | JWK, @@ -1294,10 +1319,21 @@ function pqcImportKeyObject( isPublic: true, }; } else if (format === 'pkcs8') { + const ab = bufferLikeToArrayBuffer(data as BufferLike); + const family = PQC_FAMILY[name]; + if ( + family !== undefined && + ab.byteLength === PQC_SEEDLESS_PKCS8_LENGTHS[name] + ) { + throw lazyDOMException( + `Importing an ${family} PKCS#8 key without a seed is not supported`, + 'NotSupportedError', + ); + } return { keyObject: KeyObject.createKeyObject( 'private', - bufferLikeToArrayBuffer(data as BufferLike), + ab, KFormatType.DER, KeyEncoding.PKCS8, ), @@ -1551,22 +1587,26 @@ const exportKeyPkcs8 = async ( case 'ML-DSA-65': // Fall through case 'ML-DSA-87': - if (key.type === 'private') { - // Export ML-DSA key in PKCS8 DER format - return bufferLikeToArrayBuffer( - key.keyObject.handle.exportKey(KFormatType.DER, KeyEncoding.PKCS8), - ); - } - break; + // Fall through case 'ML-KEM-512': // Fall through case 'ML-KEM-768': // Fall through case 'ML-KEM-1024': if (key.type === 'private') { - return bufferLikeToArrayBuffer( + const ab = bufferLikeToArrayBuffer( key.keyObject.handle.exportKey(KFormatType.DER, KeyEncoding.PKCS8), ); + // 22 bytes of PKCS#8 ASN.1 + seed (32 ML-DSA, 64 ML-KEM). Guards + // against a seedless KeyObject that was wrapped via toCryptoKey. + const expected = key.algorithm.name.startsWith('ML-DSA') ? 54 : 86; + if (ab.byteLength !== expected) { + throw lazyDOMException( + 'The operation failed for an operation-specific reason', + 'OperationError', + ); + } + return ab; } break; }