Skip to content

crypto: accept argon2 PHC params in any order in Bun.password.verify - #32314

Open
robobun wants to merge 2 commits into
mainfrom
claude/15ef5c23/argon2-phc-param-order
Open

crypto: accept argon2 PHC params in any order in Bun.password.verify#32314
robobun wants to merge 2 commits into
mainfrom
claude/15ef5c23/argon2-phc-param-order

Conversation

@robobun

@robobun robobun commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Repro

$ bun -e 'const h=Bun.password.hashSync("password",{algorithm:"argon2id",memoryCost:16,timeCost:2});const r=h.replace(/m=(\d+),t=(\d+),p=(\d+)/,"t=$2,m=$1,p=$3");console.log(Bun.password.verifySync("password",r))'
error: Password verification failed with error "InvalidEncoding"
 code: "PASSWORD_INVALID_ENCODING"

Bun 1.3.x (Zig) prints true for the same input.

Cause

Zig std's phc_format.deserialize into HashResult{m,t,p} parses key=value pairs by field name and accepts them in any order. The Rust port delegates to rust-argon2's verify_encoded, whose decode_options requires the literal positional order m,t,p and returns DecodingFail (mapped to InvalidEncoding) otherwise.

Hashes emitted by other ecosystems (PHP password_hash, some Go argon2 wrappers) do not all use the canonical m,t,p order, so externally-generated hashes that verified on earlier Bun releases now fail.

Fix

str_verify already pre-scans the PHC string to splice in a missing v=19 segment and to enforce the verify-time DoS limits, iterating the params segment order-agnostically. Extend that single pass to capture each m=/t=/p= pair and, when the order is not already canonical (or v= is absent), rebuild the string as $<alg>$v=19$<m>,<t>,<p>$<salt>$<hash> before handing off to rust-argon2. The common case (v=19 present, m,t,p already canonical) stays Cow::Borrowed with no allocation. Unknown / duplicate / malformed params fall through unchanged for rust-argon2 to reject as before.

Verification

New test in test/js/bun/util/password.test.ts covers all six m/t/p permutations (sync + async, correct and wrong password), the composition with a missing v= segment, the DoS limit check on a reordered segment, and rejection of missing/duplicate/unknown params.

  • USE_SYSTEM_BUN=1 bun test test/js/bun/util/password.test.ts -t 'm/t/p in any order' → fail (InvalidEncoding)
  • bun bd test test/js/bun/util/password.test.ts → 69 pass / 0 fail

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

fails on main (without fix)
ASAN without fix: 1 failed, 8 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 (63d1ac564)

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.92ms]
(pass) hash > arguments parsing > password is required [2.46ms]
(pass) hash > arguments parsing > invalid algorithm throws [19.54ms]
(pass) hash > arguments parsing > coercion throwing doesn't crash [5.34ms]
(pass) hash > arguments parsing > empty Uint8Array throws [2.98ms]
(pass) hash > arguments parsing > empty Uint16Array throws [1.00ms]
(pass) hash > arguments parsing > empty Uint32Array throws [0.79ms]
(pass) hash > arguments parsing > empty Int8Array throws [0.77ms]
(pass) hash > arguments parsing > empty Int16Array throws [0.68ms]
(pass) hash > argument
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (d06d93167)

test/js/bun/util/password.test.ts:
(pass) does not leak > hashSync [2063.48ms]
(pass) does not leak > hash [802.17ms]
(pass) hash > arguments parsing > no blank password allowed [0.24ms]
(pass) hash > arguments parsing > password is required [0.06ms]
(pass) hash > arguments parsing > invalid algorithm throws [0.46ms]
(pass) hash > arguments parsing > coercion throwing doesn't crash [0.11ms]
(pass) hash > arguments parsing > empty Uint8Array throws [0.06ms]
(pass) hash > arguments parsing > empty Uint16Array throws [0.01ms]
(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 > empty ArrayBuffer throws [0.04ms]
(pass) hash > arguments parsing > no blank password allowed [0.01ms]
(pass) hash > arguments parsing > password is required
(pass) hash >
... (truncated)
passes on PR (with fix)
ASAN with fix: 8 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 (63d1ac564)

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 [4.17ms]
(pass) hash > arguments parsing > password is required [2.38ms]
(pass) hash > arguments parsing > invalid algorithm throws [19.95ms]
(pass) hash > arguments parsing > coercion throwing doesn't crash [5.22ms]
(pass) hash > arguments parsing > empty Uint8Array throws [2.92ms]
(pass) hash > arguments parsing > empty Uint16Array throws [0.72ms]
(pass) hash > arguments parsing > empty Uint32Array throws [0.55ms]
(pass) hash > arguments parsing > empty Int8Array throws [0.49ms]
(pass) hash > arguments parsing > empty Int16Array throws [0.47ms]
(pass) hash > argument
... (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 686ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[1/6] 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

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�
... (truncated)
diff hotspot
src/runtime/crypto/pwhash.rs      | 136 ++++++++++++++++++++++++--------------
 test/js/bun/util/password.test.ts |  60 +++++++++++++++++
 2 files changed, 147 insertions(+), 49 deletions(-)

gate history · 1 passed · 0 rejected · iteration 7

evidence per changed file
file                               reads  edits  tests
src/runtime/crypto/pwhash.rs           3      4      4
test/js/bun/util/password.test.ts      2      2      4

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 47 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: 723bae10-b56d-4a7d-9b4c-6f3c3857f7e8

📥 Commits

Reviewing files that changed from the base of the PR and between d06d931 and 63d1ac5.

📒 Files selected for processing (2)
  • src/runtime/crypto/pwhash.rs
  • test/js/bun/util/password.test.ts

Walkthrough

Changes

Argon2 PHC verification

Layer / File(s) Summary
PHC parsing and normalization
src/runtime/crypto/pwhash.rs
argon2::str_verify validates versions and m/t/p limits, accepts parameters in any order, and canonicalizes noncanonical PHC strings before verification.
Verification behavior coverage
test/js/bun/util/password.test.ts
Tests cover reordered parameters, omitted versions, weak-parameter limits, malformed inputs, duplicates, unknown parameters, and sync/async verification.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: accepting Argon2 PHC parameters in any order during password verification.
Description check ✅ Passed The description covers what changed and how it was verified, even though it uses custom headings instead of the template's exact sections.
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.

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

@robobun

robobun commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with:

bun -e 'const h=Bun.password.hashSync("password",{algorithm:"argon2id",memoryCost:16,timeCost:2});const r=h.replace(/m=(\d+),t=(\d+),p=(\d+)/,"t=$2,m=$1,p=$3");console.log(Bun.password.verifySync("password",r))'

Fix is up in this PR; full password.test.ts suite passes locally (69 pass / 0 fail).

Rebased onto main after #33909 (the bun_core::err! → per-crate thiserror enum refactor) touched the same function; conflict was a mechanical crate::Error::* swap plus dropping the now-redundant second-pass limit loop. 63d1ac5 additionally defers the WeakParameters limit check until the m/t/p set is confirmed structurally valid (CodeRabbit feedback, resolved).

CI status (build 72253): 284/286 green including password.test.ts on every platform. Two unrelated HTTP failures on single lanes (test-http-agent-scheduling.js panic/OOM on darwin 26 aarch64; bun-serve-static.test.ts timeout on ubuntu 25.04 x64), both with no overlap with the pwhash.rs change and both reported separately as main breaks. Ready for review.

@robobun

robobun commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:35 AM PT - Jul 12th, 2026

@robobun, your commit 63d1ac5 has 2 failures in Build #72253 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32314

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

bun-32314 --bun

@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 issues — the merged pre-scan looks correct and the DoS limits are still enforced on every path rust-argon2 would actually compute on — but since this loosens input acceptance in the password-verification hot path, it's worth a quick human look.

Extended reasoning...

Overview

This PR modifies argon2::str_verify in src/runtime/crypto/pwhash.rs to accept PHC strings whose m=/t=/p= parameters appear in any order, restoring parity with the prior Zig phc_format behavior and with hashes produced by other ecosystems (PHP, Go). It does so by folding the two existing passes (the v=19 splice and the verify-time DoS-limit scan) into a single pre-scan that captures each param by name and, when needed, rebuilds the string in canonical $<alg>$v=19$m,t,p$<salt>$<hash> form before handing off to rust-argon2. The common case (v=19 present, canonical order) stays Cow::Borrowed with no allocation. A new test in password.test.ts covers all six permutations (sync + async, right + wrong password), composition with a missing v= segment, the DoS limit on a reordered segment, and rejection of missing/duplicate/unknown params.

Security risks

This is the argon2 password-verification path, so the relevant question is whether the refactor opens any way to (a) bypass the verify-time DoS limits, or (b) accept a hash that should be rejected. I traced every break 'norm Cow::Borrowed fall-through (no leading $, no alg separator, v= with no terminator, no params terminator, pair without =, value not u32-parseable, unknown key, duplicate key, fewer than three of m/t/p): in each case the unmodified string is handed to rust-argon2::decode_string, which requires the literal positional m,t,p shape and rejects with DecodingFail. So no fall-through reaches the actual hash computation, and every path that does compute has had the MAX_VERIFY_* limits applied. The v != "19" early-reject is preserved. The only intentional loosening is accepting reordered params, which matches the PHC spec and prior Bun behavior. I don't see an injection, bypass, or resource-exhaustion vector introduced here.

Level of scrutiny

High — this is crypto/auth code that loosens input acceptance in a verification function. Per the review guidelines I won't auto-approve changes in this category even when they look correct; a maintainer should sanity-check the fall-through reasoning above.

Other factors

The bug-hunting system found nothing. No CODEOWNERS entry covers this path. Test coverage for the new behavior is thorough (permutations, missing-version composition, DoS-limit preservation, malformed/duplicate/unknown rejection), and the full password.test.ts suite passes per the PR description. The diff is a clean consolidation of two adjacent passes into one with no change to the surrounding API surface.

@robobun
robobun force-pushed the claude/15ef5c23/argon2-phc-param-order branch from 1959892 to f84ee24 Compare June 15, 2026 10:12

@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 issues, but this loosens parsing on the password-verification path (security-sensitive crypto), so it's worth a human sanity check before merging.

Extended reasoning...

Overview

This PR changes argon2::str_verify in src/runtime/crypto/pwhash.rs to accept PHC-encoded argon2 hashes whose m=/t=/p= parameters appear in any order, restoring parity with the prior Zig phc_format behaviour and with hashes produced by other ecosystems (PHP, Go). It folds the existing version-splice and DoS-limit pre-scan into a single pass that captures each param pair and, when needed, rebuilds a canonical $<alg>$v=19$m=..,t=..,p=..$<salt>$<hash> string before delegating to rust-argon2. A new test in password.test.ts covers all six permutations, the missing-v= composition, the DoS-limit check on a reordered segment, and rejection of missing/duplicate/unknown params.

Security risks

This is the Bun.password.verify code path — auth/crypto by definition. The change broadens the set of encoded strings accepted on verify. I traced the logic and believe it's safe: the rebuilt string reuses the original key=value slices verbatim (just reordered), the per-param DoS limits are still enforced before any compute, and every malformed/unknown/duplicate case falls through as Cow::Borrowed to be rejected by rust-argon2 without hashing. One minor behavioural shift: parse failures in the params loop now break 'norm instead of continue, so a params segment with one malformed pair no longer gets the other pairs limit-checked here — but rust-argon2's decoder rejects such strings before any compute, so there's no DoS-bypass window. I did not spot a way to smuggle a higher cost or a different algorithm past the normaliser.

Level of scrutiny

High. Password verification is a security boundary, and this change relaxes input acceptance on that boundary. Even though the diff is small (~70 LoC net), well-commented, and thoroughly tested, my policy is not to auto-approve crypto/auth changes.

Other factors

  • No CODEOWNERS entry covers these files.
  • No outstanding human review comments.
  • The bug-hunting system found no issues.
  • Test coverage is comprehensive and the PR description includes a clear repro and before/after verification.

Deferring to a human reviewer purely on the security-sensitivity criterion; the implementation itself looks correct to me.

Zig std's phc_format deserialises the m/t/p parameters by field name in
any order; rust-argon2's decode_string requires the literal positional
order m,t,p and returns DecodingFail otherwise. Hashes emitted by other
ecosystems (PHP password_hash, some Go wrappers) do not all use the
canonical order, so verify() regressed to InvalidEncoding on strings
that earlier Bun releases accepted.

The pre-scan in str_verify already walked the params segment
order-agnostically for the DoS limit check; extend it to capture each
pair by name and rebuild the string in canonical m,t,p order (with the
version segment inserted if absent) before handing off to rust-argon2.
The happy path (version present, params already canonical) is still
zero-alloc via Cow::Borrowed. Unknown / duplicate / malformed params
fall through unchanged for rust-argon2 to reject as before.
@robobun
robobun force-pushed the claude/15ef5c23/argon2-phc-param-order branch from f84ee24 to d06d931 Compare July 12, 2026 14:29

@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: 1

🤖 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/runtime/crypto/pwhash.rs`:
- Around line 228-260: Update the parameter parsing in the normalization flow
around m_pair, t_pair, and p_pair so recognized values are stored without
returning WeakParameters immediately. First validate the complete set—exactly
one each of m, t, and p, with no malformed, duplicate, or unknown entries—and
preserve malformed encodings for rust-argon2 InvalidEncoding; only after this
validation apply MAX_VERIFY_MEMORY_COST, MAX_VERIFY_TIME_COST, and
MAX_VERIFY_PARALLELISM and return WeakParameters for oversized values. Add a
test covering a malformed or incomplete parameter set that also contains an
oversized recognized value, ensuring it remains InvalidEncoding regardless of
order.
🪄 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: b072f9a3-fa18-4e51-b904-140e39955f56

📥 Commits

Reviewing files that changed from the base of the PR and between 2e2230a and d06d931.

📒 Files selected for processing (2)
  • src/runtime/crypto/pwhash.rs
  • test/js/bun/util/password.test.ts

Comment thread src/runtime/crypto/pwhash.rs Outdated
…structurally valid

A params segment that is both malformed (missing/duplicate/unknown key)
and carries an oversized value now consistently surfaces as
InvalidEncoding regardless of where the oversized value sits; the
WeakParameters limit check fires only on a structurally-valid m/t/p
triple.

@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 bugs found, but deferring to a human reviewer since this touches the Bun.password.verify crypto path.

Checked: the reorder pass only rewrites the params segment when exactly one each of m/t/p is present (any other shape falls through unchanged to rust-argon2); the DoS limit check still fires on reordered segments and now runs only after structural validation so malformed+oversized stays InvalidEncoding; the canonical fast path stays Cow::Borrowed with no allocation; tail retains its leading $ so the rebuilt string is well-formed.

Extended reasoning...

Overview

Extends the existing PHC pre-scan in argon2::str_verify (src/runtime/crypto/pwhash.rs) to accept m=/t=/p= in any order, restoring parity with the earlier Zig phc_format behavior. The two pre-existing passes (splice missing v=19; enforce verify-time DoS limits) are merged into a single scan that also captures each param pair and, when non-canonical, rebuilds the string as $<alg>$v=19$m,t,p$<salt>$<hash> before delegating to rust-argon2::verify_encoded. A new test in test/js/bun/util/password.test.ts covers all six permutations, composition with a missing v= segment, DoS-limit enforcement on reordered params, and rejection of malformed/duplicate/unknown/oversized-but-malformed segments. CodeRabbit's one finding (order-dependent WeakParameters vs InvalidEncoding) was addressed in 63d1ac5 and the thread is resolved.

Security risks

This is a password-verification code path. The change loosens accepted input encodings, so the key question is whether it can cause a mismatched password to verify or bypass the DoS limits. I traced through and believe not: the normalizer only rewrites when the params segment is exactly {m,t,p} with parseable u32 values, otherwise it passes the original string through unchanged for rust-argon2 to reject; the DoS limit check is preserved and now applied after structural validation; and the actual constant-time hash comparison remains entirely inside rust-argon2. There is no new user-controlled allocation sizing (String::with_capacity is bounded by the input length).

Level of scrutiny

High — Bun.password is a user-facing credential-verification API. Even though the change is a bounded string-normalization step in front of an unchanged third-party verifier, crypto/auth paths in this repo warrant human sign-off rather than bot approval.

Other factors

Test coverage is thorough (permutations, sync+async, wrong-password, no-version composition, DoS limit, malformed variants). No outstanding reviewer comments. No CODEOWNERS entry for this path. The bug-hunting system found no issues.

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