Files
ThothII/backend/test/auth-password.test.ts
T

119 lines
5.1 KiB
TypeScript

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<typeof import("node:crypto")>();
argon2Spy.mockImplementation(actual.argon2);
return { ...actual, argon2: argon2Spy };
});
import { isValidPasswordHash, 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}`,
`$argon2id$v=19$m=8,t=1,p=1$${salt}$${digest}`,
];
for (const phc of cases) {
argon2Spy.mockClear();
await expect(verifyPassword(password, phc)).resolves.toBe(false);
expect(argon2Spy).not.toHaveBeenCalled();
}
});
test("accepts only the exact Go Argon2id policy in registry PHCs", () => {
const [empty, algorithm, version, parameters, salt, digest] = vectors[0].phc.split("$");
expect(empty).toBe("");
expect(algorithm).toBe("argon2id");
expect(version).toBe("v=19");
expect(isValidPasswordHash(`$${algorithm}$${version}$m=8,t=1,p=1$${salt}$${digest}`)).toBe(false);
expect(isValidPasswordHash(`$${algorithm}$${version}$m=65536,t=1,p=1$${salt}$${digest}`)).toBe(false);
expect(isValidPasswordHash(`$${algorithm}$${version}$m=65536,t=3,p=2$${salt}$${digest}`)).toBe(false);
expect(isValidPasswordHash(`$${algorithm}$${version}$${parameters}$${salt}$${digest}`)).toBe(true);
});
test("rejects raw-base64 PHCs with non-zero trailing bits", async () => {
const nonCanonicalSalt = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODx$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
const nonCanonicalDigest = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC5";
for (const phc of [nonCanonicalSalt, nonCanonicalDigest]) {
expect(isValidPasswordHash(phc)).toBe(false);
await expect(verifyPassword(vectors[0].password, phc)).resolves.toBe(false);
}
});
test("surfaces a sanitized operational error when native Argon2 fails", async () => {
argon2Spy.mockImplementationOnce((_algorithm, _parameters, callback) => {
callback(new Error("native details must not leave the verifier"));
});
await expect(verifyPassword(vectors[0].password, vectors[0].phc))
.rejects.toThrow("local_password_verification_failed");
});
});