/** * Password hashing, shared by everything that reads or writes an account's * credential: the `auth` worker (/connect/token credential login and * /account/me/changepassword) and the `admin` CLI (`runx admin set-password`). * It lives here in @repo/domain — next to the account storage the hash is written * into — so there is exactly one definition of the on-disk format and a hash minted * by one caller always verifies in another. * * PBKDF2-SHA256 with a random per-password salt, stored as `salt:hash` (both * base64). The raw password is never persisted. */ const ITERATIONS = 100_000 const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes)) const fromB64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0)) async function deriveBits(password: string, salt: Uint8Array): Promise { const keyMaterial = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits'] ) const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' }, keyMaterial, 256 ) return new Uint8Array(bits) } /** Hash a password into a `salt:hash` string (both base64). */ export async function hashPassword(password: string): Promise { const salt = crypto.getRandomValues(new Uint8Array(16)) return `${b64(salt)}:${b64(await deriveBits(password, salt))}` } /** Verify a password against a stored `salt:hash`. */ export async function verifyPassword(password: string, stored: string): Promise { const [saltB64, hashB64] = stored.split(':') if (!saltB64 || !hashB64) return false const actual = b64(await deriveBits(password, fromB64(saltB64))) return actual === hashB64 }