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",