From d28e887facf46a2a5999ca1008bfb583d2d4c3a1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:37:24 +0000 Subject: [PATCH 1/2] fix(Bun.password): accept optional v= segment in PHC bcrypt hashes The PHC string format allows an optional $v=$ segment between the algorithm id and the parameters. Zig std.crypto.phc_format parses and discards it for bcrypt, but the hand-rolled parser in pwhash.rs expected r= immediately after $bcrypt$, so verifySync on a hash like $bcrypt$v=19$r=4$... threw InvalidEncoding instead of returning a bool. --- src/runtime/crypto/pwhash.rs | 12 +++++++++++- test/js/bun/util/password.test.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/runtime/crypto/pwhash.rs b/src/runtime/crypto/pwhash.rs index c03802cfce5..e91d05a558b 100644 --- a/src/runtime/crypto/pwhash.rs +++ b/src/runtime/crypto/pwhash.rs @@ -385,7 +385,7 @@ pub mod bcrypt { } } - /// Verify a PHC-encoded bcrypt hash: `$bcrypt$r=N$$`. + /// Verify a PHC-encoded bcrypt hash: `$bcrypt$[v=V$]r=N$$`. /// /// The Rust `bcrypt` crate has no PHC codec, so parse the string here, /// recompute via the raw block cipher, and compare the 23-byte digests. @@ -399,6 +399,16 @@ pub mod bcrypt { return Err(bun_core::err!("PasswordVerificationFailed")); } + // Optional `v=` segment (PHC string-format spec). bcrypt has + // no versioning that affects the digest, so accept and discard it. + let rest = match rest.strip_prefix("v=") { + Some(after_v) => { + let (_, rest) = after_v.split_once('$').ok_or_else(invalid)?; + rest + } + None => rest, + }; + // r=N (rounds must fit in 6 bits; checked below) let (params, rest) = rest.split_once('$').ok_or_else(invalid)?; let rounds_str = params.strip_prefix("r=").ok_or_else(invalid)?; diff --git a/test/js/bun/util/password.test.ts b/test/js/bun/util/password.test.ts index 99926d4aa7b..0fefe787d3a 100644 --- a/test/js/bun/util/password.test.ts +++ b/test/js/bun/util/password.test.ts @@ -262,6 +262,20 @@ test("bcrypt pre-hashing does not break compatibility across Bun versions", asyn expect(await password.verify(secret, hash)).toBeTrue(); }); +test("bcrypt PHC-format hashes accept the optional v= segment", async () => { + // $bcrypt$r=4$$ derived from the modular-crypt hash of "hello" at cost 4. + const salt = "4/oeh1q2vAEB2Dxgn4q/UQ"; + const dk = "sf22627z3ZZphiGZ2tAwdvbTHXSRk5g"; + for (const v of ["", "v=19$", "v=2b$"]) { + const hash = `$bcrypt$${v}r=4$${salt}$${dk}`; + expect(password.verifySync("hello", hash)).toBeTrue(); + expect(password.verifySync("wrong", hash)).toBeFalse(); + expect(password.verifySync("hello", hash, "bcrypt")).toBeTrue(); + expect(await password.verify("hello", hash)).toBeTrue(); + expect(await password.verify("wrong", hash)).toBeFalse(); + } +}); + test("argon2 memoryCost at the 8 minimum is encoded faithfully (regression for #30960)", async () => { const hashed = await password.hash("test", { algorithm: "argon2id", From 62fedecbc75d3abd3fe6011978bf2b4c08b421c0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:18:03 +0000 Subject: [PATCH 2/2] ci: retrigger