diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts index 9fd77292..ea0a778d 100644 --- a/src/lib/auth/password.test.ts +++ b/src/lib/auth/password.test.ts @@ -1,4 +1,5 @@ -import { hash as bcryptHash } from "@node-rs/argon2"; +import { randomBytes } from "node:crypto"; +import { argon2id } from "hash-wasm"; import { describe, expect, it, vi } from "vitest"; const mockEnv = vi.hoisted(() => ({ @@ -29,10 +30,10 @@ describe("md5Hex", () => { }); describe("hashPassword (default driver: bcrypt)", () => { - it("emits an argon2id hash and round-trips", async () => { + it("emits a bcrypt hash and round-trips", async () => { mockEnv.PASSWORD_HASH = undefined; const h = await hashPassword("s3cret!"); - expect(h).toMatch(/^\$argon2id\$/); + expect(h).toMatch(/^\$2y\$\d{2}\$/); expect(await verifyPassword("s3cret!", h)).toBe(true); expect(await verifyPassword("wrong", h)).toBe(false); }); @@ -50,9 +51,25 @@ describe("hashPassword (PASSWORD_HASH=argon2id)", () => { }); }); -describe("argon2", () => { - it("verifies an argon2 hash and round-trips", async () => { - const h = await bcryptHash("hunter2"); +describe("verifyPassword", () => { + it("verifies argon2id hashes", async () => { + const h = await argon2id({ + password: "hunter2", + salt: randomBytes(16), + outputType: "encoded", + parallelism: 1, + iterations: 4, + memorySize: 1024, + hashLength: 32, + }); + expect(await verifyPassword("hunter2", h)).toBe(true); + expect(await verifyPassword("nope", h)).toBe(false); + }); + + it("verifies legacy bcrypt hashes ($2y$)", async () => { + mockEnv.PASSWORD_HASH = undefined; + const h = await hashPassword("hunter2"); + expect(h).toMatch(/^\$2y\$/); expect(await verifyPassword("hunter2", h)).toBe(true); expect(await verifyPassword("nope", h)).toBe(false); }); @@ -67,12 +84,12 @@ describe("isMd5Of", () => { }); describe("checkLogin", () => { - it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => { + it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => { mockEnv.PASSWORD_HASH = undefined; const stored = await md5Hex("oldpass"); const res = await checkLogin("oldpass", stored, { convertPasswords: true }); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$argon2id\$/); + expect(res.upgradedHash).toMatch(/^\$2y\$/); expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe( true, ); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 1f6e9051..2ec005b6 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -1,9 +1,11 @@ import { randomBytes } from "node:crypto"; -import { hash, verify } from "@node-rs/argon2"; -import { argon2id, argon2Verify, md5 } from "hash-wasm"; - -export const bcryptHash = (password: string) => hash(password); -export const bcryptCompare = (password: string, hash: string) => verify(hash, password); +import { + argon2id, + argon2Verify, + bcrypt, + bcryptVerify, + md5, +} from "hash-wasm"; import { env } from "@/env"; @@ -54,9 +56,14 @@ export async function hashPassword(password: string): Promise { ...argon2Params(), }); } - // native bcrypt emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the + // hash-wasm bcrypt emits $2a$; normalise to the PHP-canonical $2y$ the // emulator and existing AtomCMS rows use. - const h = await bcryptHash(password); + const h = await bcrypt({ + password, + salt: randomBytes(16), + costFactor: env.BCRYPT_ROUNDS, + outputType: "encoded", + }); return h.replace(/^\$2[ab]\$/, "$2y$"); } @@ -89,9 +96,7 @@ export async function verifyPassword( } if (/^\$2[aby]\$/.test(stored)) { try { - // PHP/AtomCMS store $2y$; native bcrypt only accepts $2a$/$2b$. - const normalized = stored.replace(/^\$2y\$/, "$2a$"); - return await bcryptCompare(password, normalized); + return await bcryptVerify({ password, hash: stored }); } catch { return false; }