1// Password hashing + unlock-cookie signing for protected shares.23import type { Env } from "./env.ts";45const PBKDF2_ITERATIONS = 100_000;6const COOKIE_TTL_S = 7 * 24 * 3600;78const hex = (bytes: ArrayBuffer | Uint8Array) =>9 [...new Uint8Array(bytes as ArrayBuffer)].map((b) => b.toString(16).padStart(2, "0")).join("");1011function hexToBytes(s: string): Uint8Array {12 const out = new Uint8Array(s.length / 2);13 for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);14 return out;15}1617async function pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise<string> {18 const key = await crypto.subtle.importKey(19 "raw",20 new TextEncoder().encode(password),21 "PBKDF2",22 false,23 ["deriveBits"],24 );25 const bits = await crypto.subtle.deriveBits(26 { name: "PBKDF2", hash: "SHA-256", salt: salt as BufferSource, iterations },27 key,28 256,29 );30 return hex(bits);31}3233/** → "pbkdf2$<iterations>$<saltHex>$<hashHex>" */34export async function hashPassword(password: string): Promise<string> {35 const salt = crypto.getRandomValues(new Uint8Array(16));36 return `pbkdf2$${PBKDF2_ITERATIONS}$${hex(salt)}$${await pbkdf2(password, salt, PBKDF2_ITERATIONS)}`;37}3839export async function verifyPassword(password: string, stored: string): Promise<boolean> {40 const [scheme, iterStr, saltHex, hashHex] = stored.split("$");41 if (scheme !== "pbkdf2") return false;42 const actual = await pbkdf2(password, hexToBytes(saltHex), parseInt(iterStr, 10));43 // Both sides are fixed-length hex of a fresh derivation; timing is not a concern here,44 // but compare in constant time anyway.45 if (actual.length !== hashHex.length) return false;46 let diff = 0;47 for (let i = 0; i < actual.length; i++) diff |= actual.charCodeAt(i) ^ hashHex.charCodeAt(i);48 return diff === 0;49}5051async function hmacKey(env: Env): Promise<CryptoKey> {52 return await crypto.subtle.importKey(53 "raw",54 new TextEncoder().encode(`nzip-unlock:${env.NZIP_TOKEN}`),55 { name: "HMAC", hash: "SHA-256" },56 false,57 ["sign", "verify"],58 );59}6061function cookieName(address: string): string {62 return `nzip_a${address}`;63}6465/** Set-Cookie value granting access to one site for COOKIE_TTL_S. */66export async function makeUnlockCookie(env: Env, address: string): Promise<string> {67 const exp = Math.floor(Date.now() / 1000) + COOKIE_TTL_S;68 const sig = hex(69 await crypto.subtle.sign(70 "HMAC",71 await hmacKey(env),72 new TextEncoder().encode(`${address}.${exp}`),73 ),74 );75 // Path without trailing slash so the cookie also matches the bare /{address} URL.76 return `${cookieName(address)}=${exp}.${sig}; Max-Age=${COOKIE_TTL_S}; Path=/${address}; HttpOnly; Secure; SameSite=Lax`;77}7879export async function hasValidUnlockCookie(80 req: Request,81 env: Env,82 address: string,83): Promise<boolean> {84 const cookies = req.headers.get("cookie") ?? "";85 const match = cookies.match(new RegExp(`(?:^|;\\s*)${cookieName(address)}=([^;]+)`));86 if (!match) return false;87 const [expStr, sig] = match[1].split(".");88 const exp = parseInt(expStr, 10);89 if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return false;90 try {91 return await crypto.subtle.verify(92 "HMAC",93 await hmacKey(env),94 hexToBytes(sig) as BufferSource,95 new TextEncoder().encode(`${address}.${exp}`),96 );97 } catch {98 return false;99 }100}