import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; const { argon2Spy } = vi.hoisted(() => ({ argon2Spy: vi.fn() })); vi.mock("node:crypto", async (importOriginal) => { const actual = await importOriginal(); argon2Spy.mockImplementation(actual.argon2); return { ...actual, argon2: argon2Spy }; }); import { verifyPassword } from "../src/auth/password.js"; interface Argon2Vector { password: string; phc: string; } const vectors = JSON.parse(readFileSync( resolve(import.meta.dirname, "fixtures/argon2id-vectors.json"), "utf8", )) as Argon2Vector[]; describe("local Argon2id password verification", () => { test("accepts every committed Go-generated vector", async () => { expect(vectors.length).toBeGreaterThan(0); for (const vector of vectors) { await expect(verifyPassword(vector.password, vector.phc)).resolves.toBe(true); } }); test("rejects a one-byte password change", async () => { for (const vector of vectors) { await expect(verifyPassword(`${vector.password}!`, vector.phc)).resolves.toBe(false); } }); test("rejects ill-formed Unicode instead of authenticating as U+FFFD", async () => { const replacementPassword = "correct horse battery stap\uFFFD"; const loneSurrogatePassword = "correct horse battery stap\uD800"; const replacementPasswordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$+tAXzgaQVnNaonNvgevyG6UKlaKcwyRMi1mESNk0BvQ"; await expect(verifyPassword(replacementPassword, replacementPasswordHash)).resolves.toBe(true); await expect(verifyPassword(loneSurrogatePassword, replacementPasswordHash)).resolves.toBe(false); }); test("accepts a multibyte password at exactly the 1024-byte boundary", async () => { const password = "é".repeat(512); const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$LfO3M3JBKeXf1knenCjQ6m9z4B7nedb0As2uc522I1I"; expect(Buffer.byteLength(password, "utf8")).toBe(1024); await expect(verifyPassword(password, passwordHash)).resolves.toBe(true); await expect(verifyPassword(`${password}é`, passwordHash)).resolves.toBe(false); }); test("rejects an over-limit password before converting it with Buffer.from", async () => { const password = "é".repeat(513); const fromSpy = vi.spyOn(Buffer, "from"); try { expect(Buffer.byteLength(password, "utf8")).toBe(1026); await expect(verifyPassword(password, vectors[0].phc)).resolves.toBe(false); expect(fromSpy.mock.calls.some(([value, encoding]) => value === password && encoding === "utf8")).toBe(false); } finally { fromSpy.mockRestore(); } }); test("rejects malformed and oversized PHC parameters before Argon2 allocation", async () => { const password = vectors[0].password; const digest = vectors[0].phc.split("$")[5]; const salt = vectors[0].phc.split("$")[4]; const cases = [ `$argon2id$v=19$m=262145,t=1,p=1$${salt}$${digest}`, `$argon2id$v=19$m=65536,t=11,p=1$${salt}$${digest}`, `$argon2id$v=19$m=65536,t=3,p=5$${salt}$${digest}`, `$argon2id$v=19$m=65536,t=3,p=1$${salt}$${"A".repeat(88)}`, `$argon2id$v=19$m=65536,t=3,p=1$${salt}=$${digest}`, ]; for (const phc of cases) { argon2Spy.mockClear(); await expect(verifyPassword(password, phc)).resolves.toBe(false); expect(argon2Spy).not.toHaveBeenCalled(); } }); });