Skip to content

Reject a CryptoKey record whose key bytes do not import, and an RSA JWK with a zero component - #45952

Open
robobun wants to merge 3 commits into
mainfrom
robobun/eabd4915/cryptokey-record-import-failure
Open

robobun wants to merge 3 commits into
mainfrom
robobun/eabd4915/cryptokey-record-import-failure

Conversation

@robobun

@robobun robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun:jsc and node:v8 deserialize() and advanced IPC return null for a CryptoKey record whose key bytes do not import. No user reported it.
  • readCryptoKey (src/jsc/bindings/webcore/SerializedScriptValue.cpp:3362) turns the null import result into JS null and reports success.
  • A failed EC import or KeyObject PEM parse leaves a BoringSSL error queued. The next failed decipher.final() reports ERR_OSSL_EC_POINT_IS_NOT_ON_CURVE.

Fix

  • readCryptoKey returns false when the import gives no key. readRawKey rejects a ChaCha20-Poly1305 key that is not 32 bytes. The decode throws TypeError: Unable to deserialize data.
  • The failure branch clears the error queue. readKeyObject does after a bad PEM.
  • CryptoKeyRSA::create rejects a zero component. Otherwise a JWK with e: "AA" imports and its clone goes from null to a throw.
  • Verified: bun-jsc.test.ts (16 new, 14 fail on main), web-crypto.test.ts (1), spawn.ipc.test.ts (1). Self-reviewed: 27 concerns raised, 22 addressed (Notes).

Background

  • A CryptoKey record is plain bytes, validated only by readCryptoKey and the import functions.
  • BoringSSL keeps a per-thread error queue. node:crypto can report its oldest entry.
  • Considered a check per reader: new readers can miss it. Considered a zero byte in the RSA record writer: it changes serialized bytes for keys the W3C import steps reject.

Downsides

  • deserialize() returned null for these records since Bun 1.2.11 and now throws. Advanced IPC closes the channel.
  • importKey("jwk") rejects an RSA key with a zero component. Chrome rejects all 8 forms, Node.js accepts 6. This needs a maintainer.
  • Decode: RSA +27 instructions (public), +55 (private), raw keys +12, others -1. Allocations +0. Text +352 B.
Notes

Reproduction (Bun 1.2.11, 1.3.13, 1.4.2 and main, release, Linux x64):

import { serialize, deserialize } from "bun:jsc";

// An AES-GCM key record cut from 32 to 31 key bytes.
const key = await crypto.subtle.importKey("raw", new Uint8Array(32).fill(7), "AES-GCM", true, ["encrypt"]);
const bytes = Buffer.from(serialize(key));
const cut = Buffer.from(bytes.subarray(0, bytes.length - 1));
cut.writeUInt32LE(cut.readUInt32LE(5) - 1, 5); // record length
cut.writeUInt32LE(31, cut.length - 31 - 4); // key length
console.log(deserialize(cut)); // null. With this PR: TypeError: Unable to deserialize data.

Bun 1.2.10 throws DataCloneError when it serializes a CryptoKey. Bun 1.2.11 serializes it (#19040), and the null dates from there.

Records that gave null. 21 of 21 malformed records, through bun:jsc and through node:v8:

  • HMAC: 0 key bytes.
  • AES-GCM: 0, 15, 17, 31 and 33 key bytes.
  • Ed25519: 31 bytes (public and private), and a private key whose public half does not match.
  • ECDH P-256: a point that is not on the curve, 0 bytes, the curve name P-384, the curve name P-999, a corrupt PKCS#8, a truncated PKCS#8.
  • RSA: an empty modulus, an empty exponent, no primes, a third prime, an empty first prime, an empty private exponent.

An ML-DSA record already threw, because readAKPKey returned !!result. With this PR all 22 throw.

A ChaCha20-Poly1305 record with 0, 31 or 33 key bytes did not give null. It decoded to a key, because CryptoKeyRaw::create does not fail, and every encrypt with that key throws OperationError. importKey rejects those bytes with DataError: Invalid key length. The record now throws too. HKDF and PBKDF2 keys of any length still clone. A malformed key inside [key, key, { key }] decoded to [null, null, { key: null }] and now fails the whole value. Advanced IPC delivered null as a message. It now reports the TypeError and closes the channel.

235 keys made through crypto.subtle and KeyObject.toCryptoKey clone to a key with and without this change.

Why the RSA change is in this PR.

  • importKey("jwk") on main accepts an RSA key with one component of value zero: n, e, d, p, q, dp, dq or qi. These are the only keys from importKey whose record does not import. Each one clones to null on main.
  • The mechanism: a BIGNUM of value zero exports as zero bytes (convertToBytes, src/jsc/bindings/webcrypto/OpenSSLUtilities.cpp:97), the record stores that empty component, and CryptoKeyRSA::create (CryptoKeyRSAOpenSSL.cpp:71) rejected an empty component but accepted a zero one.
  • With the null check alone, the clone of such a key goes from null to a throw. worker_threads.receiveMessageOnPort aborts the process for a value that serializes but does not deserialize, so the check would add a way to reach that abort.
  • create now treats a component whose bytes are all zero like an empty one. It has two callers, importKey("jwk") and the record reader, so the guard is there and not in the JWK code.
  • The Import Key steps of the W3C specification throw DataError for a JWK that is not a valid RSA key. Bun's spki, pkcs8 and KeyObject.toCryptoKey imports already reject these keys.
Zero component main This PR Chrome 153 Node.js v26.3.0
n, e, d, dp, dq, qi imports, clones to null DataError rejects imports, clones
p, q imports, clones to null DataError rejects DataError

No serialized bytes change. The records of 8 ordinary keys (HMAC, AES-GCM, RSA, ECDSA, Ed25519) are byte-identical to main, and currentKeyFormatVersion stays 2. A record of one of the zero-component keys that Bun 1.3.13, 1.4.2 or main wrote reads as null there and throws with this PR.

An earlier draft of this change kept those keys: the record writer stored one 0x00 byte for a zero component. The self-review rejected that draft. It changed serialized bytes without a version bump (REVIEW.md:40), and it kept keys that the open #33903 rejects with RSA_check_key in the same function.

Error queue. One process per case. The case counts as stale when the next failed decipher.final() reports a code other than ERR_OSSL_BAD_DECRYPT.

Failed record main This PR
EC CryptoKey, 8 cases 6 stale 0
KeyObject with a PEM that does not parse, 3 cases 3 stale 0
ML-DSA-44 CryptoKey, 2 cases 0 0

A failed HMAC, AES, RSA or Ed25519 import leaves no entry (0 of 9 cases on main). A plain failed crypto.subtle.importKey leaves the same kind of entry as a failed EC record. That is #44863, and #44940 covers it.

Measurements (release builds of main 6f58f6b and of this change on that commit, Linux x64, gdb. perf, valgrind, bloaty and strace are not installed here.)

  • readCryptoKey, own instructions per successful decode (10th decode): 87 to 86 for HMAC, RSA public, RSA private, ChaCha20-Poly1305 and HKDF. 88 to 87 for AES-GCM, P-256 public, P-256 private, Ed25519 and ML-DSA-44. The null test was already there for toJS. The success path loses one jmp.
  • readCryptoKey with its callees (10th decode, two equal runs each): HMAC 368 to 367, AES-GCM 381 to 380, Ed25519 521 to 520. RSA-2048 public 1723 to 1750 and RSA-2048 private 5298 to 5353: the zero checks in create. ChaCha20-Poly1305 358 to 370 and HKDF 355 to 366: the length check in readRawKey.
  • CryptoKeyRSA::create, own instructions in importKey("jwk"): public 106 to 113, private 187 to 222 (measured on the first version of this PR, the function did not change since).
  • ERR_clear_error calls over 1000 decodes: valid HMAC records 0 (main 0), EC records with a point off the curve 1000 (main 0), HMAC records with 0 key bytes 1000 (main 0), malformed payloads with no key 0 (main 0). ERR_set_mark and ERR_pop_to_mark: 0 in every row.
  • Allocations per successful decode (mi_malloc / mi_free, 10th decode): HMAC 3 / 1, AES-GCM 3 / 1, ChaCha20-Poly1305 3 / 1, RSA public 12 / 6, RSA private 36 / 18, P-256 public 9 / 5, ML-DSA-44 5 / 2. The same on main.
  • Release bun text (llvm-size): 107098365 B to 107098717 B. By symbol (nm -S): readCryptoKey 524 to 527, readAKPKey 297 to 267, readRawKey 334 to 355, readKeyObject 1749 to 1754, CryptoKeyRSA::create 971 to 1123. readTerminal (6035), readRSAKey (1823) and the writer (7190) do not change.

Tests.

  • bun-jsc.test.ts: 16 new tests. 14 fail on main. The 2 ML-DSA rows pass before and after: they pin the clear that moved out of readAKPKey.
  • Each row compares the class and the message of the thrown error. toThrow(error) compares the message only.
  • web-crypto.test.ts: 1 new test. On main all 8 zero-component keys import.
  • spawn.ipc.test.ts: 1 new test. It fails on main with MESSAGE null.
  • Mutation check: with the null check kept and ERR_clear_error() removed from it, exactly the 3 queue rows fail (EC public, EC private, ML-DSA-44).
  • The queue rows run one child process each. A failed decipher.final() reports the oldest entry of the queue, so it shows an entry that the deserialize left.
  • Also run on the debug build: structured-clone.test.ts, structuredClone-classes.test.ts, message-channel.test.ts, structured-clone-fastpath.test.ts, worker-postmessage-transfer.test.ts, v8-serdes-buffer.test.ts, node-crypto.test.js, crypto.key-objects.test.ts, child_process_ipc.test.js, and 45 Node test files (test-webcrypto-*, test-crypto-key-objects*, test-crypto-rsa*, test-crypto-keygen-rsa*, test-crypto-sign-verify).

Review follow-up.

  • The ChaCha20-Poly1305 length check and the class comparison in the tests come from the review of the first version.
  • Not taken: a check of the hash tag of an HMAC or RSA record (second review). It is a field other than the key bytes, and it is listed under "Not changed here".
  • Not taken: ncrypto::ClearErrorOnReturn in place of the three ERR_clear_error() calls. The guard clears on entry and on return, so every successful key clone would make 2 calls. The calls in this PR run on the failure branches only: 0 calls per 1000 valid decodes.

Self-review: the 5 concerns not taken.

Not changed here.

  • readOKPKey and readHMACKey are not edited (webcrypto: accept HMAC key lengths that are not a multiple of 8 #42107 rewrites the second). The check after the class switch covers both.
  • node:crypto createPublicKey still accepts an RSA JWK with e of zero, as Node.js does. The clone of that KeyObject throws, and receiveMessageOnPort aborts for it (exit 134), on main and with this PR.
  • worker_threads.receiveMessageOnPort aborts for any value that serializes but does not deserialize, for example the KeyObject above (Fire messageerror when a posted message fails to deserialize #39408 is about the failed decode).
  • A record whose HMAC or RSA hash tag is not a digest still decodes to a key. Its algorithm.hash.name is then, for example, AES-GCM, and sign throws OperationError. importKey cannot make such a key. This PR checks the key bytes of a record, not its other fields.
  • CryptoKeyRSA::create does not check that the components agree. A record with p = q = n still decodes to a key, as the same JWK imports (webcrypto: validate RSA private key components on JWK import #33903).
  • In-process clones still go through the byte record. A design that carries the native key for them still needs this check for byte input.
  • readX509Certificate clears the error queue on entry and on return, for a successful clone too.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/spawn/spawn.ipc.test.ts, test/js/bun/jsc/bun-jsc.test.ts

@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 PM PT - Oct 21st, 2026

❌ @robobun, your commit 9a355e2 has some failures in Build #130239 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 45952

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

bun-45952 --bun

@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How I reproduced it:

  • On Bun 1.2.11, 1.3.13, 1.4.2 and main, deserialize() from bun:jsc returns null for an AES-GCM key record that is cut to 31 key bytes. The script is in the Notes of the description.
  • On main, 14 of the 16 new tests in test/js/bun/jsc/bun-jsc.test.ts fail. The new test in test/js/web/crypto/web-crypto.test.ts and the new test in test/js/bun/spawn/spawn.ipc.test.ts also fail. All pass with this change on a debug build with ASAN.

PR: #45952

@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 4a1243fd-0297-4b53-87ee-2a0afd5218fa
📥 Commits

Reviewing files that changed from the base of the PR and between 3abd2fa and 56a1dba.

📒 Files selected for processing: 4
  • src/jsc/bindings/webcore/SerializedScriptValue.cpp
  • test/js/bun/jsc/bun-jsc.test.ts
  • test/js/bun/spawn/spawn.ipc.test.ts
  • test/js/web/crypto/web-crypto.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Walkthrough

RSA key creation rejects zero-valued components. Serialized key deserialization validates ChaCha20-Poly1305 lengths, detects failed imports, and clears BoringSSL errors from failed imports and PEM parses. Tests cover malformed records, error cleanup, and IPC rejection.

Changes

Crypto key handling

Layer / File(s) Summary
RSA component validation
src/jsc/bindings/webcrypto/CryptoKeyRSAOpenSSL.cpp, test/js/web/crypto/web-crypto.test.ts
RSA key creation rejects zero-valued modulus, exponents, prime factors, and CRT components. Tests cover valid RSA JWK imports and rejection of zero-valued components.
Serialized key deserialization and error cleanup
src/jsc/bindings/webcore/SerializedScriptValue.cpp, test/js/bun/jsc/bun-jsc.test.ts, test/js/bun/spawn/spawn.ipc.test.ts
Deserialization validates ChaCha20-Poly1305 key lengths, detects failed CryptoKey imports, and clears queued BoringSSL errors from failed imports and PEM parses. Tests cover malformed records, intact records, error-queue cleanup, and IPC rejection.

Suggested reviewers: jarred-sumner, cirospaciari

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 56a1d

No actionable issue remains identified; the change is ready to merge after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly summarizes both primary changes: rejecting CryptoKey records that fail import and rejecting RSA JWKs with zero components.
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.
Description check Passed The description is complete and relevant. It clearly explains the problem, fix, verification, background, downsides, test results, and review considerations. It does not use the exact template heading…
Full details: Description check

Explanation

The description is complete and relevant. It clearly explains the problem, fix, verification, background, downsides, test results, and review considerations. It does not use the exact template headings ### What does this PR do? and ### How did you verify your code works?, but it provides the required information in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked that moving the ERR_clear_error() out of readAKPKey is behavior-preserving: it now returns true with a null result, which reaches the central !result branch in readCryptoKey that clears the queue and fails the decode, so the ML-DSA path keeps its previous contract. I also checked the isZero change in CryptoKeyRSA::create against the JWK and record-reader callers: for the CRT params a zero component becomes nullptr, and RSA_set0_crt_params rejects a null parameter, so the resulting DataError is the behavior the PR description states rather than an accident.

Extended reasoning...

The change touches the structured-clone CryptoKey reader in src/jsc/bindings/webcore/SerializedScriptValue.cpp and RSA key construction in src/jsc/bindings/webcrypto/CryptoKeyRSAOpenSSL.cpp, both crypto and deserialization surfaces that parse untrusted bytes. Three inline findings were posted (a pre-existing ChaCha20 raw-key hole, a test assertion that does not pin the error class, and bare ERR_clear_error calls instead of the RAII helper), and the WebCrypto import contract change is one the author explicitly flags as needing a maintainer, so a human review is warranted rather than approval.

Comment thread test/js/bun/jsc/bun-jsc.test.ts Outdated
Comment thread src/jsc/bindings/webcore/SerializedScriptValue.cpp
Comment thread src/jsc/bindings/webcore/SerializedScriptValue.cpp
@robobun
robobun force-pushed the robobun/eabd4915/cryptokey-record-import-failure branch from 3abd2fa to 56a1dba Compare October 20, 2026 06:51

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked the readAKPKey refactor — its null result now reaches the centralized !result branch in readCryptoKey, so the ML-DSA/ML-KEM clear-and-fail behavior is preserved; the new readRawKey early return needs no ERR_clear_error() since neither the length check nor CryptoKeyRaw::create touches BoringSSL; and isZero() on the CRT params yields the same nullptr to RSA_set0_crt_params that an empty component already did, so no previously-accepted CRT shape changes.

Extended reasoning...

The push since the prior review adds the ChaCha20-Poly1305 key-length check in src/jsc/bindings/webcore/SerializedScriptValue.cpp and switches the bun-jsc and IPC tests to compare the thrown error's class as well as its message. This is WebCrypto and structured-clone deserialization code, so a human maintainer should still weigh the RSA zero-component import rejection the PR description flags as a compat decision.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/jsc/bindings/webcore/SerializedScriptValue.cpp — pre-existing: a CryptoKey record whose HMAC or RSA hash tag names a non-digest algorithm still deserializes to a key that importKey would never create. readHMACKey at SerializedScriptValue.cpp:3063 and readRSAKey at :3095 accept any CryptoAlgorithmIdentifier as the hash, and CryptoKeyHMAC::importRaw never checks it. Fix: have both readers reject a record whose hash is not a digest, e.g. if (!digestAlgorithm(hash)) return false; (OpenSSLUtilities.h:40), so the reader matches what importKey accepts. The resulting key reports algorithm.hash.name such as "AES-GCM", sign() fails with OperationError, and exportKey("jwk") reaches ASSERT_NOT_REACHED at CryptoAlgorithmHMAC.cpp:257 in debug builds.

    Why this was flagged

    A bun:jsc or node:v8 deserialize call, an advanced-IPC frame, or a postMessage payload carries a CryptoKey record of class HMAC (or RSA with isRestrictedToHash set) whose hash byte is any valid CryptoAlgorithmIdentifierTag that is not SHA-*, for example the tag for AES_GCM. read(CryptoAlgorithmIdentifier&) at SerializedScriptValue.cpp:2899 maps every tag, so readHMACKey at :3063 and readRSAKey at :3095 accept it. CryptoKeyHMAC::importRaw at CryptoKeyHMAC.cpp:87 only checks the key length, and CryptoKeyRSA::create never looks at the hash, so a non-null key is produced and the new null check at SerializedScriptValue.cpp:3362 does not fire. crypto.subtle.importKey cannot build such a key because normalizeImportParameters only accepts digest algorithms for hash. The caller gets a CryptoKey whose algorithm.hash.name is "AES-GCM"; platformSign at CryptoAlgorithmHMACOpenSSL.cpp:64 returns OperationError, and exportKey("jwk") hits ASSERT_NOT_REACHED at CryptoAlgorithmHMAC.cpp:257 in debug builds. The base branch behaves the same.

    Verification: pre-existing. Triggered when a deserialize call or postMessage payload carries an HMAC (or RSA) CryptoKey record whose hash byte is a valid non-digest tag (e.g. AES_GCM). readHMACKey at src/jsc/bindings/webcore/SerializedScriptValue.cpp:3062-3065 passes that hash straight to CryptoKeyHMAC::importRaw, which checks only keyData.size() and lengthBits. The new check at :3362 does not fire. The base branch behaves identically.

CryptoKeyRSA::create rejected an empty component. It now also rejects
a component whose bytes are all zero, for the modulus, the exponents,
the primes and the CRT values. No valid RSA key has such a component,
and exportData() writes it as no bytes, so the key could not be
created again from its own components.

importKey("jwk") and the structured-clone reader are the two callers.
…port

readCryptoKey returns false when the import of the key bytes gives no
key, and clears the BoringSSL error that the failed import can leave.
Before, the record deserialized to null. The clear that readAKPKey
had for ML-DSA and ML-KEM keys moves to that one place.

readKeyObject clears the BoringSSL error of a PEM that does not parse.
…length

readRawKey built the key from any number of bytes, because
CryptoKeyRaw::create does not fail. importKey rejects a
ChaCha20-Poly1305 key that is not 256 bits, and the reader now does
the same. HKDF and PBKDF2 keys still take any length.

The tests compare the class of the thrown error and not only its
message.
@robobun
robobun force-pushed the robobun/eabd4915/cryptokey-record-import-failure branch from 56a1dba to 9a355e2 Compare October 21, 2026 17:11
@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

The second review found one more pre-existing case: a record whose HMAC or RSA hash tag is not a digest still decodes to a key. I confirmed it. A record with the AES-GCM tag in the hash field gives a key with algorithm.hash.name of AES-GCM, and sign throws OperationError.

I do not change that in this PR. This PR checks the key bytes of a record, and the hash tag is another field. The description now lists the case under "Not changed here".

The branch is rebased onto the current main. The three changed test files pass on a debug build with ASAN (61, 98 and 21 tests).

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.

1 participant