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
126 changes: 126 additions & 0 deletions example/src/tests/subtle/import_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include <cstdio>
#include <mutex>
#include <stdexcept>

#include "../utils/base64.h"
Expand All @@ -10,10 +11,41 @@
#include <openssl/ec.h>
#include <openssl/evp.h>
#include <openssl/obj_mac.h>
#include <openssl/provider.h>
#include <openssl/rsa.h>

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace margelo::nitro::crypto {

class HybridKeyObjectHandle : public HybridKeyObjectHandleSpec {
public:
HybridKeyObjectHandle() : HybridObject(TAG) {}
HybridKeyObjectHandle();

public:
std::shared_ptr<ArrayBuffer> exportKey(std::optional<KFormatType> format, std::optional<KeyEncoding> type,
Expand Down
58 changes: 49 additions & 9 deletions packages/react-native-quick-crypto/src/subtle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, number>> = {
'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<Record<string, 'ML-DSA' | 'ML-KEM'>> = {
'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,
Expand All @@ -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,
),
Expand Down Expand Up @@ -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;
}
Expand Down
Loading