Skip to content

node:crypto: throw (not return) validation errors from createDiffieHellman - #36508

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/f3f235c7/dh-constructor-throw-not-return
Jul 31, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/f3f235c7/dh-constructor-throw-not-return

Conversation

@robobun

@robobun robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #31708 by @saklani.

Repro

const crypto = require("crypto");
try {
  const r = crypto.createDiffieHellman(2);
  console.log("returned:", r instanceof Error, r.code);  // returned: true ERR_INVALID_ARG_VALUE
} catch (e) {
  console.log("threw:", e.code);
}

Bun prints returned: true ERR_INVALID_ARG_VALUE; Node throws ERR_INVALID_ARG_VALUE.

Cause

Seven error paths in constructDiffieHellman() did return JSValue::encode(createError(...)), which returns an Error instance from the constructor rather than throwing it. A [[Construct]] that returns an object makes that object the result of the new-expression, so createDiffieHellman(2) evaluated to a TypeError instead of raising one.

The bn_g.setWord() failure path also called throwCryptoError() without returning, falling through into DHPointer::New() with a pending exception.

Fix

Mechanical swap to throwError(globalObject, scope, ...); return {}; at each site, matching the pattern used in the neighbouring ECDH bindings.

Verification

DiffieHellman > throws (not returns) validation errors from the constructor  (pass)

New test fails on the released binary (threw: false), passes on this branch. test/js/node/crypto/node-crypto.test.js is 203/203, and the vendored test-crypto-dh-constructor.js / test-crypto-dh-errors.js still pass.

The test captures control flow explicitly rather than using toThrow(), because toThrow() accepts a returned Error instance as a throw and cannot distinguish the two cases here.

Related

Overlaps with #33522, which fixes the same constructor paths as part of a larger DH_check() / verifyError change. Whichever lands first, the other rebases trivially.


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

fails on main (without fix)
ASAN without fix: 1 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/node-crypto.test.js
bun test v1.4.0 (f250508a3)

test/js/node/crypto/node-crypto.test.js:
(pass) crypto.randomBytes should return a Buffer [4.90ms]
(pass) crypto.randomInt should return a number [2.39ms]
(pass) crypto.randomInt with no arguments [4.24ms]
(pass) crypto.randomInt with one argument [2.61ms]
(pass) crypto.randomInt with a callback [40.92ms]
(pass) createHash > rsa-md5 - "Hello World" [8.82ms]
(pass) createHash > rsa-md5 - "Hello World" -> binary [4.94ms]
(pass) createHash > rsa-ripemd160 - "Hello World" [1.07ms]
(pass) createHash > rsa-ripemd160 - "Hello World" -> binary [1.05ms]
(pass) createHash > rsa-sha1 - "Hello World" [9.46ms]
(pass) createHash > rsa-sha1 - "Hello World" -> binary [1.89ms]
(pass) createHash > rsa-sha1-2 - "Hello World" [0.84ms]
(pass) createHash > rsa-sha1-2 - "Hello World" -> binary [0.95ms]
(pass) createHash > rsa-sha224 - "Hello World" [3.12ms]
(pass) createHash > rsa-sha224 - "Hello World" -> binary [0.89ms]
(pass) createHash > rsa-sha256 - "Hello World" [1.95ms]
(pass) create
... (truncated)

release without fix: 13 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/crypto/node-crypto.test.js:
(pass) crypto.randomBytes should return a Buffer [0.14ms]
(pass) crypto.randomInt should return a number [0.04ms]
(pass) crypto.randomInt with no arguments [0.13ms]
(pass) crypto.randomInt with one argument [0.07ms]
(pass) crypto.randomInt with a callback [0.75ms]
(pass) createHash > rsa-md5 - "Hello World" [0.20ms]
(pass) createHash > rsa-md5 - "Hello World" -> binary [0.11ms]
(pass) createHash > rsa-ripemd160 - "Hello World" [0.02ms]
(pass) createHash > rsa-ripemd160 - "Hello World" -> binary [0.03ms]
(pass) createHash > rsa-sha1 - "Hello World" [0.16ms]
(pass) createHash > rsa-sha1 - "Hello World" -> binary [0.08ms]
(pass) createHash > rsa-sha1-2 - "Hello World" [0.01ms]
(pass) createHash > rsa-sha1-2 - "Hello World" -> binary [0.01ms]
(pass) createHash > rsa-sha224 - "Hello World" [0.05ms]
(pass) createHash > rsa-sha224 - "Hello World" -> binary [0.01ms]
(pass) createHash > rsa-sha256 - "Hello World" [0.02ms]
(pass) createHash > rsa-sha256 - "Hello World" -> binary [0.01ms]
(pass) createHash > rsa-sha3-224 - "Hello World"
(pass) createHash > rsa-sha3-224 - "Hello World" -> binary
(pas
... (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/node-crypto.test.js
bun test v1.4.0 (f250508a3)

test/js/node/crypto/node-crypto.test.js:
(pass) crypto.randomBytes should return a Buffer [5.50ms]
(pass) crypto.randomInt should return a number [2.71ms]
(pass) crypto.randomInt with no arguments [4.92ms]
(pass) crypto.randomInt with one argument [2.47ms]
(pass) crypto.randomInt with a callback [42.07ms]
(pass) createHash > rsa-md5 - "Hello World" [8.87ms]
(pass) createHash > rsa-md5 - "Hello World" -> binary [5.58ms]
(pass) createHash > rsa-ripemd160 - "Hello World" [1.13ms]
(pass) createHash > rsa-ripemd160 - "Hello World" -> binary [1.49ms]
(pass) createHash > rsa-sha1 - "Hello World" [9.81ms]
(pass) createHash > rsa-sha1 - "Hello World" -> binary [1.84ms]
(pass) createHash > rsa-sha1-2 - "Hello World" [0.91ms]
(pass) createHash > rsa-sha1-2 - "Hello World" -> binary [0.92ms]
(pass) createHash > rsa-sha224 - "Hello World" [3.03ms]
(pass) createHash > rsa-sha224 - "Hello World" -> binary [0.99ms]
(pass) createHash > rsa-sha256 - "Hello World" [1.97ms]
(pass) create
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 824ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] cc obj/packages/bun-usockets/src/context.c.o
[2/8] gen cpp.rs (cppbind)
[3/8] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[3/8] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_http_jsc v0.0.0 (/workspace/bun/src/http_jsc)
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m   Compiling�[0m bun_bin v0.0.0 (/workspace/bun/src/bun_bin)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 5m 08s
[4/8] cxx obj/unified/UnifiedSource-src_jsc_bindings_node_crypto-0.cpp.o
[5/8] link bun-profile
[7/8] strip bun
[7/8] bun-profile --revision
1.4.0-canary.1+f250508a3
[build] done
bun test v1.4.0-canary.1 (f250508a3)

test/js/node/crypto/node-crypto.test.js:
(pass) crypto.randomBytes should return a Buffer [0.10ms]

... (truncated)
diff hotspot
.../node/crypto/JSDiffieHellmanConstructor.cpp     | 22 +++++++++++------
 test/js/node/crypto/node-crypto.test.js            | 28 ++++++++++++++++++++++
 2 files changed, 43 insertions(+), 7 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
…jsc/bindings/node/crypto/JSDiffieHellmanConstructor.cpp      1      2      0
test/js/node/crypto/node-crypto.test.js                       2      2      0

…llman

Several error paths in constructDiffieHellman() built their errors with
createError() and returned them, so crypto.createDiffieHellman(2)
evaluated to a TypeError instance rather than throwing one. A
constructor returning an object makes that object the result of the
new-expression, so the caller saw an Error where a DiffieHellman was
expected and no exception was ever raised. Node throws.

The bn_g.setWord() failure path also called throwCryptoError() without
returning, falling through into DHPointer::New() with a pending
exception.

Co-authored-by: saklani <50768838+saklani@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e347f71f-a0a7-47f2-9702-290354826d2e

📥 Commits

Reviewing files that changed from the base of the PR and between 468dac3 and f250508.

📒 Files selected for processing (2)
  • src/jsc/bindings/node/crypto/JSDiffieHellmanConstructor.cpp
  • test/js/node/crypto/node-crypto.test.js

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

@robobun

robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is green; ready for a maintainer.

Reproduced with crypto.createDiffieHellman(2) returning an Error instead of throwing; new test fails on main with threw: false, passes on this branch. test/js/node/crypto/node-crypto.test.js is 203/203 and the vendored test-crypto-dh-* pass. Adopts #31708.

CI (#85993, #86070): every failing lane is an unrelated flake that passed on retry (fetch.stream timeout, fastutf8stream-reopen race, install-registry hoisting, napi GC timing on Windows, streams-leak memory threshold, etc.). None touch crypto or the files in this diff. The one non-flaky hit in #85993 was s3.test.ts failing on a Cloudflare ServiceUnavailable + a missing Docker daemon; reported separately.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:crypto: throw (not return) validation errors from createDiffieHe… #31708 - Same fix: throws (instead of returns) validation errors from createDiffieHellman; this PR explicitly adopts node:crypto: throw (not return) validation errors from createDiffieHe… #31708

🤖 Generated with Claude Code

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

LGTM — mechanical swap from returning Error objects to throwing them.

What was reviewed:

  • All 7 createError sites converted to throwError(...) ; return {}, matching the pattern in JSECDHPrototype.cpp; the bn_g.setWord() fall-through now returns.
  • Remaining Bun::ERR::* calls in this constructor already take a ThrowScope& and throw correctly.
  • Test uses an explicit outcome() helper so a returned Error can't masquerade as a throw; covers both createDiffieHellman and new DiffieHellman entry points.
Extended reasoning...

Overview

Seven error paths in constructDiffieHellman() (src/jsc/bindings/node/crypto/JSDiffieHellmanConstructor.cpp) previously did return JSValue::encode(createError(...)), which returns an Error object from the constructor rather than throwing it — under [[Construct]] semantics that Error becomes the result of the new-expression. Each site is now throwError(globalObject, scope, ErrorCode::..., msg); return {};, and the bn_g.setWord() failure path gains its missing return {} after throwCryptoError. A regression test is added to the existing DiffieHellman describe block in test/js/node/crypto/node-crypto.test.js.

Security risks

None. This is error-path plumbing for argument validation; no cryptographic computation, key handling, or TLS state is touched. The only behavioral change is that invalid arguments now raise instead of returning an Error instance — strictly closer to Node semantics.

Level of scrutiny

Low. The transformation is purely mechanical and identical at every site. I verified throwError(JSGlobalObject*, ThrowScope&, ErrorCode, const WTF::String&) is declared in ErrorCode.h:63 and is already the established idiom in neighbouring ECDH bindings (JSECDHPrototype.cpp). The remaining early returns in this constructor use Bun::ERR::INVALID_ARG_TYPE/CRYPTO_OPERATION_FAILED, which take a ThrowScope& and already throw, so no other sites need conversion.

Other factors

The test correctly avoids toThrow() in favour of an explicit try/catch outcome() helper, so it distinguishes throw-vs-return unambiguously and would fail on the old build with threw: false. It exercises two distinct converted paths (ERR_INVALID_ARG_VALUE from DHPointer::New failure, ERR_INVALID_ARG_TYPE from the non-numeric-generator guard) plus the direct new DiffieHellman constructor route. The PR description reports the vendored Node test-crypto-dh-constructor.js/test-crypto-dh-errors.js still pass. No outstanding reviewer comments; no prior claude review on this PR.

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

LGTM — mechanical swap from return JSValue::encode(createError(...)) to throwError(...); return {}; at seven sites, plus the missing return {} after throwCryptoError in the bn_g.setWord() path.

What was reviewed:

  • Confirmed throwError(globalObject, scope, ErrorCode, msg) is the established pattern in the neighbouring ECDH bindings (JSECDHPrototype.cpp).
  • Traced the two test inputs through the constructor: createDiffieHellman(2) reaches the !dh guard after DHPointer::New(2, 2); createDiffieHellman(1024, "abc") reaches the int32-only generator guard before any DH generation, so the test stays fast.
  • Test uses an explicit outcome() helper to distinguish throw-vs-return rather than toThrow(), and covers both the factory and new DiffieHellman(...).
Extended reasoning...

Overview

The PR touches one C++ binding (JSDiffieHellmanConstructor.cpp) and one test file. Seven error paths in constructDiffieHellman() previously did return JSValue::encode(createError(...)), which — because a [[Construct]] returning an object substitutes that object for the newly-allocated this — made createDiffieHellman(2) evaluate to an Error instance rather than throw one. The fix is a mechanical swap to throwError(globalObject, scope, ErrorCode::…, msg); return {}; at each site, plus an added return {}; after the pre-existing throwCryptoError in the bn_g.setWord() failure branch (which previously fell through into DHPointer::New with a pending exception). The pattern is copied verbatim from the adjacent ECDH bindings, and throwError with this signature is declared in ErrorCode.h.

Security risks

None. This is in node:crypto but the change is purely about how validation failures are surfaced (throw vs. return). No parsing, allocation, key handling, or DH parameter logic is altered; error codes and messages are byte-identical to before.

Level of scrutiny

Low-medium. Native code in a JSC constructor, but the transformation is uniform and the surrounding throwCryptoError(...); return {}; sites in the same function already model exactly this shape. Each of the eight touched sites was audited individually against the diff; there are no other return JSValue::encode(createError(...)) sites left in the function.

Other factors

The new test lives in the existing DiffieHellman describe block in node-crypto.test.js, not a new file. It captures control flow explicitly ({threw, value}) instead of relying on toThrow(), so it fails on the released binary (threw: false) and passes here — satisfying the "prove the test fails for the right reason" bar. The (1024, "abc") case throws before DHPointer::New, so no expensive prime generation runs. The PR description notes vendored test-crypto-dh-constructor.js / test-crypto-dh-errors.js still pass. This adopts the community PR #31708 and is acknowledged to overlap with #33522, which the author says rebases trivially either way.

@Jarred-Sumner
Jarred-Sumner merged commit 081b614 into main Jul 31, 2026
53 of 54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/f3f235c7/dh-constructor-throw-not-return branch July 31, 2026 07:45
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