- hashPassword now emits bcrypt (cost 12) instead of argon2id - checkLogin migrates legacy md5/argon2id hashes to bcrypt on sign-in - keep argon2id verification only as a one-time migration path - replace ARGON2_* env vars with BCRYPT_COST
124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
const mockEnv = vi.hoisted(() => ({
|
|
BCRYPT_COST: 12,
|
|
}));
|
|
|
|
vi.mock("@/env", () => ({
|
|
env: mockEnv,
|
|
}));
|
|
|
|
import {
|
|
checkLogin,
|
|
hashPassword,
|
|
isArgon2idOf,
|
|
isBcryptOf,
|
|
isMd5Of,
|
|
md5Hex,
|
|
verifyPassword,
|
|
} from "./password";
|
|
|
|
describe("md5Hex", () => {
|
|
it("matches PHP md5() on canonical vectors", async () => {
|
|
expect(await md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e");
|
|
expect(await md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72");
|
|
});
|
|
});
|
|
|
|
describe("hashPassword", () => {
|
|
it("emits a bcrypt hash and round-trips", async () => {
|
|
const h = await hashPassword("s3cret!");
|
|
expect(h).toMatch(/^\$2[aby]\$/);
|
|
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
|
expect(await verifyPassword("wrong", h)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isArgon2idOf", () => {
|
|
it("verifies a legacy argon2id hash (pre-migration accounts)", async () => {
|
|
const stored =
|
|
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
|
|
expect(await isArgon2idOf("test-password-123", stored)).toBe(true);
|
|
expect(await isArgon2idOf("wrong", stored)).toBe(false);
|
|
expect(await isArgon2idOf("anything", "$2y$12$ABC")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isBcryptOf", () => {
|
|
it("verifies a bcrypt hash", async () => {
|
|
const { bcrypt } = await import("hash-wasm");
|
|
const { randomBytes } = await import("node:crypto");
|
|
const stored = await bcrypt({
|
|
password: "hunter2",
|
|
salt: randomBytes(16),
|
|
costFactor: 10,
|
|
outputType: "encoded",
|
|
});
|
|
expect(await isBcryptOf("hunter2", stored)).toBe(true);
|
|
expect(await isBcryptOf("wrong", stored)).toBe(false);
|
|
expect(await isBcryptOf("anything", "$argon2id$v=19$")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isMd5Of", () => {
|
|
it("detects a legacy md5 password", async () => {
|
|
expect(await isMd5Of("habbo", await md5Hex("habbo"))).toBe(true);
|
|
expect(await isMd5Of("habbo", await md5Hex("other"))).toBe(false);
|
|
expect(await isMd5Of("habbo", "not-a-hash")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("verifyPassword", () => {
|
|
it("verifies bcrypt hashes", async () => {
|
|
const h = await hashPassword("hunter2");
|
|
expect(h).toMatch(/^\$2[aby]\$/);
|
|
expect(await verifyPassword("hunter2", h)).toBe(true);
|
|
expect(await verifyPassword("nope", h)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("checkLogin", () => {
|
|
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => {
|
|
const stored = await md5Hex("oldpass");
|
|
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
|
expect(res.valid).toBe(true);
|
|
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
|
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("does NOT upgrade md5 when conversion is disabled", async () => {
|
|
const stored = await md5Hex("oldpass");
|
|
const res = await checkLogin("oldpass", stored, {
|
|
convertPasswords: false,
|
|
});
|
|
expect(res.valid).toBe(false);
|
|
expect(res.upgradedHash).toBeUndefined();
|
|
});
|
|
|
|
it("migrates a legacy argon2id hash to bcrypt", async () => {
|
|
const stored =
|
|
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
|
|
const res = await checkLogin("test-password-123", stored, {
|
|
convertPasswords: true,
|
|
});
|
|
expect(res.valid).toBe(true);
|
|
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
|
});
|
|
|
|
it("accepts an existing bcrypt hash with no rehash", async () => {
|
|
const { bcrypt } = await import("hash-wasm");
|
|
const { randomBytes } = await import("node:crypto");
|
|
const stored = await bcrypt({
|
|
password: "modern",
|
|
salt: randomBytes(16),
|
|
costFactor: 10,
|
|
outputType: "encoded",
|
|
});
|
|
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
|
expect(res.valid).toBe(true);
|
|
expect(res.upgradedHash).toBeUndefined();
|
|
});
|
|
});
|