import { argon2id } from "@noble/hashes/argon2.js"; import { timingSafeEqualBytes } from "@/worker/security/timing-safe"; const PARAMS = { m: 19456, t: 2, p: 1, dkLen: 32 } as const; const SALT_BYTES = 16; const PHC_PREFIX = "$argon2id$v=19$"; const encodeBase64Phc = (bytes: Uint8Array): string => { let binary = ""; for (let i = 0; i < bytes.length; i += 1) { binary += String.fromCharCode(bytes[i]); } return btoa(binary).replace(/=+$/, ""); }; const decodeBase64Phc = (value: string): Uint8Array => { const padded = value + "=".repeat((4 - (value.length % 4)) % 4); const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } return bytes; }; export const hashArgon2id = async (password: string): Promise => { const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); const hash = argon2id(password, salt, PARAMS); return `${PHC_PREFIX}m=${PARAMS.m},t=${PARAMS.t},p=${PARAMS.p}$${encodeBase64Phc(salt)}$${encodeBase64Phc(hash)}`; }; // Defense-in-depth bounds on PHC parameters parsed from stored hashes. // argon2id's memory parameter is allocated up-front and OOMs the worker // if pushed past isolate limits; verifying a password should never // require more cost than hashing it. An attacker with arbitrary D1 // write would have worse problems available, but the clamp removes a // trivial DoS surface. const PHC_LIMITS = { m: 65536, t: 8, p: 4 } as const; const PHC_MIN = { m: 1, t: 1, p: 1 } as const; export const verifyArgon2id = async ({ hash, password }: { hash: string; password: string }): Promise => { const parts = hash.split("$"); if (parts.length !== 6 || parts[1] !== "argon2id" || parts[2] !== "v=19") { return false; } const params: Record = {}; for (const pair of parts[3].split(",")) { const [key, value] = pair.split("="); const numeric = Number.parseInt(value ?? "", 10); if (!Number.isFinite(numeric)) { return false; } params[key] = numeric; } if (params.m === undefined || params.t === undefined || params.p === undefined) { return false; } if ( params.m < PHC_MIN.m || params.m > PHC_LIMITS.m || params.t < PHC_MIN.t || params.t > PHC_LIMITS.t || params.p < PHC_MIN.p || params.p > PHC_LIMITS.p ) { return false; } let salt: Uint8Array; let stored: Uint8Array; try { salt = decodeBase64Phc(parts[4]); stored = decodeBase64Phc(parts[5]); } catch { return false; } // tessera-generated PHC strings always have dkLen=PARAMS.dkLen (32). // Any other length is by definition not produced by hashArgon2id — // reject without invoking argon2id so a corrupted or hand-crafted // shortened digest can't reduce the brute-force cost of a match. if (stored.length !== PARAMS.dkLen) { return false; } const recomputed = argon2id(password, salt, { m: params.m, t: params.t, p: params.p, dkLen: PARAMS.dkLen }); return timingSafeEqualBytes(recomputed, stored); };