From 54a0a53d81aaf40209365f12415d0570f0cd6fb1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:05:25 +0000 Subject: [PATCH 01/10] crypto: accept the RFC 9881/9935 "both" PKCS#8 form for ML-DSA/ML-KEM private keys BoringSSL's EVP decoder only parses the `seed [0]` arm of the ML-DSA / ML-KEM private-key CHOICE, rejecting the `both` SEQUENCE (which is OpenSSL 3.5's default `genpkey` output) with EVP_R_PRIVATE_KEY_WAS_NOT_SEED. That made both `crypto.createPrivateKey` and `subtle.importKey("pkcs8")` refuse keys that Node v26 accepts. When BoringSSL reports that error, re-parse the PKCS#8 with CBS, match the ML-DSA/ML-KEM OID, extract the seed from the inner `SEQUENCE { seed, expandedKey }`, and build the key via `EVP_PKEY_from_private_seed`. The seed-derived public key is checked against the matching portion of the expanded key (rho for ML-DSA, the embedded ek for ML-KEM) so inconsistent pairs are still rejected, which keeps the upstream `testImportPkcs8MismatchedSeed` case passing. The `expandedKey`-only arm (no seed) remains unsupported: BoringSSL has no EVP-level representation for a seedless ML-DSA/ML-KEM private key. --- src/jsc/bindings/ncrypto.cpp | 119 +++++++++++++++++++- src/jsc/bindings/ncrypto.h | 9 ++ src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp | 19 +++- test/js/node/crypto/crypto-pqc.test.ts | 106 ++++++++++++++++- 4 files changed, 250 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index aeb428c7cdf8..59476747e418 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -6,9 +6,11 @@ #include "ncrypto.h" #include #include +#include #include #include #include +#include #include #include #include @@ -2541,6 +2543,96 @@ Buffer GetPassphrase( } } // namespace +EVPKeyPointer EVPKeyPointer::TryParsePqcBothFormPkcs8( + const Buffer& der) +{ + // Seed / expanded-key sizes per FIPS 204 (ML-DSA) and FIPS 203 (ML-KEM), + // plus where the expanded key overlaps the public key so we can verify the + // two halves of the "both" form agree. For ML-DSA that is the 32-byte rho + // at the start of both encodings; for ML-KEM the expanded dk embeds the + // full encapsulation key ek at offset 384*k. + struct Params { + int nid; + const EVP_PKEY_ALG* (*alg)(); + size_t seedLen; + size_t expandedLen; + size_t pubInExpandedOffset; + size_t pubCompareLen; + }; + static constexpr Params pqcAlgs[] = { + { NID_ML_DSA_44, EVP_pkey_ml_dsa_44, 32, 2560, 0, 32 }, + { NID_ML_DSA_65, EVP_pkey_ml_dsa_65, 32, 4032, 0, 32 }, + { NID_ML_DSA_87, EVP_pkey_ml_dsa_87, 32, 4896, 0, 32 }, + { NID_ML_KEM_768, EVP_pkey_ml_kem_768, 64, 2400, 1152, 1184 }, + { NID_ML_KEM_1024, EVP_pkey_ml_kem_1024, 64, 3168, 1536, 1568 }, + }; + + CBS cbs, pkcs8, algorithm, oid, privateKey; + uint64_t version; + CBS_init(&cbs, der.data, der.len); + if (!CBS_get_asn1(&cbs, &pkcs8, CBS_ASN1_SEQUENCE) || CBS_len(&cbs) != 0 + || !CBS_get_asn1_uint64(&pkcs8, &version) || version != 0 + || !CBS_get_asn1(&pkcs8, &algorithm, CBS_ASN1_SEQUENCE) + || !CBS_get_asn1(&algorithm, &oid, CBS_ASN1_OBJECT) + || CBS_len(&algorithm) != 0 + || !CBS_get_asn1(&pkcs8, &privateKey, CBS_ASN1_OCTETSTRING)) { + return {}; + } + + const Params* params = nullptr; + int nid = OBJ_cbs2nid(&oid); + for (const auto& candidate : pqcAlgs) { + if (candidate.nid == nid) { + params = &candidate; + break; + } + } + if (!params) return {}; + + // The "both" arm is a SEQUENCE of two OCTET STRINGs: seed then + // expandedKey. BoringSSL only needs the seed; it re-derives the rest. + CBS both, seed, expanded; + if (!CBS_get_asn1(&privateKey, &both, CBS_ASN1_SEQUENCE) + || CBS_len(&privateKey) != 0 + || !CBS_get_asn1(&both, &seed, CBS_ASN1_OCTETSTRING) + || !CBS_get_asn1(&both, &expanded, CBS_ASN1_OCTETSTRING) + || CBS_len(&both) != 0 + || CBS_len(&seed) != params->seedLen + || CBS_len(&expanded) != params->expandedLen) { + return {}; + } + + MarkPopErrorOnReturn markPop; + EVPKeyPointer key(EVP_PKEY_from_private_seed(params->alg(), CBS_data(&seed), CBS_len(&seed))); + if (!key) return {}; + + // Reject a seed that does not re-derive the expanded key, matching + // OpenSSL's consistency check for this form. + size_t pubLen = 0; + if (!EVP_PKEY_get_raw_public_key(key.get(), nullptr, &pubLen) + || pubLen < params->pubCompareLen) { + return {}; + } + auto pub = DataPointer::Alloc(pubLen); + if (!pub + || !EVP_PKEY_get_raw_public_key(key.get(), static_cast(pub.get()), &pubLen) + || CRYPTO_memcmp(pub.get(), + CBS_data(&expanded) + params->pubInExpandedOffset, + params->pubCompareLen) + != 0) { + return {}; + } + + return key; +} + +namespace { +bool isPrivateKeyWasNotSeedError(int err) +{ + return ERR_GET_LIB(err) == ERR_LIB_EVP && ERR_GET_REASON(err) == EVP_R_PRIVATE_KEY_WAS_NOT_SEED; +} +} // namespace + EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryParsePrivateKey( const PrivateKeyEncodingConfig& config, const Buffer& buffer) @@ -2568,6 +2660,24 @@ EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryParsePrivateKey( nullptr, PasswordCallback, config.passphrase.has_value() ? &passphrase : nullptr); + if (!key && isPrivateKeyWasNotSeedError(ERR_peek_error())) { + auto pemBio = BIOPointer::New(buffer); + uint8_t* der = nullptr; + long derLen = 0; + char* name = nullptr; + if (pemBio + && PEM_bytes_read_bio(&der, &derLen, &name, PEM_STRING_PKCS8INF, pemBio.get(), + PasswordCallback, config.passphrase.has_value() ? &passphrase : nullptr)) { + OPENSSL_free(name); + Buffer derBuf { .data = der, .len = static_cast(derLen) }; + auto recovered = TryParsePqcBothFormPkcs8(derBuf); + OPENSSL_free(der); + if (recovered) { + ERR_clear_error(); + return ParseKeyResult(WTF::move(recovered)); + } + } + } return keyOrError(EVPKeyPointer(key), config.passphrase.has_value()); } @@ -2594,7 +2704,14 @@ EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryParsePrivateKey( if (!p8inf) { return ParseKeyResult(PKParseError::FAILED, ERR_peek_error()); } - return keyOrError(EVPKeyPointer(EVP_PKCS82PKEY(p8inf.get()))); + EVPKeyPointer key(EVP_PKCS82PKEY(p8inf.get())); + if (!key && isPrivateKeyWasNotSeedError(ERR_peek_error())) { + if (auto recovered = TryParsePqcBothFormPkcs8(buffer)) { + ERR_clear_error(); + return ParseKeyResult(WTF::move(recovered)); + } + } + return keyOrError(WTF::move(key)); } case PKEncodingType::SEC1: { auto key = d2i_PrivateKey_bio(bio.get(), nullptr); diff --git a/src/jsc/bindings/ncrypto.h b/src/jsc/bindings/ncrypto.h index e8f51a20391f..3038d508dc7d 100644 --- a/src/jsc/bindings/ncrypto.h +++ b/src/jsc/bindings/ncrypto.h @@ -927,6 +927,15 @@ class EVPKeyPointer final { const PrivateKeyEncodingConfig& config, const Buffer& buffer); + // ML-DSA / ML-KEM PKCS#8 private keys encode a CHOICE of {seed [0], + // expandedKey, both SEQUENCE{seed, expandedKey}} (RFC 9881 / 9935). + // BoringSSL's EVP decoder only accepts the seed arm. When the inner + // privateKey is the "both" form, extract the seed and build the key from + // it so OpenSSL-3.5-default keys load. Returns nullptr for any other + // structure, including the seed-less expandedKey arm. + static EVPKeyPointer TryParsePqcBothFormPkcs8( + const Buffer& der); + EVPKeyPointer() = default; explicit EVPKeyPointer(EVP_PKEY* pkey); EVPKeyPointer(EVPKeyPointer&& other) noexcept; diff --git a/src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp b/src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp index 7ec1815cb16f..5d3db228adfd 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp +++ b/src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp @@ -33,9 +33,11 @@ #include "CryptoAlgorithmRegistry.h" #include "CryptoKeyPair.h" #include "JsonWebKey.h" +#include "ncrypto.h" #include #include #include +#include #include #include @@ -194,8 +196,23 @@ RefPtr CryptoKeyAKP::importPkcs8(CryptoAlgorithmIdentifier identif CBS cbs; CBS_init(&cbs, keyData.begin(), keyData.size()); EvpPKeyPtr key(EVP_parse_private_key(&cbs)); - if (!key || CBS_len(&cbs) != 0) + if (!key) { + // BoringSSL's decoder only accepts the `seed [0]` arm of the RFC 9881 + // / 9935 private-key CHOICE. The `both` SEQUENCE (OpenSSL 3.5's + // default output) carries a seed too; recover it so those keys load. + int err = ERR_peek_last_error(); + if (ERR_GET_LIB(err) == ERR_LIB_EVP && ERR_GET_REASON(err) == EVP_R_PRIVATE_KEY_WAS_NOT_SEED) { + ncrypto::Buffer der { .data = keyData.begin(), .len = keyData.size() }; + if (auto recovered = ncrypto::EVPKeyPointer::TryParsePqcBothFormPkcs8(der)) { + ERR_clear_error(); + key.reset(recovered.release()); + } + } + if (!key) + return nullptr; + } else if (CBS_len(&cbs) != 0) { return nullptr; + } if (EVP_PKEY_id(key.get()) != nidForIdentifier(identifier)) { if (wrongKeyType) *wrongKeyType = true; diff --git a/test/js/node/crypto/crypto-pqc.test.ts b/test/js/node/crypto/crypto-pqc.test.ts index de76c2d9ac22..4f99c7121e94 100644 --- a/test/js/node/crypto/crypto-pqc.test.ts +++ b/test/js/node/crypto/crypto-pqc.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "crypto"; +import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, subtle, verify } from "crypto"; import fs from "fs"; import { bunEnv, bunExe } from "harness"; import path from "path"; @@ -11,6 +11,11 @@ function fixture(name: string) { return fs.readFileSync(path.join(keysDir, name)); } +function fixtureDer(name: string) { + const pem = fixture(name).toString("ascii"); + return Buffer.from(pem.replace(/-----(BEGIN|END) PRIVATE KEY-----|\s/g, ""), "base64"); +} + // ML-DSA signature sizes and public-key sizes per FIPS 204. const mlDsa = [ { type: "ml-dsa-44", pubLen: 1312, sigLen: 2420 }, @@ -93,6 +98,105 @@ describe("ML-KEM", () => { }); }); +// ML-DSA / ML-KEM private keys encode a CHOICE of three forms (RFC 9881 / +// 9935): `seed [0]`, `expandedKey`, or `both SEQUENCE{seed, expandedKey}`. +// OpenSSL 3.5's default `genpkey` output is the `both` form. BoringSSL only +// natively parses `seed [0]`, so Bun extracts the seed from `both` itself. +describe("PKCS#8 private-key CHOICE forms", () => { + const mlDsaUsages: KeyUsage[] = ["sign"]; + const mlKemUsages: KeyUsage[] = ["decapsulateBits", "decapsulateKey"]; + + describe.each([ + ["ml-dsa-44", "ML-DSA-44", mlDsaUsages], + ["ml-dsa-65", "ML-DSA-65", mlDsaUsages], + ["ml-dsa-87", "ML-DSA-87", mlDsaUsages], + ["ml-kem-768", "ML-KEM-768", mlKemUsages], + ["ml-kem-1024", "ML-KEM-1024", mlKemUsages], + ] as const)("%s", (type, algName, usages) => { + const stem = type.replaceAll("-", "_"); + const seedOnly = `${stem}_private_seed_only.pem`; + const both = `${stem}_private.pem`; + const expandedOnly = `${stem}_private_priv_only.pem`; + + test("createPrivateKey accepts the `both` form (PEM and DER)", () => { + const reference = createPrivateKey(fixture(seedOnly)); + const fromPem = createPrivateKey(fixture(both)); + const fromDer = createPrivateKey({ key: fixtureDer(both), format: "der", type: "pkcs8" }); + expect({ + pemType: fromPem.asymmetricKeyType, + derType: fromDer.asymmetricKeyType, + pemEqualsSeedOnly: fromPem.equals(reference), + derEqualsSeedOnly: fromDer.equals(reference), + exportedPem: fromPem.export({ format: "pem", type: "pkcs8" }), + }).toEqual({ + pemType: type, + derType: type, + pemEqualsSeedOnly: true, + derEqualsSeedOnly: true, + exportedPem: fixture(seedOnly).toString("ascii"), + }); + }); + + test("createPublicKey from the `both` private PEM derives the right public key", () => { + const pub = createPublicKey(fixture(both)); + const reference = createPublicKey(fixture(`${stem}_public.pem`)); + expect(pub.equals(reference)).toBe(true); + }); + + test("subtle.importKey accepts the `both` form", async () => { + const key = await subtle.importKey("pkcs8", fixtureDer(both), { name: algName }, true, usages); + const exported = Buffer.from(await subtle.exportKey("pkcs8", key)); + expect({ + type: key.type, + name: key.algorithm.name, + exportedMatchesSeedOnly: exported.equals(fixtureDer(seedOnly)), + }).toEqual({ type: "private", name: algName, exportedMatchesSeedOnly: true }); + }); + + test("subtle.importKey of the `both` form under a different parameter set is rejected as wrong key type", async () => { + const other = type === "ml-dsa-44" ? "ML-DSA-65" : "ML-DSA-44"; + const otherUsages: KeyUsage[] = ["sign"]; + await expect(subtle.importKey("pkcs8", fixtureDer(both), { name: other }, true, otherUsages)).rejects.toThrow( + "Invalid key type", + ); + }); + + test("`both` form with a seed that does not match the expanded key is rejected", async () => { + // Byte 30 of the PKCS#8 is the first byte of the seed OCTET STRING + // contents for every fixture here (13-byte AlgorithmIdentifier, 4-byte + // OCTET STRING and SEQUENCE headers, 2-byte seed OCTET STRING header). + const modified = Buffer.from(fixtureDer(both)); + modified[30] ^= 0xff; + await expect(subtle.importKey("pkcs8", modified, { name: algName }, true, usages)).rejects.toThrow( + expect.objectContaining({ name: "DataError" }), + ); + expect(() => createPrivateKey({ key: modified, format: "der", type: "pkcs8" })).toThrow( + expect.objectContaining({ code: "ERR_OSSL_EVP_PRIVATE_KEY_WAS_NOT_SEED" }), + ); + }); + + test("expandedKey-only form (no seed) is rejected", async () => { + expect(() => createPrivateKey(fixture(expandedOnly))).toThrow( + expect.objectContaining({ code: "ERR_OSSL_EVP_PRIVATE_KEY_WAS_NOT_SEED" }), + ); + expect(() => createPrivateKey({ key: fixtureDer(expandedOnly), format: "der", type: "pkcs8" })).toThrow( + expect.objectContaining({ code: "ERR_OSSL_EVP_PRIVATE_KEY_WAS_NOT_SEED" }), + ); + await expect(subtle.importKey("pkcs8", fixtureDer(expandedOnly), { name: algName }, true, usages)).rejects.toThrow( + /PKCS#8 key without a seed is not supported/, + ); + }); + }); + + test("ML-DSA: sign/verify works with a key imported from the `both` form", () => { + const priv = createPrivateKey(fixture("ml_dsa_44_private.pem")); + const pub = createPublicKey(fixture("ml_dsa_44_public.pem")); + const data = Buffer.from("hello bun"); + const sig = sign(undefined, data, priv); + expect(verify(undefined, data, pub, sig)).toBe(true); + }); +}); + describe("encrypted PKCS#8", () => { for (const [name, type] of [ ["ml_dsa_44_private_encrypted.pem", "ml-dsa-44"], From 7b388c58aa05b927ef0508b69bbc205531fa3424 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:07:52 +0000 Subject: [PATCH 02/10] [autofix.ci] apply automated fixes --- test/js/node/crypto/crypto-pqc.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/js/node/crypto/crypto-pqc.test.ts b/test/js/node/crypto/crypto-pqc.test.ts index 4f99c7121e94..f9b756286553 100644 --- a/test/js/node/crypto/crypto-pqc.test.ts +++ b/test/js/node/crypto/crypto-pqc.test.ts @@ -182,9 +182,9 @@ describe("PKCS#8 private-key CHOICE forms", () => { expect(() => createPrivateKey({ key: fixtureDer(expandedOnly), format: "der", type: "pkcs8" })).toThrow( expect.objectContaining({ code: "ERR_OSSL_EVP_PRIVATE_KEY_WAS_NOT_SEED" }), ); - await expect(subtle.importKey("pkcs8", fixtureDer(expandedOnly), { name: algName }, true, usages)).rejects.toThrow( - /PKCS#8 key without a seed is not supported/, - ); + await expect( + subtle.importKey("pkcs8", fixtureDer(expandedOnly), { name: algName }, true, usages), + ).rejects.toThrow(/PKCS#8 key without a seed is not supported/); }); }); From 5d73a114ff212a26b8dec6faae75d6ed895355ca Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:40:30 +0000 Subject: [PATCH 03/10] crypto: also accept the "both" PKCS#8 form inside encrypted PKCS#8 The previous commit covered unencrypted PKCS#8 (DER and PEM) and WebCrypto. An EncryptedPrivateKeyInfo wrapping a "both"-form inner key (as produced by `openssl genpkey -algorithm ML-DSA-65 | openssl pkcs8 -topk8`) still failed: `d2i_PKCS8PrivateKey_bio` and `PEM_read_bio_PrivateKey` decrypt and then hit the same `EVP_R_PRIVATE_KEY_WAS_NOT_SEED` on the plaintext. BoringSSL has no public API that stops at the plaintext PrivateKeyInfo bytes; both `PKCS8_decrypt` and `PKCS8_parse_encrypted_private_key` route the result through `EVP_parse_private_key`. The internal `bssl::pkcs8_pbe_decrypt` does exactly that step and has external linkage in the static link, so forward-declare it and feed its output to the same "both"-form seed-extraction used for the unencrypted path. PEM recovery now reads whichever of PRIVATE KEY / ENCRYPTED PRIVATE KEY is present via `PEM_read_bio` and dispatches accordingly. Adds two `*_private_both_encrypted.pem` fixtures (the in-tree "both" fixtures wrapped with `openssl pkcs8 -topk8 -passout pass:password`) and tests for PEM and DER import plus missing/wrong passphrase behaviour. --- src/jsc/bindings/ncrypto.cpp | 66 +++++++++++++++++-- test/js/node/crypto/crypto-pqc.test.ts | 36 ++++++++++ .../keys/ml_dsa_44_private_both_encrypted.pem | 60 +++++++++++++++++ .../ml_kem_768_private_both_encrypted.pem | 57 ++++++++++++++++ 4 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 test/js/node/test/fixtures/keys/ml_dsa_44_private_both_encrypted.pem create mode 100644 test/js/node/test/fixtures/keys/ml_kem_768_private_both_encrypted.pem diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index 59476747e418..50638136ae58 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -2626,11 +2626,49 @@ EVPKeyPointer EVPKeyPointer::TryParsePqcBothFormPkcs8( return key; } +} // namespace ncrypto + +// BoringSSL has no public API that decrypts an EncryptedPrivateKeyInfo to the +// plaintext PrivateKeyInfo bytes without also routing them through +// EVP_parse_private_key (which is the call that rejects the "both" form). This +// internal helper does exactly the decrypt-to-bytes step; it has external +// linkage in the object file so we can reach it from the static link here. +namespace bssl { +int pkcs8_pbe_decrypt(uint8_t** out, size_t* out_len, CBS* algorithm, + const char* pass, size_t pass_len, const uint8_t* in, size_t in_len); +} + +namespace ncrypto { namespace { bool isPrivateKeyWasNotSeedError(int err) { return ERR_GET_LIB(err) == ERR_LIB_EVP && ERR_GET_REASON(err) == EVP_R_PRIVATE_KEY_WAS_NOT_SEED; } + +EVPKeyPointer tryRecoverPqcBothFormEncrypted( + const Buffer& der, const Buffer& pass) +{ + // EncryptedPrivateKeyInfo ::= SEQUENCE { algorithm, encryptedData } + CBS cbs, epki, algorithm, ciphertext; + CBS_init(&cbs, der.data, der.len); + if (!CBS_get_asn1(&cbs, &epki, CBS_ASN1_SEQUENCE) + || !CBS_get_asn1(&epki, &algorithm, CBS_ASN1_SEQUENCE) + || !CBS_get_asn1(&epki, &ciphertext, CBS_ASN1_OCTETSTRING) + || CBS_len(&epki) != 0) { + return {}; + } + + uint8_t* plain = nullptr; + size_t plainLen = 0; + if (!bssl::pkcs8_pbe_decrypt(&plain, &plainLen, &algorithm, pass.data, pass.len, + CBS_data(&ciphertext), CBS_len(&ciphertext))) { + return {}; + } + Buffer plainBuf { .data = plain, .len = plainLen }; + auto key = EVPKeyPointer::TryParsePqcBothFormPkcs8(plainBuf); + OPENSSL_free(plain); + return key; +} } // namespace EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryParsePrivateKey( @@ -2661,16 +2699,27 @@ EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryParsePrivateKey( PasswordCallback, config.passphrase.has_value() ? &passphrase : nullptr); if (!key && isPrivateKeyWasNotSeedError(ERR_peek_error())) { + // The PEM could have been either PRIVATE KEY (plaintext + // PrivateKeyInfo) or ENCRYPTED PRIVATE KEY (EncryptedPrivateKeyInfo + // wrapping one). Either way BoringSSL decrypted successfully and + // then rejected the inner encoding; re-read whichever block it was + // to recover the plaintext PrivateKeyInfo for the "both"-form + // retry. auto pemBio = BIOPointer::New(buffer); uint8_t* der = nullptr; long derLen = 0; char* name = nullptr; - if (pemBio - && PEM_bytes_read_bio(&der, &derLen, &name, PEM_STRING_PKCS8INF, pemBio.get(), - PasswordCallback, config.passphrase.has_value() ? &passphrase : nullptr)) { - OPENSSL_free(name); + char* header = nullptr; + if (pemBio && PEM_read_bio(pemBio.get(), &name, &header, &der, &derLen)) { Buffer derBuf { .data = der, .len = static_cast(derLen) }; - auto recovered = TryParsePqcBothFormPkcs8(derBuf); + EVPKeyPointer recovered; + if (strcmp(name, PEM_STRING_PKCS8INF) == 0) { + recovered = TryParsePqcBothFormPkcs8(derBuf); + } else if (strcmp(name, PEM_STRING_PKCS8) == 0 && config.passphrase.has_value()) { + recovered = tryRecoverPqcBothFormEncrypted(derBuf, passphrase); + } + OPENSSL_free(name); + OPENSSL_free(header); OPENSSL_free(der); if (recovered) { ERR_clear_error(); @@ -2697,6 +2746,13 @@ EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryParsePrivateKey( nullptr, PasswordCallback, config.passphrase.has_value() ? &passphrase : nullptr); + if (!key && isPrivateKeyWasNotSeedError(ERR_peek_error()) + && config.passphrase.has_value()) { + if (auto recovered = tryRecoverPqcBothFormEncrypted(buffer, passphrase)) { + ERR_clear_error(); + return ParseKeyResult(WTF::move(recovered)); + } + } return keyOrError(EVPKeyPointer(key), config.passphrase.has_value()); } diff --git a/test/js/node/crypto/crypto-pqc.test.ts b/test/js/node/crypto/crypto-pqc.test.ts index f9b756286553..176af7023d54 100644 --- a/test/js/node/crypto/crypto-pqc.test.ts +++ b/test/js/node/crypto/crypto-pqc.test.ts @@ -195,6 +195,42 @@ describe("PKCS#8 private-key CHOICE forms", () => { const sig = sign(undefined, data, priv); expect(verify(undefined, data, pub, sig)).toBe(true); }); + + describe.each([ + ["ml_dsa_44", "ml-dsa-44"], + ["ml_kem_768", "ml-kem-768"], + ] as const)("encrypted `both` form (%s)", (stem, type) => { + const encPem = fixture(`${stem}_private_both_encrypted.pem`); + const encDer = Buffer.from( + encPem.toString("ascii").replace(/-----(BEGIN|END) ENCRYPTED PRIVATE KEY-----|\s/g, ""), + "base64", + ); + const reference = createPrivateKey(fixture(`${stem}_private_seed_only.pem`)); + + test("createPrivateKey accepts an encrypted `both`-form key (PEM and DER)", () => { + const fromPem = createPrivateKey({ key: encPem, passphrase: "password" }); + const fromDer = createPrivateKey({ key: encDer, format: "der", type: "pkcs8", passphrase: "password" }); + expect({ + pemType: fromPem.asymmetricKeyType, + derType: fromDer.asymmetricKeyType, + pemEqualsSeedOnly: fromPem.equals(reference), + derEqualsSeedOnly: fromDer.equals(reference), + }).toEqual({ pemType: type, derType: type, pemEqualsSeedOnly: true, derEqualsSeedOnly: true }); + }); + + test("missing passphrase on an encrypted `both`-form key still reports ERR_MISSING_PASSPHRASE", () => { + expect(() => createPrivateKey(encPem)).toThrow(expect.objectContaining({ code: "ERR_MISSING_PASSPHRASE" })); + }); + + test("wrong passphrase on an encrypted `both`-form key still surfaces the decrypt error", () => { + expect(() => createPrivateKey({ key: encPem, passphrase: "wrong" })).toThrow( + expect.objectContaining({ code: expect.stringMatching(/^ERR_OSSL_/) }), + ); + expect(() => + createPrivateKey({ key: encDer, format: "der", type: "pkcs8", passphrase: "wrong" }), + ).toThrow(expect.objectContaining({ code: expect.stringMatching(/^ERR_OSSL_/) })); + }); + }); }); describe("encrypted PKCS#8", () => { diff --git a/test/js/node/test/fixtures/keys/ml_dsa_44_private_both_encrypted.pem b/test/js/node/test/fixtures/keys/ml_dsa_44_private_both_encrypted.pem new file mode 100644 index 000000000000..8ca490e7c8ce --- /dev/null +++ b/test/js/node/test/fixtures/keys/ml_dsa_44_private_both_encrypted.pem @@ -0,0 +1,60 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +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 +d0SIw93tHXDq +-----END ENCRYPTED PRIVATE KEY----- diff --git a/test/js/node/test/fixtures/keys/ml_kem_768_private_both_encrypted.pem b/test/js/node/test/fixtures/keys/ml_kem_768_private_both_encrypted.pem new file mode 100644 index 000000000000..5d17467b9946 --- /dev/null +++ b/test/js/node/test/fixtures/keys/ml_kem_768_private_both_encrypted.pem @@ -0,0 +1,57 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +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 +NJeCz5JlwOqizP9UEUmaG31lsA3CJ4UDlA== +-----END ENCRYPTED PRIVATE KEY----- From 11c767af4dde4fe363ac391377245a8a591d5404 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:42:42 +0000 Subject: [PATCH 04/10] [autofix.ci] apply automated fixes --- test/js/node/crypto/crypto-pqc.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/js/node/crypto/crypto-pqc.test.ts b/test/js/node/crypto/crypto-pqc.test.ts index 176af7023d54..6a95279ec400 100644 --- a/test/js/node/crypto/crypto-pqc.test.ts +++ b/test/js/node/crypto/crypto-pqc.test.ts @@ -226,9 +226,9 @@ describe("PKCS#8 private-key CHOICE forms", () => { expect(() => createPrivateKey({ key: encPem, passphrase: "wrong" })).toThrow( expect.objectContaining({ code: expect.stringMatching(/^ERR_OSSL_/) }), ); - expect(() => - createPrivateKey({ key: encDer, format: "der", type: "pkcs8", passphrase: "wrong" }), - ).toThrow(expect.objectContaining({ code: expect.stringMatching(/^ERR_OSSL_/) })); + expect(() => createPrivateKey({ key: encDer, format: "der", type: "pkcs8", passphrase: "wrong" })).toThrow( + expect.objectContaining({ code: expect.stringMatching(/^ERR_OSSL_/) }), + ); }); }); }); From 1c80b4ad7a0a4e30d4825713d972d0670c94276e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:22:43 +0000 Subject: [PATCH 05/10] crypto: scan past leading non-key PEM blocks in the "both"-form recovery PEM_read_bio_PrivateKey loops over blocks and skips CERTIFICATE / EC PARAMETERS / etc. until it finds a private-key block. The recovery path now mirrors that so a concatenated PEM whose "both"-form PRIVATE KEY (or ENCRYPTED PRIVATE KEY) block is not first is still recovered. --- src/jsc/bindings/ncrypto.cpp | 22 ++++++++++++++-------- test/js/node/crypto/crypto-pqc.test.ts | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index 50638136ae58..4a792fa40d2c 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -2704,27 +2704,33 @@ EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryParsePrivateKey( // wrapping one). Either way BoringSSL decrypted successfully and // then rejected the inner encoding; re-read whichever block it was // to recover the plaintext PrivateKeyInfo for the "both"-form - // retry. + // retry. PEM_read_bio_PrivateKey skips leading non-key blocks, so + // this scan does too. auto pemBio = BIOPointer::New(buffer); uint8_t* der = nullptr; long derLen = 0; char* name = nullptr; char* header = nullptr; - if (pemBio && PEM_read_bio(pemBio.get(), &name, &header, &der, &derLen)) { - Buffer derBuf { .data = der, .len = static_cast(derLen) }; - EVPKeyPointer recovered; + EVPKeyPointer recovered; + while (pemBio && PEM_read_bio(pemBio.get(), &name, &header, &der, &derLen)) { + bool matched = true; if (strcmp(name, PEM_STRING_PKCS8INF) == 0) { + Buffer derBuf { .data = der, .len = static_cast(derLen) }; recovered = TryParsePqcBothFormPkcs8(derBuf); } else if (strcmp(name, PEM_STRING_PKCS8) == 0 && config.passphrase.has_value()) { + Buffer derBuf { .data = der, .len = static_cast(derLen) }; recovered = tryRecoverPqcBothFormEncrypted(derBuf, passphrase); + } else { + matched = false; } OPENSSL_free(name); OPENSSL_free(header); OPENSSL_free(der); - if (recovered) { - ERR_clear_error(); - return ParseKeyResult(WTF::move(recovered)); - } + if (matched) break; + } + if (recovered) { + ERR_clear_error(); + return ParseKeyResult(WTF::move(recovered)); } } return keyOrError(EVPKeyPointer(key), config.passphrase.has_value()); diff --git a/test/js/node/crypto/crypto-pqc.test.ts b/test/js/node/crypto/crypto-pqc.test.ts index 6a95279ec400..c19854457766 100644 --- a/test/js/node/crypto/crypto-pqc.test.ts +++ b/test/js/node/crypto/crypto-pqc.test.ts @@ -196,6 +196,24 @@ describe("PKCS#8 private-key CHOICE forms", () => { expect(verify(undefined, data, pub, sig)).toBe(true); }); + test("PEM with a leading non-key block still recovers the `both` form", () => { + // PEM_read_bio_PrivateKey skips leading non-private-key blocks; the + // recovery path must scan past them too. + const cert = fixture("rsa_cert.crt").toString("ascii"); + const reference = createPrivateKey(fixture("ml_dsa_44_private_seed_only.pem")); + for (const inner of ["ml_dsa_44_private.pem", "ml_dsa_44_private_both_encrypted.pem"]) { + const bundle = cert + fixture(inner).toString("ascii"); + const key = createPrivateKey( + inner.includes("encrypted") ? { key: bundle, passphrase: "password" } : bundle, + ); + expect({ inner, type: key.asymmetricKeyType, equalsSeedOnly: key.equals(reference) }).toEqual({ + inner, + type: "ml-dsa-44", + equalsSeedOnly: true, + }); + } + }); + describe.each([ ["ml_dsa_44", "ml-dsa-44"], ["ml_kem_768", "ml-kem-768"], From cd3e9327ac89753ddb69018dd9f3319b7e292553 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:24:59 +0000 Subject: [PATCH 06/10] [autofix.ci] apply automated fixes --- test/js/node/crypto/crypto-pqc.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/node/crypto/crypto-pqc.test.ts b/test/js/node/crypto/crypto-pqc.test.ts index c19854457766..ce639fdb14db 100644 --- a/test/js/node/crypto/crypto-pqc.test.ts +++ b/test/js/node/crypto/crypto-pqc.test.ts @@ -203,9 +203,7 @@ describe("PKCS#8 private-key CHOICE forms", () => { const reference = createPrivateKey(fixture("ml_dsa_44_private_seed_only.pem")); for (const inner of ["ml_dsa_44_private.pem", "ml_dsa_44_private_both_encrypted.pem"]) { const bundle = cert + fixture(inner).toString("ascii"); - const key = createPrivateKey( - inner.includes("encrypted") ? { key: bundle, passphrase: "password" } : bundle, - ); + const key = createPrivateKey(inner.includes("encrypted") ? { key: bundle, passphrase: "password" } : bundle); expect({ inner, type: key.asymmetricKeyType, equalsSeedOnly: key.equals(reference) }).toEqual({ inner, type: "ml-dsa-44", From a762a5f52d6074acc4b93438cae1964c490b0bda Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:25:47 +0000 Subject: [PATCH 07/10] crypto: trim redundant comments in the "both"-form recovery path --- src/jsc/bindings/ncrypto.cpp | 14 ++------------ src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp | 3 --- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index 4a792fa40d2c..124b7bc2615d 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -2589,8 +2589,6 @@ EVPKeyPointer EVPKeyPointer::TryParsePqcBothFormPkcs8( } if (!params) return {}; - // The "both" arm is a SEQUENCE of two OCTET STRINGs: seed then - // expandedKey. BoringSSL only needs the seed; it re-derives the rest. CBS both, seed, expanded; if (!CBS_get_asn1(&privateKey, &both, CBS_ASN1_SEQUENCE) || CBS_len(&privateKey) != 0 @@ -2606,8 +2604,6 @@ EVPKeyPointer EVPKeyPointer::TryParsePqcBothFormPkcs8( EVPKeyPointer key(EVP_PKEY_from_private_seed(params->alg(), CBS_data(&seed), CBS_len(&seed))); if (!key) return {}; - // Reject a seed that does not re-derive the expanded key, matching - // OpenSSL's consistency check for this form. size_t pubLen = 0; if (!EVP_PKEY_get_raw_public_key(key.get(), nullptr, &pubLen) || pubLen < params->pubCompareLen) { @@ -2648,7 +2644,6 @@ bool isPrivateKeyWasNotSeedError(int err) EVPKeyPointer tryRecoverPqcBothFormEncrypted( const Buffer& der, const Buffer& pass) { - // EncryptedPrivateKeyInfo ::= SEQUENCE { algorithm, encryptedData } CBS cbs, epki, algorithm, ciphertext; CBS_init(&cbs, der.data, der.len); if (!CBS_get_asn1(&cbs, &epki, CBS_ASN1_SEQUENCE) @@ -2699,13 +2694,8 @@ EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryParsePrivateKey( PasswordCallback, config.passphrase.has_value() ? &passphrase : nullptr); if (!key && isPrivateKeyWasNotSeedError(ERR_peek_error())) { - // The PEM could have been either PRIVATE KEY (plaintext - // PrivateKeyInfo) or ENCRYPTED PRIVATE KEY (EncryptedPrivateKeyInfo - // wrapping one). Either way BoringSSL decrypted successfully and - // then rejected the inner encoding; re-read whichever block it was - // to recover the plaintext PrivateKeyInfo for the "both"-form - // retry. PEM_read_bio_PrivateKey skips leading non-key blocks, so - // this scan does too. + // PEM_read_bio_PrivateKey skips leading non-key blocks, so this + // scan does too. auto pemBio = BIOPointer::New(buffer); uint8_t* der = nullptr; long derLen = 0; diff --git a/src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp b/src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp index 5d3db228adfd..d1c15d7ff2f0 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp +++ b/src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp @@ -197,9 +197,6 @@ RefPtr CryptoKeyAKP::importPkcs8(CryptoAlgorithmIdentifier identif CBS_init(&cbs, keyData.begin(), keyData.size()); EvpPKeyPtr key(EVP_parse_private_key(&cbs)); if (!key) { - // BoringSSL's decoder only accepts the `seed [0]` arm of the RFC 9881 - // / 9935 private-key CHOICE. The `both` SEQUENCE (OpenSSL 3.5's - // default output) carries a seed too; recover it so those keys load. int err = ERR_peek_last_error(); if (ERR_GET_LIB(err) == ERR_LIB_EVP && ERR_GET_REASON(err) == EVP_R_PRIVATE_KEY_WAS_NOT_SEED) { ncrypto::Buffer der { .data = keyData.begin(), .len = keyData.size() }; From 27ecef29e6b0f02ab261d02c9ef76cf45708f31d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:36:40 +0000 Subject: [PATCH 08/10] ci: retrigger From 95f39ba1083616ba9bdf833c165c2f92d4a8a3d4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:49:33 +0000 Subject: [PATCH 09/10] crypto: review follow-ups for the "both"-form recovery - OPENSSL_clear_free the decrypted PrivateKeyInfo buffer (BoringSSL's OPENSSL_free already cleanses, but the explicit form documents intent). - Wrong-key-type test now picks the same-family sibling (ML-KEM-768 vs ML-KEM-1024, ML-DSA-44 vs ML-DSA-65) so the KEM rows exercise a parameter-set mismatch rather than a cross-family one. - Mismatched-seed test asserts byte 30 is the seed before flipping it, anchoring the offset against the seed-only fixture. --- src/jsc/bindings/ncrypto.cpp | 2 +- test/js/node/crypto/crypto-pqc.test.ts | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index 124b7bc2615d..146810f2b803 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -2661,7 +2661,7 @@ EVPKeyPointer tryRecoverPqcBothFormEncrypted( } Buffer plainBuf { .data = plain, .len = plainLen }; auto key = EVPKeyPointer::TryParsePqcBothFormPkcs8(plainBuf); - OPENSSL_free(plain); + OPENSSL_clear_free(plain, plainLen); return key; } } // namespace diff --git a/test/js/node/crypto/crypto-pqc.test.ts b/test/js/node/crypto/crypto-pqc.test.ts index ce639fdb14db..9b651c7e9ab2 100644 --- a/test/js/node/crypto/crypto-pqc.test.ts +++ b/test/js/node/crypto/crypto-pqc.test.ts @@ -154,18 +154,23 @@ describe("PKCS#8 private-key CHOICE forms", () => { }); test("subtle.importKey of the `both` form under a different parameter set is rejected as wrong key type", async () => { - const other = type === "ml-dsa-44" ? "ML-DSA-65" : "ML-DSA-44"; - const otherUsages: KeyUsage[] = ["sign"]; - await expect(subtle.importKey("pkcs8", fixtureDer(both), { name: other }, true, otherUsages)).rejects.toThrow( - "Invalid key type", - ); + const siblings: Record = { + "ML-DSA-44": "ML-DSA-65", + "ML-DSA-65": "ML-DSA-44", + "ML-DSA-87": "ML-DSA-44", + "ML-KEM-768": "ML-KEM-1024", + "ML-KEM-1024": "ML-KEM-768", + }; + await expect( + subtle.importKey("pkcs8", fixtureDer(both), { name: siblings[algName] }, true, usages), + ).rejects.toThrow("Invalid key type"); }); test("`both` form with a seed that does not match the expanded key is rejected", async () => { - // Byte 30 of the PKCS#8 is the first byte of the seed OCTET STRING - // contents for every fixture here (13-byte AlgorithmIdentifier, 4-byte - // OCTET STRING and SEQUENCE headers, 2-byte seed OCTET STRING header). + const seedLen = type.startsWith("ml-kem") ? 64 : 32; + const seed = fixtureDer(seedOnly).subarray(-seedLen); const modified = Buffer.from(fixtureDer(both)); + expect(modified.subarray(30, 30 + seedLen).equals(seed)).toBe(true); modified[30] ^= 0xff; await expect(subtle.importKey("pkcs8", modified, { name: algName }, true, usages)).rejects.toThrow( expect.objectContaining({ name: "DataError" }), From 2c09d70a614a58d6e5216be0e25c190c1ebbadf5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:19:35 +0000 Subject: [PATCH 10/10] crypto: also check the z half of the ML-KEM seed against the expanded dk The ML-KEM seed is d||z (FIPS 203). ek depends on d alone while z is stored verbatim as the trailing 32 bytes of the expanded dk, so the ek comparison validated d but not z. RFC 9935 section 6 requires rejecting a "both" pair whose expandedKey is not exactly KeyGen_internal(d, z); compare the trailing z bytes too so an inconsistent z is caught, matching what OpenSSL does. --- src/jsc/bindings/ncrypto.cpp | 9 +++++++++ test/js/node/crypto/crypto-pqc.test.ts | 23 ++++++++++++++--------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index 146810f2b803..0375a7b9f334 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -2618,6 +2618,15 @@ EVPKeyPointer EVPKeyPointer::TryParsePqcBothFormPkcs8( != 0) { return {}; } + // For ML-KEM the 64-byte seed is d||z: ek depends only on d, and z is + // stored verbatim as the trailing 32 bytes of dk. RFC 9935 requires + // checking the full KeyGen_internal(d, z) output, so also compare z. + if (params->seedLen == 64 + && CRYPTO_memcmp(CBS_data(&seed) + 32, + CBS_data(&expanded) + params->expandedLen - 32, 32) + != 0) { + return {}; + } return key; } diff --git a/test/js/node/crypto/crypto-pqc.test.ts b/test/js/node/crypto/crypto-pqc.test.ts index 9b651c7e9ab2..b811ef4209c2 100644 --- a/test/js/node/crypto/crypto-pqc.test.ts +++ b/test/js/node/crypto/crypto-pqc.test.ts @@ -169,15 +169,20 @@ describe("PKCS#8 private-key CHOICE forms", () => { test("`both` form with a seed that does not match the expanded key is rejected", async () => { const seedLen = type.startsWith("ml-kem") ? 64 : 32; const seed = fixtureDer(seedOnly).subarray(-seedLen); - const modified = Buffer.from(fixtureDer(both)); - expect(modified.subarray(30, 30 + seedLen).equals(seed)).toBe(true); - modified[30] ^= 0xff; - await expect(subtle.importKey("pkcs8", modified, { name: algName }, true, usages)).rejects.toThrow( - expect.objectContaining({ name: "DataError" }), - ); - expect(() => createPrivateKey({ key: modified, format: "der", type: "pkcs8" })).toThrow( - expect.objectContaining({ code: "ERR_OSSL_EVP_PRIVATE_KEY_WAS_NOT_SEED" }), - ); + const bothDer = fixtureDer(both); + expect(bothDer.subarray(30, 30 + seedLen).equals(seed)).toBe(true); + // For ML-KEM, seed = d||z: ek depends only on d, z is the trailing 32 + // bytes of dk. Flip a byte in each half so the z check is exercised. + for (const offset of seedLen === 64 ? [30, 62] : [30]) { + const modified = Buffer.from(bothDer); + modified[offset] ^= 0xff; + await expect(subtle.importKey("pkcs8", modified, { name: algName }, true, usages)).rejects.toThrow( + expect.objectContaining({ name: "DataError" }), + ); + expect(() => createPrivateKey({ key: modified, format: "der", type: "pkcs8" })).toThrow( + expect.objectContaining({ code: "ERR_OSSL_EVP_PRIVATE_KEY_WAS_NOT_SEED" }), + ); + } }); test("expandedKey-only form (no seed) is rejected", async () => {