import { argon2, timingSafeEqual } from "node:crypto"; const MAXIMUM_PHC_BYTES = 256; const ARGON2_MEMORY_KIB = 65_536; const ARGON2_PASSES = 3; const ARGON2_PARALLELISM = 1; const ARGON2_SALT_BYTES = 16; const ARGON2_KEY_BYTES = 32; const MINIMUM_PASSWORD_BYTES = 12; const MAXIMUM_PASSWORD_BYTES = 1024; interface Argon2Parameters { memory: number; passes: number; parallelism: number; salt: Buffer; digest: Buffer; } /** Internal-only signal for unavailable native Argon2 work; never expose its cause. */ export class LocalPasswordVerificationError extends Error { constructor() { super("local_password_verification_failed"); } } function parseDecimal(value: string, maximum: number): number | undefined { if (!/^\d+$/.test(value) || (value.length > 1 && value[0] === "0") || value.length > 10) return undefined; const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed <= maximum ? parsed : undefined; } function decodeRawBase64(value: string, minimum: number, maximum: number): Buffer | undefined { if (!/^[A-Za-z0-9+/]+$/.test(value)) return undefined; const decoded = Buffer.from(value, "base64"); if (decoded.length < minimum || decoded.length > maximum) { decoded.fill(0); return undefined; } if (decoded.toString("base64").replace(/=+$/, "") !== value) { decoded.fill(0); return undefined; } return decoded; } function parsePHC(encoded: string): Argon2Parameters | undefined { if (typeof encoded !== "string" || encoded.length === 0 || Buffer.byteLength(encoded, "utf8") > MAXIMUM_PHC_BYTES) return undefined; const parts = encoded.split("$"); if (parts.length !== 6 || parts[0] !== "" || parts[1] !== "argon2id" || parts[2] !== "v=19") return undefined; const parameterParts = parts[3].split(","); if (parameterParts.length !== 3 || !parameterParts[0].startsWith("m=") || !parameterParts[1].startsWith("t=") || !parameterParts[2].startsWith("p=")) return undefined; const memory = parseDecimal(parameterParts[0].slice(2), ARGON2_MEMORY_KIB); const passes = parseDecimal(parameterParts[1].slice(2), ARGON2_PASSES); const parallelism = parseDecimal(parameterParts[2].slice(2), ARGON2_PARALLELISM); if (memory !== ARGON2_MEMORY_KIB || passes !== ARGON2_PASSES || parallelism !== ARGON2_PARALLELISM) return undefined; const salt = decodeRawBase64(parts[4], ARGON2_SALT_BYTES, ARGON2_SALT_BYTES); const digest = decodeRawBase64(parts[5], ARGON2_KEY_BYTES, ARGON2_KEY_BYTES); if (!salt || !digest) { salt?.fill(0); digest?.fill(0); return undefined; } return { memory, passes, parallelism, salt, digest }; } function hasValidPasswordBytes(password: string): boolean { if (typeof password !== "string") return false; const typedPassword = password as string & { isWellFormed?: () => boolean }; if (typeof typedPassword.isWellFormed === "function") { if (!typedPassword.isWellFormed()) return false; } else if (/[\uD800-\uDFFF]/.test(password)) { return false; } const byteLength = Buffer.byteLength(password, "utf8"); return byteLength >= MINIMUM_PASSWORD_BYTES && byteLength <= MAXIMUM_PASSWORD_BYTES; } function passwordBytes(password: string): Buffer | undefined { return hasValidPasswordBytes(password) ? Buffer.from(password, "utf8") : undefined; } function clearParameters(parameters: Argon2Parameters): void { parameters.salt.fill(0); parameters.digest.fill(0); } function deriveArgon2(message: Buffer, parameters: Argon2Parameters): Promise { return new Promise((resolve, reject) => { let settled = false; const complete = (error: Error | null, derived?: Buffer): void => { if (settled) { derived?.fill(0); return; } settled = true; if (error || !derived) { derived?.fill(0); reject(error ?? new Error("argon2_failed")); return; } resolve(derived); }; try { argon2("argon2id", { message, nonce: parameters.salt, memory: parameters.memory, passes: parameters.passes, parallelism: parameters.parallelism, tagLength: parameters.digest.length, }, complete); } catch (error) { if (!settled) { settled = true; reject(error); } } }); } export function isValidPasswordHash(encoded: string): boolean { const parameters = parsePHC(encoded); if (!parameters) return false; clearParameters(parameters); return true; } export async function verifyPassword(password: string, encoded: string): Promise { const parameters = parsePHC(encoded); const message = passwordBytes(password); if (!message || !parameters) { message?.fill(0); if (parameters) clearParameters(parameters); return false; } let derived: Buffer | undefined; try { derived = await deriveArgon2(message, parameters); return derived.length === parameters.digest.length && timingSafeEqual(derived, parameters.digest); } catch { throw new LocalPasswordVerificationError(); } finally { message.fill(0); derived?.fill(0); clearParameters(parameters); } } const DUMMY_PASSWORD = "thothii-process-local-dummy-password"; const DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=1$ABEiM0RVZneImaq7zN3u/w$/YuK14HV5biXcVIYqaJs07meu0nRgo+d62KnM02MSoU"; export async function verifyWithDummy(password: string): Promise { await verifyPassword(hasValidPasswordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH); }