Skip to content

Bun.password: reject argon2 cost options that verify would refuse - #33865

Open
robobun wants to merge 5 commits into
mainfrom
farm/7cc74278/password-argon2-cost-bounds
Open

robobun wants to merge 5 commits into
mainfrom
farm/7cc74278/password-argon2-cost-bounds

Conversation

@robobun

@robobun robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.password.hash accepts argon2 timeCost up to 4294967295 and memoryCost up to 4294967295 KiB. Bun.password.verify refuses any PHC string with t > 65536, m > 4194304, or p > 64 (PASSWORD_WEAK_PARAMETERS, a DoS prescan in str_verify, src/runtime/crypto/pwhash.rs:230). So hash() can produce a hash that Bun's own verify() rejects:
    const h = Bun.password.hashSync("p", { algorithm: "argon2id", memoryCost: 8, timeCost: 65537 });
    Bun.password.verifySync("p", h); // throws PASSWORD_WEAK_PARAMETERS
  • The cause is AlgorithmValue::from_js (src/runtime/crypto/PasswordObject.rs:100-135). It range-checks both options against u32::MAX, not against the verify ceilings.

Fix

  • Export MAX_VERIFY_TIME_COST (65536) and MAX_VERIFY_MEMORY_COST (4194304) from pwhash::argon2 as pub(crate) and use them as the upper bounds in from_js. One check per option now covers integer, lower bound, and upper bound, the same shape the bcrypt cost check already has.
  • Error messages state the real range: "Time cost must be an integer between 1 and 65536", "Memory cost must be an integer between 8 and 4194304". bun.d.ts JSDoc documents the same bounds.
  • Correct because the bounds now come from one place. Any value hash() accepts, verify() accepts.
  • Verified: test/js/bun/util/password.test.ts (release bun 1.4.3 fails 7, debug build passes 78, skips 9 debug-gated slow cases). A release-only test round-trips timeCost: 65536 through hash and verify.

Background

  • Argon2 PHC strings encode their own cost parameters ($argon2id$v=19$m=...,t=...,p=...$). verify() must run argon2 with whatever the string says, so an attacker-supplied string with huge m/t is a CPU and memory DoS. str_verify prescans the params and rejects values above fixed ceilings before it computes anything.
  • hash() options come from the developer, not an attacker, so they had only the lower bounds argon2 itself needs (t >= 1, m >= 8 * p with p = 1). Bun.udpSocket, Bun.password: range-check numeric options before ToInt32 narrowing #36999 later added the integer and u32::MAX checks. Neither matched the verify ceilings.
Notes

Previous description (before #36999 landed):

Repro

// hash accepts timeCost one past the verify ceiling, then verify refuses Bun's own output
const h = Bun.password.hashSync("p", { algorithm: "argon2id", memoryCost: 8, timeCost: 65537 });
// h === "$argon2id$v=19$m=8,t=65537,p=1$..."
Bun.password.verifySync("p", h);
// throws { code: "PASSWORD_WEAK_PARAMETERS" }

// out-of-i32-range options silently wrap mod 2^32 into near-minimum cost
Bun.password.hashSync("p", { algorithm: "argon2id", memoryCost: 2**32 + 64, timeCost: 2**32 + 2 });
// "$argon2id$v=19$m=64,t=2,p=1$..."  (no error)

Cause

src/runtime/crypto/PasswordObject.rs reads timeCost / memoryCost via coerce_to_i32 (ECMA ToInt32, wraps mod 2^32) and validates only the lower bounds (>= 1 / >= 8). src/runtime/crypto/pwhash.rs str_verify runs a DoS prescan on the PHC params of every input, rejecting t > 1<<16, m > 1<<22, p > 64 as WeakParameters. The two bounds disagree, so hash() can mint PHC strings that verify() refuses. The bcrypt cost option has the same ToInt32 wrap (2**32 + 5 silently becomes cost: 5).

Fix

Expose MAX_VERIFY_TIME_COST / MAX_VERIFY_MEMORY_COST from pwhash::argon2 and apply them as upper bounds in AlgorithmValue::from_js. Compare the JS number as f64 (via as_number()) before casting so out-of-range and non-finite values throw instead of wrapping. The bcrypt cost range check gets the same f64 treatment.

Error messages now state both bounds ("Time cost must be between 1 and 65536", "Memory cost must be between 8 and 4194304").

Overlaps #32279, which swaps coerce_to_i32 for saturating coerce::<i32> at the same three sites but intentionally leaves the missing upper bound as an orthogonal concern. This PR supersedes the PasswordObject.rs hunk of #32279; the other files in that PR are untouched here.

Verification

test/js/bun/util/password.test.ts:

  • USE_SYSTEM_BUN=1 bun test fails 6 tests (hash accepts timeCost: 65537, memoryCost: 4194305, and 2**32 + N)
  • bun bd test passes 72, skips 9 (debug-gated slow argon2 cases)

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

fails on main (without fix)
ASAN without fix: 6 failed, 9 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/password.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (55c8cedb5)

test/js/bun/util/password.test.ts:
(skip) does not leak > hashSync
(skip) does not leak > hash
(pass) hash > arguments parsing > no blank password allowed [3.68ms]
(pass) hash > arguments parsing > password is required [2.41ms]
117 |           expect(() =>
118 |             hash(placeholder, {
119 |               algorithm: "argon2id",
120 |               memoryCost: invalid,
121 |             }),
122 |           ).toThrow("Memory cost must be between 8 and 4194304");
                  ^
error: expect(received).toThrow(expected)

Expected substring: "Memory cost must be between 8 and 4194304"
Received message: "Memory cost must be at least 8"

      at <anonymous> (/workspace/bun/test/js/bun/util/password.test.ts:122:13)
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (55c8cedb5)

test/js/bun/util/password.test.ts:
(pass) does not leak > hashSync [2048.19ms]
(pass) does not leak > hash [1119.86ms]
(pass) hash > arguments parsing > no blank password allowed [0.16ms]
(pass) hash > arguments parsing > password is required [0.03ms]
(pass) hash > arguments parsing > invalid algorithm throws [0.31ms]
(pass) hash > arguments parsing > argon2 cost upper bounds match verify [0.05ms]
(pass) hash > arguments parsing > out-of-i32-range cost options throw instead of wrapping [0.12ms]
(pass) hash > arguments parsing > coercion throwing doesn't crash [0.06ms]
(pass) hash > arguments parsing > empty Uint8Array throws [0.04ms]
(pass) hash > arguments parsing > empty Uint16Array throws
(pass) hash > arguments parsing > empty Uint32Array throws
(pass) hash > arguments parsing > empty Int8Array throws
(pass) hash > arguments parsing > empty Int16Array throws
(pass) hash > arguments parsing > empty Int32Array throws
(pass) hash > arguments parsing > empty Float16Array throws
(pass) hash > arguments parsing > empty Float32Array throws
(pass) hash > arguments parsing > empty Float64Array throws
(pass) hash > arguments parsing >
... (truncated)
passes on PR (with fix)
ASAN with fix: 9 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/password.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (55c8cedb5)

test/js/bun/util/password.test.ts:
(skip) does not leak > hashSync
(skip) does not leak > hash
(pass) hash > arguments parsing > no blank password allowed [3.81ms]
(pass) hash > arguments parsing > password is required [2.74ms]
(pass) hash > arguments parsing > invalid algorithm throws [22.32ms]
(pass) hash > arguments parsing > argon2 cost upper bounds match verify [5.49ms]
(pass) hash > arguments parsing > out-of-i32-range cost options throw instead of wrapping [12.90ms]
(pass) hash > arguments parsing > coercion throwing doesn't crash [5.11ms]
(pass) hash > arguments parsing > empty Uint8Array throws [2.87ms]
(pass) hash > arguments parsing > empty Uint16Array throws [0.92ms]
(pass) hash > arguments parsing > empty Ui
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 702ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 90 exports (host=3, lazy=10, generic=77, rust=0); 254 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

info: checking for self-update (current version: 1.29.0)
  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

^[[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)
^[[1
... (truncated)
diff hotspot
packages/bun-types/bun.d.ts          |  4 ++--
 src/runtime/crypto/PasswordObject.rs | 32 +++++++++++++++++------------
 src/runtime/crypto/pwhash.rs         |  7 +++++--
 test/js/bun/util/password.test.ts    | 40 +++++++++++++++++++++++++++++++++++-
 4 files changed, 65 insertions(+), 18 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                  reads  edits  tests
packages/bun-types/bun.d.ts               1      1      0
src/runtime/crypto/PasswordObject.rs      1      2      0
src/runtime/crypto/pwhash.rs              1      1      0
test/js/bun/util/password.test.ts         1      3      0

Bun.password.hash accepted any timeCost >= 1 and memoryCost >= 8, while
Bun.password.verify runs a prescan that rejects PHC params above
t > 65536, m > 4194304, or p > 64 with WeakParameters. A hash produced
with e.g. timeCost: 65537 therefore could never be verified by Bun.

Separately, the cost options were read via JSC ToInt32 (mod 2^32 wrap),
so values like memoryCost: 2**32 + 64 silently became m=64 instead of
throwing.

hash() now validates timeCost and memoryCost against the same upper
bounds that verify() enforces, comparing the f64 value directly so
out-of-range inputs throw instead of wrapping. bcrypt cost gets the same
wrap-free range check.
@github-actions github-actions Bot added the claude label Jul 9, 2026
@robobun

robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:49 PM PT - Sep 6th, 2026

@robobun, your commit bc32b68 is building: #111748

@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file.

Or wait 36 seconds for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 28a5560d-56c7-402b-b904-06cd1e00a449

📥 Commits

Reviewing files that changed from the base of the PR and between d316760 and 216f464.

📒 Files selected for processing (4)
  • packages/bun-types/bun.d.ts
  • src/runtime/crypto/PasswordObject.rs
  • src/runtime/crypto/pwhash.rs
  • test/js/bun/util/password.test.ts

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

Comment thread src/runtime/crypto/pwhash.rs Resolved
@robobun
robobun requested a review from alii as a code owner July 9, 2026 18:45

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

No issues found — the validation tightening looks correct and well-tested, but since this touches crypto option parsing in src/runtime/crypto/ and CODEOWNER'd packages/bun-types/, deferring to a human for sign-off.

Extended reasoning...

Overview

This PR aligns Bun.password.hash's argon2 timeCost/memoryCost upper bounds with the ceilings that str_verify already enforces on incoming PHC strings, so hash() can no longer produce a hash that Bun's own verify() refuses. It also fixes silent mod-2^32 wrapping by comparing the raw f64 (as_number()) instead of coerce_to_i32, and gives bcrypt cost the same treatment. Changes: two consts made pub(crate) in pwhash.rs, three option-parse blocks in PasswordObject.rs, new tests in password.test.ts, and a JSDoc update in bun.d.ts (added after my earlier nit).

Security risks

The change strictly narrows accepted input — it rejects more values, not fewer — and does not touch the crypto primitives themselves. NaN/Infinity/out-of-range now throw instead of wrapping to a low cost, which is a security improvement (previously 2**32 + 2 silently became timeCost: 2). The is_number() guard precedes each as_number() call, and the f64 range check runs before the as u8/as u32 cast, so no new UB or truncation surprises. Fractional values still truncate toward zero, matching the old coerce_to_i32 behavior.

Level of scrutiny

This is input validation in src/runtime/crypto/, which is security-adjacent even though no cipher code changes. Per policy I don't auto-approve crypto-path changes. Additionally, packages/bun-types/ is CODEOWNER'd (@alii), so the .d.ts hunk needs owner review even though it's a two-line JSDoc tweak.

Other factors

The bug-hunting pass found nothing. Test coverage is thorough: one-past-ceiling values, 2**32 + N wrapping, 2**31 boundary, all three argon2 variants, bcrypt cost, plus a release-only round-trip at the exact t=65536 ceiling. The PR description confirms USE_SYSTEM_BUN=1 fails 6 tests and bun bd test passes. My earlier nit (document the new bounds in bun.d.ts) was addressed in a460898. The change is small and mechanically sound; deferral is purely because of the crypto/CODEOWNER policy gates, not any concern with the 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.

I didn't find any bugs — the f64 range checks after is_number() are sound and the bounds derive from the single source of truth in pwhash.rs — but since this tightens the accepted value range on a user-facing crypto API and overlaps #32279, it's worth a human sign-off.

Extended reasoning...

Overview

This PR aligns Bun.password.hash()'s argon2 timeCost/memoryCost upper bounds with the ceilings that Bun.password.verify() already enforces on PHC params, and fixes silent mod-2³² wrapping of out-of-i32-range cost options (including bcrypt cost). Changes: PasswordObject.rs swaps coerce_to_i32 for as_number() + f64 range checks at three sites; pwhash.rs promotes two private constants to pub(crate); bun.d.ts documents the new bounds; password.test.ts adds boundary/wrap coverage.

Security risks

None identified. The change is input-validation-only and strictly tightens accepted ranges — no crypto primitive logic is touched. The is_number() guard precedes every as_number() call, so the f64 read is sound; NaN/±Infinity are correctly rejected by RangeInclusive::contains; the post-check as u8/as u32 casts are safe because the f64 is proven in-range. The removed & 0x3F mask on bcrypt cost was redundant given the 4..=31 check.

Level of scrutiny

Moderate. The code is mechanically simple and well-tested, but it lives in src/runtime/crypto/ and changes the accepted value range of a user-facing password-hashing API — previously-accepted calls with timeCost > 65536 or memoryCost > 4194304 will now throw. That's the correct behavior (Bun was producing hashes its own verify refused), but it's a user-visible API contract change that a maintainer should acknowledge.

Other factors

  • My earlier docs nit was addressed in a460898 and the thread is resolved.
  • The PR description notes overlap with #32279 (which touches the same three PasswordObject.rs sites); a human should confirm the intended merge order.
  • Test coverage is thorough: boundary-at-ceiling round-trip, one-past-ceiling rejection, 2³²-wrap cases across all three argon variants plus bcrypt, and the release-only t=65536 hash→verify round-trip.

@robobun

robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status at 216f464: test/js/bun/util/password.test.ts passes on every lane. The remaining red is unrelated to this diff:

  • build 111763: test/js/node/test/parallel/test-crypto-dh-leak.js on debian 13 x64-asan (Diffie-Hellman RSS threshold, 24.7 MB growth against a 20 MB limit under ASAN). Reported separately as a main break.
  • build 111748: Docker Hub returned 502 for the postgres:15 and mariadb:11.8 image pulls, so the SQL service tests could not start.

Nothing red touches src/runtime/crypto/ or Bun.password. The diff is ready for review.

…d-argon2-cost-bounds

# Conflicts:
#	src/runtime/crypto/PasswordObject.rs
#	test/js/bun/util/password.test.ts
Comment thread src/runtime/crypto/pwhash.rs Resolved
@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Merged main into this branch to resolve conflicts with #36999 and #39596. #36999 already landed the mod-2^32 wrap fix, so the diff here is now only the argon2 upper bounds (timeCost <= 65536, memoryCost <= 4194304, the same ceilings verify() enforces) plus the error messages and JSDoc. The PR description is updated to match.

The bug still reproduces on bun 1.4.3: hashSync("p", { algorithm: "argon2id", memoryCost: 8, timeCost: 65537 }) succeeds and verifySync on the result throws PASSWORD_WEAK_PARAMETERS. test/js/bun/util/password.test.ts fails 7 tests on 1.4.3 and passes on the debug build of this branch (78 pass, 9 debug-gated skips).

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

Code review found no issues

No high-confidence issues detected in this change.

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

Code review found no issues

No high-confidence issues detected in this change.

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