Skip to content

crypto: accept the "both" PKCS#8 form for ML-DSA/ML-KEM private keys - #35510

Open
robobun wants to merge 10 commits into
mainfrom
farm/5bba1bc9/pqc-pkcs8-both-form
Open

robobun wants to merge 10 commits into
mainfrom
farm/5bba1bc9/pqc-pkcs8-both-form

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

ML-DSA / ML-KEM PKCS#8 private keys carry a CHOICE { seed [0], expandedKey, both SEQUENCE{seed, expandedKey} } (RFC 9881 / 9935). BoringSSL's EVP decoder only parses the seed [0] arm and rejects the other two with EVP_R_PRIVATE_KEY_WAS_NOT_SEED. The both form is what openssl genpkey -algorithm ML-DSA-65 writes by default on OpenSSL 3.5, so Bun could not load those keys on either crypto surface.

Reproduction

With the in-tree fixtures (test/js/node/test/fixtures/keys/ml_dsa_44_private.pem is the OpenSSL "both" form):

import { createPrivateKey, subtle } from "crypto";
import fs from "fs";
const pem = fs.readFileSync("test/js/node/test/fixtures/keys/ml_dsa_44_private.pem");
const der = Buffer.from(pem.toString().replace(/-----(BEGIN|END) PRIVATE KEY-----|\s/g, ""), "base64");

createPrivateKey(pem);
// before: Error: error:0600008b:public key routines:OPENSSL_internal:PRIVATE_KEY_WAS_NOT_SEED
//         code: ERR_OSSL_EVP_PRIVATE_KEY_WAS_NOT_SEED

await subtle.importKey("pkcs8", der, { name: "ML-DSA-44" }, true, ["sign"]);
// before: DataError: Invalid keyData

Node v26.3.0 accepts both calls.

Cause

EVP_parse_private_key / EVP_PKCS82PKEY dispatch to p_mldsa.cc / p_mlkem.cc DecodePrivate, which by design only reads [0] seed and errors EVP_R_PRIVATE_KEY_WAS_NOT_SEED for the other arms.

Fix

When the BoringSSL parse reports EVP_R_PRIVATE_KEY_WAS_NOT_SEED, re-parse the PKCS#8 with CBS, match the ML-DSA/ML-KEM OID, read the inner SEQUENCE { OCTET STRING seed, OCTET STRING expandedKey }, and build the key via EVP_PKEY_from_private_seed. The seed-derived public key is compared against the overlapping portion of the expanded key (rho for ML-DSA, the embedded ek for ML-KEM) so an inconsistent both pair is still rejected with DataError / ERR_OSSL_EVP_PRIVATE_KEY_WAS_NOT_SEED, which keeps the upstream testImportPkcs8MismatchedSeed case in test-webcrypto-export-import-ml-{dsa,kem}.js passing.

Applied at every entry point that reaches BoringSSL's ML-DSA/ML-KEM DecodePrivate:

  • ncrypto::EVPKeyPointer::TryParsePrivateKey for unencrypted DER PKCS#8, unencrypted PEM, encrypted DER PKCS#8, and encrypted PEM (covering crypto.createPrivateKey / createPublicKey). BoringSSL has no public API that stops at the plaintext PrivateKeyInfo bytes (PKCS8_decrypt and PKCS8_parse_encrypted_private_key both route through EVP_parse_private_key), so the encrypted paths forward-declare the internal bssl::pkcs8_pbe_decrypt helper, which has external linkage in the static link, to obtain the decrypted bytes before the EVP dispatch.
  • CryptoKeyAKP::importPkcs8 for subtle.importKey("pkcs8").

The expandedKey-only arm remains unsupported (BoringSSL has no EVP-level seedless ML-DSA/ML-KEM private key); both surfaces keep their existing diagnostic for it.

Verification

New tests in test/js/node/crypto/crypto-pqc.test.ts cover, for every ML-DSA/ML-KEM parameter set: createPrivateKey (PEM + DER) and subtle.importKey accepting the both form and round-tripping to the seed-only form, createPublicKey deriving the correct public key from it, rejection under a mismatched algorithm, rejection of a both form whose seed was flipped, and continued rejection of the expandedKey-only arm. Sign/verify with a key imported from the both form is exercised. Encrypted both-form fixtures (*_private_both_encrypted.pem, generated with openssl pkcs8 -topk8 on the in-tree *_private.pem) are covered for PEM and DER import plus missing/wrong passphrase. 23 of the new cases fail on an unmodified build and all 60 pass with the fix. Existing crypto.key-objects.test.ts, web-crypto.test.ts, and the vendored upstream test-webcrypto-export-import-ml-{dsa,kem}.js pass.


[review] gate passed · iteration 2 · 6 files touched

fails on main (without fix)
ASAN without fix: 24 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/crypto/crypto-pqc.test.ts
bun test v1.4.0 (2c09d70a6)

test/js/node/crypto/crypto-pqc.test.ts:
(pass) ML-DSA > ml-dsa-44 > generateKeyPairSync [5.21ms]
(pass) ML-DSA > ml-dsa-44 > sign and verify [11.76ms]
(pass) ML-DSA > ml-dsa-44 > import PEM and JWK round-trip [11.75ms]
(pass) ML-DSA > ml-dsa-65 > generateKeyPairSync [3.36ms]
(pass) ML-DSA > ml-dsa-65 > sign and verify [9.32ms]
(pass) ML-DSA > ml-dsa-65 > import PEM and JWK round-trip [7.99ms]
(pass) ML-DSA > ml-dsa-87 > generateKeyPairSync [4.15ms]
(pass) ML-DSA > ml-dsa-87 > sign and verify [14.11ms]
(pass) ML-DSA > ml-dsa-87 > import PEM and JWK round-trip [7.85ms]
(pass) ML-KEM > ml-kem-768 > generateKeyPairSync [4.28ms]
(pass) ML-KEM > ml-kem-768 > import PEM and JWK round-trip [8.16ms]
(pass) ML-KEM > ml-kem-1024 > generateKeyPairSync [1.93ms]
(pass) ML-KEM > ml-kem-1024 > import PEM and JWK round-trip [5.44ms]
118 |     const both = `${stem}_private.pem`;
119 |     const expandedOnly = `${stem}_private_priv_only.pem`;
120 | 
121 |     test("createPrivateKey accep
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (95f39ba10)

test/js/node/crypto/crypto-pqc.test.ts:
(pass) ML-DSA > ml-dsa-44 > generateKeyPairSync [4.38ms]
(pass) ML-DSA > ml-dsa-44 > sign and verify [3.28ms]
(pass) ML-DSA > ml-dsa-44 > import PEM and JWK round-trip [1.00ms]
(pass) ML-DSA > ml-dsa-65 > generateKeyPairSync [0.16ms]
(pass) ML-DSA > ml-dsa-65 > sign and verify [1.45ms]
(pass) ML-DSA > ml-dsa-65 > import PEM and JWK round-trip [0.26ms]
(pass) ML-DSA > ml-dsa-87 > generateKeyPairSync [0.20ms]
(pass) ML-DSA > ml-dsa-87 > sign and verify [0.94ms]
(pass) ML-DSA > ml-dsa-87 > import PEM and JWK round-trip [0.36ms]
(pass) ML-KEM > ml-kem-768 > generateKeyPairSync [0.13ms]
(pass) ML-KEM > ml-kem-768 > import PEM and JWK round-trip [0.23ms]
(pass) ML-KEM > ml-kem-1024 > generateKeyPairSync [0.07ms]
(pass) ML-KEM > ml-kem-1024 > import PEM and JWK round-trip [0.17ms]
(pass) PKCS#8 private-key CHOICE forms > ml-dsa-44 > createPrivateKey accepts the `both` form (PEM and DER) [1.21ms]
(pass) PKCS#8 private-key CHOICE forms > ml-dsa-44 > createPublicKey from the `both` private PEM derives the right public key [0.34ms]
(pass) PKCS#8 private-key CHOICE forms > ml-dsa-44 > subtle.importKey
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/crypto/crypto-pqc.test.ts
bun test v1.4.0 (2c09d70a6)

test/js/node/crypto/crypto-pqc.test.ts:
(pass) ML-DSA > ml-dsa-44 > generateKeyPairSync [5.03ms]
(pass) ML-DSA > ml-dsa-44 > sign and verify [11.63ms]
(pass) ML-DSA > ml-dsa-44 > import PEM and JWK round-trip [11.55ms]
(pass) ML-DSA > ml-dsa-65 > generateKeyPairSync [3.32ms]
(pass) ML-DSA > ml-dsa-65 > sign and verify [12.16ms]
(pass) ML-DSA > ml-dsa-65 > import PEM and JWK round-trip [7.73ms]
(pass) ML-DSA > ml-dsa-87 > generateKeyPairSync [4.19ms]
(pass) ML-DSA > ml-dsa-87 > sign and verify [13.81ms]
(pass) ML-DSA > ml-dsa-87 > import PEM and JWK round-trip [7.35ms]
(pass) ML-KEM > ml-kem-768 > generateKeyPairSync [4.27ms]
(pass) ML-KEM > ml-kem-768 > import PEM and JWK round-trip [7.75ms]
(pass) ML-KEM > ml-kem-1024 > generateKeyPairSync [1.88ms]
(pass) ML-KEM > ml-kem-1024 > import PEM and JWK round-trip [5.31ms]
(pass) PKCS#8 private-key CHOICE forms > ml-dsa-44 > createPrivateKey accepts the `both` form (PEM and DER) [13.81ms]
(pass) PKCS#8 private-key CHOICE for
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 928ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/55] gen bindgenv2
[2/52] gen cpp.rs (cppbind)
[3/52] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (31 fields)
Found 8 classes from /workspace/bun/src/runtime/api/html_rewriter.classes.ts
  - HTMLRewriter (3 fields
... (truncated)
diff hotspot
src/jsc/bindings/ncrypto.cpp                       | 180 ++++++++++++++++++++-
 src/jsc/bindings/ncrypto.h                         |   9 ++
 src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp        |  16 +-
 test/js/node/crypto/crypto-pqc.test.ts             | 168 ++++++++++++++++++-
 .../keys/ml_dsa_44_private_both_encrypted.pem      |  60 +++++++
 .../keys/ml_kem_768_private_both_encrypted.pem     |  57 +++++++
 6 files changed, 487 insertions(+), 3 deletions(-)

gate history · 4 passed · 2 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/jsc/bindings/ncrypto.cpp                                 10     22      0
src/jsc/bindings/ncrypto.h                                    3      1      0
src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp                   2      3      0
test/js/node/crypto/crypto-pqc.test.ts                        6      8      0
…test/fixtures/keys/ml_dsa_44_private_both_encrypted.pem      0      0      0
…est/fixtures/keys/ml_kem_768_private_both_encrypted.pem      0      0      0

@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Adds ML-DSA and ML-KEM both-form PKCS#8 recovery from seed and expanded-key data, supporting PEM, DER, encrypted keys, native imports, and WebCrypto imports. Tests cover valid, invalid, encrypted, and mixed PEM inputs.

PQC PKCS#8 recovery

Layer / File(s) Summary
Both-form PKCS#8 parser
src/jsc/bindings/ncrypto.h, src/jsc/bindings/ncrypto.cpp
Parses ML-DSA/ML-KEM both-form structures, derives keys from seeds, and verifies embedded public keys.
Private-key parsing fallbacks
src/jsc/bindings/ncrypto.cpp
Adds PEM and DER recovery for unencrypted and encrypted keys when OpenSSL reports that the private key was not seeded.
WebCrypto integration and coverage
src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp, test/js/node/crypto/crypto-pqc.test.ts
Uses the shared recovery parser during WebCrypto imports and tests valid, invalid, encrypted, and leading-block PEM cases.

Possibly related PRs

  • oven-sh/bun#34549: Changes error-state handling in the same PQC private-key parsing path.
  • oven-sh/bun#34838: Introduces the related ML-DSA/ML-KEM WebCrypto import path.

Suggested reviewers: cirospaciari, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change.
Description check ✅ Passed It covers what changed, why, and how it was verified, though it uses custom headings instead of the template.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:28 AM PT - Jul 25th, 2026

❌ @robobun, your commit 2c09d70 has 1 failures in Build #80185 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35510

That installs a local version of the PR into your bun-35510 executable, so you can run:

bun-35510 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Proposal: Implement "Modern Algorithms in the Web Cryptography API" (WICG specification) #29218 - This PR advances ML-DSA/ML-KEM support by accepting the "both" PKCS#8 encoding form (RFC 9881/9935), fixing interop with OpenSSL 3.5-generated post-quantum keys

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #29218

🤖 Generated with Claude Code

Comment thread src/jsc/bindings/ncrypto.cpp
Comment thread src/jsc/bindings/ncrypto.cpp
Comment thread src/jsc/bindings/ncrypto.cpp
Comment thread src/jsc/bindings/ncrypto.cpp Outdated
Comment thread src/jsc/bindings/ncrypto.cpp Outdated
Comment thread src/jsc/bindings/ncrypto.cpp
Comment thread src/jsc/bindings/ncrypto.cpp Outdated
Comment thread src/jsc/bindings/ncrypto.h
Comment thread src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp Outdated
Comment thread src/jsc/bindings/ncrypto.cpp
robobun and others added 8 commits July 25, 2026 03:38
… 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.
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.
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.
@robobun
robobun force-pushed the farm/5bba1bc9/pqc-pkcs8-both-form branch from 7eca563 to 27ecef2 Compare July 25, 2026 03:41

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/ncrypto.cpp`:
- Around line 2656-2665: Before releasing the decrypted buffer in the PKCS#8
decryption flow, cleanse the `plain` contents for `plainLen` bytes, then call
`OPENSSL_free(plain)`. Keep parsing through
`EVPKeyPointer::TryParsePqcBothFormPkcs8` unchanged and ensure cleanup occurs
after parsing.
- Around line 2627-2635: Update the pkcs8_pbe_decrypt forward declaration to use
the same C linkage as the upstream symbol, either by including
crypto/pkcs8/internal.h or by matching its exact extern "C" declaration; do not
place it only inside namespace bssl.

In `@test/js/node/crypto/crypto-pqc.test.ts`:
- Around line 164-176: Strengthen the test around the tampering setup in the
“both” mismatch case by extracting the known seed from the seed-only fixture and
asserting that the DER range starting at offset 30, with the seed’s length,
matches it before flipping byte 30. Keep the existing importKey and
createPrivateKey rejection assertions unchanged, so the test verifies the
mutation targets the seed rather than merely relying on the resulting mismatch.
- Around line 156-162: Update the parameter-set selection in the test covering
subtle.importKey with the “both” fixture so ML-KEM cases choose the sibling
ML-KEM variant (ML-KEM-768 versus ML-KEM-1024) and use its appropriate key
usages, while preserving the existing ML-DSA sibling behavior and rejection
assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 35eacbb1-d1ab-4811-b6a4-bf8bbcf0c137

📥 Commits

Reviewing files that changed from the base of the PR and between 65f3fe3 and 27ecef2.

⛔ Files ignored due to path filters (2)
  • test/js/node/test/fixtures/keys/ml_dsa_44_private_both_encrypted.pem is excluded by !**/*.pem
  • test/js/node/test/fixtures/keys/ml_kem_768_private_both_encrypted.pem is excluded by !**/*.pem
📒 Files selected for processing (4)
  • src/jsc/bindings/ncrypto.cpp
  • src/jsc/bindings/ncrypto.h
  • src/jsc/bindings/webcrypto/CryptoKeyAKP.cpp
  • test/js/node/crypto/crypto-pqc.test.ts

Comment thread src/jsc/bindings/ncrypto.cpp
Comment thread src/jsc/bindings/ncrypto.cpp
Comment thread test/js/node/crypto/crypto-pqc.test.ts
Comment thread test/js/node/crypto/crypto-pqc.test.ts
- 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.
Comment thread src/jsc/bindings/ncrypto.cpp
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.
Comment thread src/jsc/bindings/ncrypto.cpp

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of my earlier inline findings (multi-block PEM recovery, ML-KEM z-half consistency) are addressed and no new issues turned up this pass. Deferring to a human for the architectural call on forward-declaring BoringSSL's internal bssl::pkcs8_pbe_decrypt — it works in the current static link, but it's a private-symbol dependency that a maintainer should sign off on.

What was reviewed:

  • CBS bounds in TryParsePqcBothFormPkcs8: every CBS_get_asn1 return is checked, seed/expanded lengths are validated against the FIPS table before the offset reads, and pubInExpandedOffset + pubCompareLen ≤ expandedLen holds for every row.
  • PEM recovery loop frees name/header/der on every iteration including the matched break; decrypted plaintext is OPENSSL_clear_free'd.
  • CryptoKeyAKP::importPkcs8 still enforces EVP_PKEY_id == nidForIdentifier on the recovered key, so a wrong-algorithm import is rejected.
Extended reasoning...

Overview

Adds support for the RFC 9881/9935 "both" PKCS#8 CHOICE arm for ML-DSA/ML-KEM private keys, which BoringSSL's EVP_parse_private_key rejects with EVP_R_PRIVATE_KEY_WAS_NOT_SEED. A new EVPKeyPointer::TryParsePqcBothFormPkcs8 re-parses the PrivateKeyInfo with CBS, extracts the seed, rebuilds the key via EVP_PKEY_from_private_seed, and verifies seed↔expandedKey consistency (rho for ML-DSA; embedded ek plus the trailing z for ML-KEM). The recovery is wired into four entry points in TryParsePrivateKey (unencrypted PEM/DER, encrypted PEM/DER) and into CryptoKeyAKP::importPkcs8. ~180 lines of C++ in ncrypto.cpp, a 9-line header addition, ~16 lines in CryptoKeyAKP.cpp, ~170 lines of new tests, and two encrypted fixtures.

Security risks

This is private-key parsing for post-quantum algorithms. The consistency check between the seed and expanded key is an RFC 9935 §6 MUST — an earlier revision missed the ML-KEM z half, now fixed. The CBS parse is defensive (all lengths checked before offset arithmetic), and the recovery only runs after BoringSSL has already identified the input as a well-formed PKCS#8 for one of these OIDs and pushed the specific not-seed error. The decrypted PrivateKeyInfo is now cleansed before free. I don't see a memory-safety or validation gap, but crypto key-import surfaces warrant maintainer eyes.

Level of scrutiny

High. This is production crypto code, and the encrypted path takes an unusual step: forward-declaring bssl::pkcs8_pbe_decrypt, an internal BoringSSL helper with no public API, to reach the plaintext PrivateKeyInfo bytes. The PR body documents why (both public entry points route through EVP_parse_private_key), and the encrypted-form tests pass in CI, so the linkage is correct for the vendored fork today. But a BoringSSL bump could rename, inline, or change the linkage/signature of this symbol. That's a maintainer-level trade-off, not something I should approve unilaterally.

Other factors

Test coverage is thorough — every parameter set × PEM/DER × unencrypted/encrypted, plus mismatched-seed rejection (both d and z halves for ML-KEM), sibling-algorithm rejection, expandedKey-only rejection, wrong/missing passphrase, and a leading-certificate PEM bundle. My two prior inline findings were addressed in 607f0d0 and 2c09d70. All prior review threads are resolved.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI on build 80185: all build-cpp lanes (including windows-x64 and windows-aarch64) passed, so the bssl::pkcs8_pbe_decrypt forward declaration links on every target. The only reds are unrelated to this diff and flagged as flaky / pre-existing by the failure scraper:

  • test/js/bun/webview/webview-chrome.test.ts (animation click timing, debian-13 x64)
  • test/cli/run/no-orphans.test.ts (perl orphan-reap timeout, darwin 14 x64 + darwin 26 aarch64)
  • two build-bun steps that saw a stale "errored" from their sibling build-cpp before its retry passed (CI orchestration race; the retried build-cpp jobs are green)

crypto-pqc.test.ts and the vendored test-webcrypto-export-import-ml-{dsa,kem}.js are green on every lane that ran tests. The diff is ready; needs a maintainer to merge past the unrelated flake (and to sign off on the bssl::pkcs8_pbe_decrypt forward declaration for the encrypted path).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants