import { argon2id } from '@noble/hashes/argon2.js' import { randomBytes, timingSafeEqual } from 'node:crypto' // Argon2id parameters const MEMORY_COST = 65536 // 64 MB const TIME_COST = 3 const PARALLELISM = 1 const SALT_LENGTH = 16 const HASH_LENGTH = 32 function base64NoPadding(buf: Uint8Array): string { return Buffer.from(buf).toString('base64').replace(/=+$/, '') } function fromBase64NoPadding(s: string): Buffer { // Add padding back if needed const pad = s.length % 4 const padded = pad ? s + '='.repeat(4 - pad) : s return Buffer.from(padded, 'base64') } /** * Hash a password using argon2id. * Returns a PHC-formatted string: * $argon2id$v=19$m=65536,t=3,p=1$$ * * @deprecated Use {@link hashPasswordAsync} instead. This synchronous version * blocks the event loop for 100-300ms due to argon2id computation. Kept for * backward compatibility; will be removed in a future release. */ export function hashPassword(password: string): string { const salt = randomBytes(SALT_LENGTH) const hash = argon2id(password, salt, { m: MEMORY_COST, t: TIME_COST, p: PARALLELISM, }) const saltB64 = base64NoPadding(salt) const hashB64 = base64NoPadding(hash) return `$argon2id$v=19$m=${MEMORY_COST},t=${TIME_COST},p=${PARALLELISM}$${saltB64}$${hashB64}` } /** * Verify a password against an argon2id hash string. * Returns true if the password matches. * * @deprecated Use {@link verifyPasswordAsync} instead. This synchronous version * blocks the event loop for 100-300ms due to argon2id computation. Kept for * backward compatibility; will be removed in a future release. */ export function verifyPassword(password: string, hash: string): boolean { if (!password || !hash) return false try { // Parse PHC format: $argon2id$v=19$m=65536,t=3,p=1$$ const parts = hash.split('$') // parts[0] = '' (empty before first $) // parts[1] = 'argon2id' // parts[2] = 'v=19' // parts[3] = 'm=65536,t=3,p=1' // parts[4] = salt (base64) // parts[5] = hash (base64) if (parts.length !== 6 || parts[1] !== 'argon2id') return false const paramParts = parts[3].split(',') const params: Record = {} for (const p of paramParts) { const [key, val] = p.split('=') params[key] = parseInt(val, 10) } const salt = fromBase64NoPadding(parts[4]) const expectedHash = fromBase64NoPadding(parts[5]) const candidateHash = argon2id(password, salt, { m: params.m || MEMORY_COST, t: params.t || TIME_COST, p: params.p || PARALLELISM, }) const a = Buffer.from(candidateHash) const b = Buffer.from(expectedHash) if (a.length !== b.length) return false return timingSafeEqual(a, b) } catch { return false } } /** * Async version of {@link hashPassword}. * * Currently delegates to the synchronous implementation (argon2id is CPU-bound * and login/signup are low-frequency operations, so brief blocking is * acceptable). The async signature allows callers to `await` and makes it * straightforward to swap in a Worker-based implementation later without * touching call sites. */ export async function hashPasswordAsync(password: string): Promise { // Yield once to avoid blocking the microtask queue before the heavy compute. await Promise.resolve() return hashPassword(password) } /** * Async version of {@link verifyPassword}. * * See {@link hashPasswordAsync} for the design rationale. */ export async function verifyPasswordAsync( password: string, hash: string, ): Promise { await Promise.resolve() return verifyPassword(password, hash) }