| 1 | // Password hashing (PBKDF2-HMAC-SHA256) and token helpers, built on the Web |
| 2 | // Crypto API available in the Workers runtime. Ported from the crash-report |
| 3 | // worker and hardened: tokens are stored hashed, peppered with a server secret. |
| 4 | import { PBKDF2_ITERATIONS } from "../config"; |
| 5 | |
| 6 | const encoder = new TextEncoder(); |
| 7 | |
| 8 | function toBase64(bytes: Uint8Array): string { |
| 9 | let s = ""; |
| 10 | for (const byte of bytes) s += String.fromCharCode(byte); |
| 11 | return btoa(s); |
| 12 | } |
| 13 | |
| 14 | function fromBase64(s: string): Uint8Array { |
| 15 | const bin = atob(s); |
| 16 | const out = new Uint8Array(bin.length); |
| 17 | for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| 18 | return out; |
| 19 | } |
| 20 | |
| 21 | function toHex(bytes: Uint8Array): string { |
| 22 | let s = ""; |
| 23 | for (const byte of bytes) s += byte.toString(16).padStart(2, "0"); |
| 24 | return s; |
| 25 | } |
| 26 | |
| 27 | async function deriveBits(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> { |
| 28 | const key = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]); |
| 29 | const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations, hash: "SHA-256" }, key, 256); |
| 30 | return new Uint8Array(bits); |
| 31 | } |
| 32 | |
| 33 | // Stored as `pbkdf2$<iters>$<salt-b64>$<hash-b64>` so the work factor travels |
| 34 | // with the hash and can be raised without invalidating older passwords. |
| 35 | export async function hashPassword(password: string): Promise<string> { |
| 36 | const salt = crypto.getRandomValues(new Uint8Array(16)); |
| 37 | const hash = await deriveBits(password, salt, PBKDF2_ITERATIONS); |
| 38 | return `pbkdf2$${PBKDF2_ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`; |
| 39 | } |
| 40 | |
| 41 | export async function verifyPassword(password: string, stored: string | null): Promise<boolean> { |
| 42 | if (!stored) return false; |
| 43 | const [scheme, iters, saltB64, hashB64] = stored.split("$"); |
| 44 | if (scheme !== "pbkdf2" || !iters || !saltB64 || !hashB64) return false; |
| 45 | const got = await deriveBits(password, fromBase64(saltB64), Number(iters)); |
| 46 | const want = fromBase64(hashB64); |
| 47 | return got.byteLength === want.byteLength && crypto.subtle.timingSafeEqual(got, want); |
| 48 | } |
| 49 | |
| 50 | export async function sha256Hex(input: string): Promise<string> { |
| 51 | const digest = await crypto.subtle.digest("SHA-256", encoder.encode(input)); |
| 52 | return toHex(new Uint8Array(digest)); |
| 53 | } |
| 54 | |
| 55 | // 256 bits of entropy as hex — used for session cookies and email links. |
| 56 | export function generateToken(): string { |
| 57 | return toHex(crypto.getRandomValues(new Uint8Array(32))); |
| 58 | } |
| 59 | |
| 60 | // Look-up key for a token: only the peppered hash is ever stored, so a DB read |
| 61 | // can neither resurrect a session nor redeem an email link. |
| 62 | export function hashToken(pepper: string, token: string): Promise<string> { |
| 63 | return sha256Hex(`${pepper}:${token}`); |
| 64 | } |
| 65 | |
| 66 | // Crockford base32 (no I/L/O/U) so a spoken or hand-typed device code has no |
| 67 | // ambiguous characters. |
| 68 | const USER_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; |
| 69 | |
| 70 | // A canonical 8-char user code for the device-authorization flow. 40 bits of |
| 71 | // entropy; the pending window is tiny and short-lived and approval is |
| 72 | // auth-gated + rate-limited, so guessing is impractical. `byte & 31` is unbiased |
| 73 | // because 256 is an exact multiple of the 32-symbol alphabet. |
| 74 | export function generateUserCode(): string { |
| 75 | const bytes = crypto.getRandomValues(new Uint8Array(8)); |
| 76 | let s = ""; |
| 77 | for (const byte of bytes) s += USER_CODE_ALPHABET[byte & 31]; |
| 78 | return s; |
| 79 | } |
| 80 | |
| 81 | // A short random suffix for handle generation. Hex-encodes crypto bytes (a |
| 82 | // bijection — no modulo, so no bias) and trims to length. Hex digits are all |
| 83 | // valid handle characters. |
| 84 | export function randomSuffix(len: number): string { |
| 85 | const bytes = crypto.getRandomValues(new Uint8Array(Math.ceil(len / 2))); |
| 86 | let s = ""; |
| 87 | for (const byte of bytes) s += byte.toString(16).padStart(2, "0"); |
| 88 | return s.slice(0, len); |
| 89 | } |
| 90 |