// Password hashing using WebCrypto PBKDF2-SHA256 — works on Node, browsers, and CF Workers. // Stored format: `pbkdf2$$$` const ITERATIONS = 100_000 const KEY_LENGTH = 32 // bytes const SALT_LENGTH = 16 const b64encode = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)) const b64decode = (str: string) => { const bin = atob(str) const out = new Uint8Array(bin.length) for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) return out } async function pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise { const enc = new TextEncoder() const key = await crypto.subtle.importKey( 'raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'], ) const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' }, key, KEY_LENGTH * 8, ) return new Uint8Array(bits) } export async function hashPassword(password: string): Promise { const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)) const hash = await pbkdf2(password, salt, ITERATIONS) return `pbkdf2$${ITERATIONS}$${b64encode(salt)}$${b64encode(hash)}` } export async function verifyPassword(password: string, stored: string): Promise { const parts = stored.split('$') if (parts.length !== 4 || parts[0] !== 'pbkdf2') return false const iterations = Number(parts[1]) if (!Number.isFinite(iterations) || iterations < 1000) return false const salt = b64decode(parts[2]) const expected = b64decode(parts[3]) const actual = await pbkdf2(password, salt, iterations) if (actual.length !== expected.length) return false let diff = 0 for (let i = 0; i < actual.length; i++) diff |= actual[i] ^ expected[i] return diff === 0 }