Repository navigation
Conversation
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (3)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review. WalkthroughThe JWK import path now handles OKP and AKP keys in a shared branch. Tests cover field validation, public/private key consistency, and missing private material across key-import APIs. ChangesOKP and AKP JWK Import
Suggested reviewers: Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to No actionable issue is established for this change; it is mergeable after normal checks. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 9:48 PM PT - Oct 2nd, 2026
❌ @robobun, your commit 5a165e3 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 32910That installs a local version of the PR into your bun-32910 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
I didn't find any issues — the validation logic and size checks look correct and the tests are thorough — but since this changes crypto key import validation paths it's worth a quick human look.
Extended reasoning...
Overview
This PR adds validation in two OKP JWK private-key import paths to reject keys whose x (public) component is missing or doesn't match the public key derived from d (private), per RFC 8037 §2. It touches:
src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp(~12 lines): replaces aFIXMEwith an explicitxpresence check, base64url decode, adlength check (32 bytes), public-key derivation via existinged25519PublicFromPrivate/x25519PublicFromPrivate, and a constant-time compare.src/jsc/bindings/node/crypto/KeyObject.cpp(~11 lines): after building the privateEVP_PKEYfromd, decodesxand compares it againstkey.rawPublicKey()withCRYPTO_memcmp, throwingERR_CRYPTO_INVALID_JWKon mismatch.- New test cases in
web-crypto.test.tsandcrypto.key-objects.test.tscovering missing/mismatched/wrong-lengthx, valid round-trip, and public-only JWK for both Ed25519 and X25519.
Security risks
The change strictly tightens validation — previously-accepted malformed keys are now rejected — so the direction is security-positive. I verified the new d->size() != externalKeySizeInBytesFromNamedCurve(namedCurve) guard runs before ED25519_keypair_from_seed/X25519_public_from_private are called on the decoded d, preventing under-read of a short seed buffer. Both compare paths use constant-time memcmp. No new attack surface is introduced; the only behavioral change is that some inputs now throw DataError/ERR_CRYPTO_INVALID_JWK instead of being silently repaired.
Level of scrutiny
This is crypto key-import code, which I treat as requiring human review even when the change is small and additive. The implementation reuses existing, already-exercised helpers (ed25519PublicFromPrivate, x25519PublicFromPrivate, rawPublicKey, constantTimeMemcmp, CRYPTO_memcmp, decodeJwkString) and follows the established pattern from the EC import path, so the risk is low — but it still merits a maintainer's eyes.
Other factors
No CODEOWNERS match the modified files. The bug-hunting system found nothing. Test coverage is comprehensive (5 cases × 2 curves for WebCrypto, 2 cases × 2 curves for node:crypto, plus the existing positive round-trip tests). The PR description includes a Node v26.3.0 comparison table showing the new behavior matches Node.
0414b01 to
56740cc
Compare
|
Good catch by the duplicate detector: #32827 (already open) covers the WebCrypto However, #32827 does not touch the I have narrowed this PR to just the |
There was a problem hiding this comment.
Thanks for updating the title/description — the diff now matches its stated scope and the validation logic looks correct to me, but I'll leave the final sign-off to a human since this is a crypto key-import trust boundary.
Extended reasoning...
Overview
This PR adds an 11-line guard to KeyObject::getKeyObjectHandleFromJwk in src/jsc/bindings/node/crypto/KeyObject.cpp: after building the private EVP_PKEY from an OKP JWK's d, it base64url-decodes the supplied x, fetches the key's derived public bytes via EVPKeyPointer::rawPublicKey(), and rejects with ERR_CRYPTO_INVALID_JWK if x is the wrong length or differs (constant-time CRYPTO_memcmp). Two new negative tests per OKP curve in test/js/node/crypto/crypto.key-objects.test.ts cover the mismatched-x and wrong-length-x cases; the existing round-trip tests cover the happy path.
My earlier review flagged that the title/description claimed a WebCrypto fix that wasn't in the diff. The author has since retitled to node:crypto: ..., rewritten the repro around createPrivateKey, and added a "Relationship to #32827" section delegating the WebCrypto half — that concern is fully resolved and the inline comment is marked resolved.
Security risks
The change is strictly a tightening of input validation at a key-import trust boundary, matching Node.js behavior and RFC 8037 §2. It uses existing helpers (decodeJwkString, rawPublicKey()) and a constant-time compare. The size check precedes the memcmp, so there is no out-of-bounds read. I see no way this introduces a new vulnerability; if anything it closes a minor one (silent repair of inconsistent JWKs).
Level of scrutiny
This is node:crypto key-import code — explicitly security-sensitive — so per policy I'm deferring rather than auto-approving even though the change is small, mechanical, and well-tested. There is also an open sibling PR (#32827) covering the WebCrypto half of the same bug class; a human can confirm the two remain non-overlapping as stated.
Other factors
No CODEOWNERS entry covers this path. No bugs were surfaced by the bug-hunt on this revision. No outstanding unresolved reviewer comments.
|
CI has now gone red three times on this PR, each time on infrastructure or unrelated flake, and never on anything this diff touches. Build 65830 has since finished, so updating this comment for accuracy: the finished build has two failed test files, not zero. Breakdown of the finished build 65830 (255 jobs passed):
For the earlier builds: 65761 (identical diff) hard-failed only on the same darwin artifact download timeout, and 65717 only on unrelated lanes.
I already pushed one empty commit to re-roll CI and do not want to keep spamming the history with more, so I will stop here. Retrying the failed and expired jobs on build 65830 in the Buildkite UI, or kicking off a fresh build, should be all that is needed to get this green. |
|
Stale PR review: keep open, rework. The fix is wanted. Bun reports Node v26.3.0, and Node v26.3.0 throws The current diff is not the Node v26 behavior yet:
Wanted shape: rebase onto One compatibility note for the PR body: code that passes a placeholder |
…with AKP
An OKP (Ed25519, X25519) JWK that carries "d" is now a private key at
every entry point. The import builds the key from "d" and rejects the
JWK with ERR_CRYPTO_INVALID_JWK ("Invalid JWK OKP key") when "x" is not
the public key of "d". Before, the caller's mode picked the member:
createPublicKey and verify built the key from "x" and never read "d",
and createPrivateKey and sign built it from "d" and never compared "x".
One JWK was two different keys.
The OKP arm of KeyObject::getKeyObjectHandleFromJwk now shares the body
of the AKP arm, as Node's ImportJWKEdKey and ImportJWKPqcKey share one
shape. The arm reads the name, the public member and the private member
once each before it validates them, and it reports each malformed member
with the same message.
The check for a public-only JWK at a private-key consumer moves after
the import, as in Node. A public-only JWK with a malformed public member
now reports the malformed key, for AKP too.
7f06207 to
cd53fd7
Compare
There was a problem hiding this comment.
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 the moved "JWK does not contain private key material" check: for sign()/createSign()/privateEncrypt()/privateDecrypt() with a public-only OKP JWK, Bun now throws ERR_CRYPTO_INVALID_JWK where Node throws ERR_INVALID_ARG_TYPE from the operation. The diff comments this as a deliberate divergence, so I did not file it, but a maintainer should confirm that divergence is acceptable.
Extended reasoning...
The commit merges the OKP JWK import arm into the AKP raw-key arm in src/jsc/bindings/node/crypto/KeyObject.cpp, adding the x-vs-d consistency check and relying on BoringSSL for raw-key length validation, and adds tests under test/js/node/crypto and the vendored test/js/node/test tree. It touches crypto key import, a security-sensitive trust boundary, and two verified findings are posted inline, so approval is not appropriate. The one additional candidate examined was the ERR_CRYPTO_INVALID_JWK vs ERR_INVALID_ARG_TYPE ordering change for private-key consumers given a public-only JWK, which the code explicitly documents as an intentional divergence from Node.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/jsc/bindings/node/crypto/KeyObject.cpp— A caller importing an EC private JWK whose d string fails to decode gets a null dereference instead of a thrown error. KeyObject.cpp:1367 calls decodeJwkString for key.d with no RETURN_IF_EXCEPTION, then :1368 reads dBuf->span() even when decodeJwkString returned null with an exception pending. The sibling calls at :1356-1359 and the rewritten OKP/AKP arm at :1265-1273 all check. Fix: add RETURN_IF_EXCEPTION(scope, {}) after every decodeJwkString call in getKeyObjectHandleFromJwk, including this EC d decode.Why this was flagged
Trigger: createPrivateKey/sign with {format:'jwk', key:{kty:'EC', crv:'P-256', x, y, d}} where decoding d throws inside constructFromEncoding at KeyObject.cpp:1135 (allocation failure for a very long d string, or any future throwing path) or where the decoded value is not an ArrayBufferView so :1139 throws ERR_INVALID_ARG_TYPE and returns null. The EC arm at KeyObject.cpp:1367 ignores the pending exception and :1368 dereferences the null dBuf, crashing the process instead of surfacing the error; with BUN_JSC_validateExceptionChecks=1 this is an unchecked-exception violation. The base branch has the identical lines, so this is pre-existing, but it sits in the same function this PR rewrote, every other decodeJwkString call in the function is checked, and the repository's rule is to fix the whole class in the same PR.
Verification: pre-existing — the EC arm is byte-identical at base (bc7a813, line 1414) and HEAD (line 1367). /home/claude/bun/src/jsc/bindings/node/crypto/KeyObject.cpp:1367
auto* dBuf = decodeJwkString(...)is followed at :1368 bydBuf->span()with no RETURN_IF_EXCEPTION. decodeJwkString returns null whenever constructFromEncoding throws (:1136), e.g. on OOM, so :1368 dereferences null.
| if (jwkType == CryptoKeyType::Private) { | ||
| auto derivedPub = key.rawPublicKey(); | ||
| auto expected = pubBuf->span(); | ||
| if (!derivedPub || derivedPub.size() != expected.size() | ||
| || CRYPTO_memcmp(derivedPub.get(), expected.data(), expected.size()) != 0) { | ||
| ERR::CRYPTO_INVALID_JWK(scope, globalObject, "Invalid JWK AKP key"_s); | ||
| ERR::CRYPTO_INVALID_JWK(scope, globalObject, invalidKey); | ||
| return {}; | ||
| } |
There was a problem hiding this comment.
🔴 Apps that use @ libp2p/crypto below 5.1.18 on Bun lose Ed25519 key handling after merge, which works on the base branch. Those versions call createPrivateKey with a JWK carrying a valid d and x: '' to derive the public key; KeyObject.cpp:1300 now rejects any x whose decoded length differs from the derived key, so the call throws ERR_CRYPTO_INVALID_JWK "Invalid JWK OKP key". This follows from the stated purpose (Node 26 parity), and the description names the package. Fix: the call is the maintainers' — if the breakage is accepted, label the PR breaking and release-note it; otherwise the derived-key check must stay while a documented, Node-matching lenience for a placeholder x is agreed upstream first.
Why this was flagged
The trigger is a private OKP JWK whose x is an empty string or otherwise not the public key of d, passed to createPrivateKey, createPublicKey, sign, verify or any other node:crypto entry point that imports a JWK. @ libp2p/crypto 4.1.9 and 5.1.7 through 5.1.17 build exactly this JWK ({ crv: 'Ed25519', x: '', d, kty: 'OKP' }) inside derivePublicKey and then export the key to read x. In the new shared arm KeyObject.cpp:1263 decodes x unconditionally, KeyObject.cpp:1298-1301 derives the public key from d and compares it with CRYPTO_memcmp, and the size mismatch (32 versus 0) throws ERR_CRYPTO_INVALID_JWK "Invalid JWK OKP key" at KeyObject.cpp:1302. On the base branch the old OKP arm read only d for private consumers and never looked at the content of x, so the same call succeeded and returned the key. No safeguard mitigates this: the PR description lists it as a downside but there is no release note or breaking label visible in the change. Node 26.3.0 is claimed to reject the same input, so the behaviour is the stated purpose.
Verification: In src/jsc/bindings/node/crypto/KeyObject.cpp the merged OKP arm now always decodes x (line ~1263), then for a string d builds the key from d and compares (lines ~1296-1304). For x: '', expected.size() is 0 vs derivedPub.size() 32, so createPrivateKey throws ERR_CRYPTO_INVALID_JWK "Invalid JWK OKP key". On base x was never decoded or compared for a private consumer, so the same input succeeded.
There was a problem hiding this comment.
This is correct, and the decision belongs to a maintainer, not to me. The break is the stated purpose: the same call throws on Node v26.3.0. I checked the versions myself: @libp2p/crypto 4.1.9, 5.1.7, 5.1.12, 5.1.13 and 5.1.17 pass x: '' next to d, and 5.1.18 does not.
I added the breaking label and put the choice first in the Notes of the PR body. The one alternative is a Bun-only exception that treats an empty x next to a string d as absent. Node has no such exception, so this PR does not add one. I leave this thread open for a maintainer to decide.
|
The head moved. It is now 5a165e3, on What changed since 7f06207
How I reproduced it. The script is in the Notes of the PR body. It gives one JWK the
The test runs and measurements in the PR body were made on cd53fd7. The head differs from it by 7 comment lines in Two decisions for a maintainer
Answers to the review
CI on this head. Build 123115 built 5a165e3 and ran the suites: 180 jobs passed and 1 failed. No file of this PR appears in a failure or in a retried test.
|
Problem
dof key A and thexof key B is two keys.signuses A, andverifyaccepts a signature by B. Node 26 throwsERR_CRYPTO_INVALID_JWK("Invalid JWK OKP key") at every entry point.case Kty::OkpofKeyObject::getKeyObjectHandleFromJwk(src/jsc/bindings/node/crypto/KeyObject.cpp). The caller's mode picks which member is the key, and nothing compares the other.Fix
ImportJWKEdKey. A stringdmakes a private key at every entry point, andxmust be its public key.crv,xanddonce each, then validates them with one error.test/js/node/crypto/crypto.key-objects.test.ts(342 of 344 new tests fail on main) andcrypto-pqc.test.ts.Background
xis the public key and a function of the private scalard.verifyopen. Ad-keyed compare in the old arm keeps three error shapes at the same cost.Downsides
xordnow throws.@libp2p/cryptobelow 5.1.18 passesx: ''(49.6% of its downloads). A maintainer must accept this.createPublicKeyandverifywith a JWK that carriesdnow derive the key: 3,557 to 151,567 instructions. A public-only JWK costs 294 more.Notes
Decisions for a maintainer.
createPrivateKey({ format: "jwk", key: { kty: "OKP", crv: "Ed25519", x: "", d } })works onmainand throws with this PR, as it does on Node v26.3.0.@libp2p/crypto4.1.9, 5.1.7, 5.1.12, 5.1.13 and 5.1.17 make that call inderivePublicKey. 5.1.18 no longer passesx: ''. Versions below 5.1.18 had 93,959 of the 189,619 npm downloads of the last week. Node has no exception for a placeholderx, so the only alternative is a Bun-only one: treat an emptyxnext to a stringdas absent. That keeps old libp2p working and differs from Node for that one shape. This PR does not do that.case Kty::Akp: case Kty::Okp:body also changes AKP. A public-only AKP JWK with a malformedpub, given to a private-key consumer, now reports "Invalid JWK AKP key" and no longer "JWK does not contain private key material". That is Node's answer (36 of 1,352 probed AKP cells move, all to Node v26.3.0's result, and no other AKP cell moves). If the AKP arm must stay untouched, a cloned OKP arm gives the same OKP behaviour at about 1 KB more in thisminsizefunction.Repro. Node v26.3.0 prints
ERR_CRYPTO_INVALID_JWKfour times.mainprintsA,B,false,true. This PR printsERR_CRYPTO_INVALID_JWKfour times.What was run, and on which commit. Every test run and measurement below was made on commit cd53fd7. The head differs from it in two ways: 7 comment lines in
KeyObject.cpp(no other source line), and the removal of three changes undertest/js/node/test/. I have not built the head itself. CI is the check for the head.Node reference (v26.3.0).
ImportJWKEdKey: https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_ec.cc#L597-L676 (added by crypto: unify asymmetric key import through KeyObjectHandle::Init nodejs/node#62499). It takes no mode. A stringddecides the type.test/parallel/test-crypto-jwk-raw-validation.js). Run as a script with that version'stest/common/crypto.js, that file exits 0 on a build of this change and 1 onmain. It is not added to the tree: see Tests.Measurements. Release builds of
mainbc7a813 and of cd53fd7 on the same commit, linux-x64.perf,valgrindandbloatyare not available in the build container, so instruction counts aregdbsingle-step counts of one import call with the JIT off.getKeyObjectHandleFromJwk: 6,800 bytes (main bc7a813: 7,448 bytes); release text: 768 bytes less than main (nm -S,size).gdbhit counts, Ed25519 and X25519 alike): public-only JWK reads 3 -> 4, mallocs 6 -> 6; private JWK at a public entry point scalar mults 0 -> 1, decodes 1 -> 2, mallocs 6 -> 10; private JWK at a private entry point decodes 1 -> 2, mallocs 6 -> 10.createPublicKey(private Ed25519 JWK): 1.49 us -> 18.38 us (median of 12 interleaved runs of 200,000 imports, min-max 0.91 to 5.48 and 15.87 to 23.13; Node 26.3.0 69.00 us). X25519: 0.97 -> 16.03 us.createPublicKey(public-only Ed25519 JWK): 0.88 -> 0.93 us (min-max 0.62 to 0.98 and 0.62 to 1.05), inside the noise.Behaviour against Node v26.3.0. The 12 call forms are
createPrivateKey,createPublicKey,sign,signwith a callback,verify,verifywith a callback,createSign().sign,createVerify().verify,publicEncrypt,publicDecrypt,privateEncryptandprivateDecrypt.maindiffers from Node on 1,292, cd53fd7 on 168. No cell that equals Node onmainchanges.sign,createSign().sign,privateEncryptorprivateDecrypt. The import throws "JWK does not contain private key material". Node fails later, in the operation:signwith an Ed25519 key givesERR_OSSL_NOT_A_PRIVATE_KEYon v26.3.0. The AKP arm already did this. (3) 26 cells: acrvwith a trailing NUL and more characters. Node compares C strings and accepts it.mainand this PR reject it.kty,crv,x,donce each, as Node does (97 of 97 read-log cells equal Node,main24).din any export, andsignrefuses it (25 probe lines, equal to Node and tomain).Not fixed here.
case Kty::Ec:dof one key withx,yof another. node:crypto: validate EC JWK private scalar matches public point in createPrivateKey #33752 is open for the private modes.case Kty::Ec: the decode ofdhas no exception check before its result is used. That line is the whole of node:crypto: fix null deref when worker.terminate() lands during an EC JWK private key import #37443, which is open.case Kty::Rsa: another key'sn. node:crypto: validate RSA private key material on JWK import #33914 was closed by a stale-PR cleanup, with no judgment on the fix.importKey("jwk")for Ed25519 and X25519. webcrypto: validate the OKP JWK x parameter against d on import #32827 was closed by the same cleanup.ktystill givesERR_INVALID_ARG_TYPEorERR_INVALID_ARG_VALUE. Node givesERR_CRYPTO_INVALID_JWK.Tests.
crypto.key-objects.test.ts: 13 malformed or inconsistent JWKs at 12 call forms on 2 curves, the read order, a getter forx, the order of the twocreatePrivateKeyerrors, and base64 forms ofx. 2 of the 344 new tests pass onmainby design: they pin that a consistent private JWK still imports as a public key.crypto-pqc.test.ts: the AKP order change.test/js/node/test/changes. An earlier head added the OKP block of upstreamtest-crypto-key-objects.js, upstream v26.10.0'stest-crypto-jwk-raw-validation.js, and anisBoringSSLexport totest/common/crypto.js. That was wrong:test/js/node/test/parallel/CLAUDE.mdsays those tests are not modified,test/common/crypto.jswas last synced to v26.3.0, and neither that file nor that export exists at v26.3.0. The four upstream OKP assertions are rows of the table above.crv, an AKPalgused ascrv, a getter that throws, and the AKP order of thealgcheck against thepubcheck. The removed upstream file covered them, and it passed on cd53fd7. Tests for them are written and not pushed, because I could not run them on a build of this change.BUN_JSC_validateExceptionChecks=1, andscripts/jsc-exception-lintonKeyObject.cpp(no finding in the file).Self-review. I checked the diff by hand for: the order of checks against Node's source, the lifetime of the two decoded buffers across allocations, exception checks after each call that can throw, the BoringSSL length checks that replace the deleted per-curve switch (Ed25519, X25519, ML-DSA, ML-KEM), and the state of a public key object that holds private material. None needed a code change.
History. The first version of this PR compared
xwithdonly when the caller asked for a private key. That closedcreatePrivateKeyandsignand leftcreatePublicKeyandverifyopen.