clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

feat/source-stable-push
nzip / worker / src / password.ts
password.ts 4.3K
1// Password hashing + unlock-cookie signing for protected shares.23const PBKDF2_ITERATIONS = 100_000;4const COOKIE_TTL_S = 7 * 24 * 3600;5const UNLOCK_COOKIE = "__Host-nzip-unlock";67interface UnlockSecrets {8 NZIP_TOKEN: string;9}1011const hex = (bytes: ArrayBuffer | Uint8Array) =>12 [...new Uint8Array(bytes as ArrayBuffer)].map((b) =>13 b.toString(16).padStart(2, "0")14 ).join("");1516function hexToBytes(s: string): Uint8Array {17 const out = new Uint8Array(s.length / 2);18 for (let i = 0; i < out.length; i++) {19 out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);20 }21 return out;22}2324async function pbkdf2(25 password: string,26 salt: Uint8Array,27 iterations: number,28): Promise<string> {29 const key = await crypto.subtle.importKey(30 "raw",31 new TextEncoder().encode(password),32 "PBKDF2",33 false,34 ["deriveBits"],35 );36 const bits = await crypto.subtle.deriveBits(37 { name: "PBKDF2", hash: "SHA-256", salt: salt as BufferSource, iterations },38 key,39 256,40 );41 return hex(bits);42}4344/** → "pbkdf2$<iterations>$<saltHex>$<hashHex>" */45export async function hashPassword(password: string): Promise<string> {46 const salt = crypto.getRandomValues(new Uint8Array(16));47 return `pbkdf2$${PBKDF2_ITERATIONS}$${hex(salt)}$${await pbkdf2(48 password,49 salt,50 PBKDF2_ITERATIONS,51 )}`;52}5354export async function verifyPassword(55 password: string,56 stored: string,57): Promise<boolean> {58 const [scheme, iterStr, saltHex, hashHex] = stored.split("$");59 if (scheme !== "pbkdf2") return false;60 const actual = await pbkdf2(61 password,62 hexToBytes(saltHex),63 parseInt(iterStr, 10),64 );65 // Both sides are fixed-length hex of a fresh derivation; timing is not a concern here,66 // but compare in constant time anyway.67 if (actual.length !== hashHex.length) return false;68 let diff = 0;69 for (let i = 0; i < actual.length; i++) {70 diff |= actual.charCodeAt(i) ^ hashHex.charCodeAt(i);71 }72 return diff === 0;73}7475async function hmacKey(env: UnlockSecrets): Promise<CryptoKey> {76 return await crypto.subtle.importKey(77 "raw",78 new TextEncoder().encode(`nzip-unlock:${env.NZIP_TOKEN}`),79 { name: "HMAC", hash: "SHA-256" },80 false,81 ["sign", "verify"],82 );83}8485/** Set-Cookie value granting access to one site for COOKIE_TTL_S. */86export async function makeUnlockCookie(87 env: UnlockSecrets,88 address: string,89 authVersion: number,90): Promise<string> {91 const exp = Math.floor(Date.now() / 1000) + COOKIE_TTL_S;92 const sig = hex(93 await crypto.subtle.sign(94 "HMAC",95 await hmacKey(env),96 new TextEncoder().encode(`${address}.${authVersion}.${exp}`),97 ),98 );99 // __Host- prevents a sibling artifact hostname from planting a parent-domain100 // cookie with this name. Each site hostname receives its own host-only value.101 return `${UNLOCK_COOKIE}=${authVersion}.${exp}.${sig}; Max-Age=${COOKIE_TTL_S}; Path=/; HttpOnly; Secure; SameSite=Lax`;102}103104export async function hasValidUnlockCookie(105 req: Request,106 env: UnlockSecrets,107 address: string,108 authVersion: number,109): Promise<boolean> {110 const cookies = req.headers.get("cookie") ?? "";111 const acceptedNames = new Set([UNLOCK_COOKIE, `nzip_a${address}`]);112 for (const entry of cookies.split(";")) {113 const [name, ...valueParts] = entry.trim().split("=");114 if (!acceptedNames.has(name)) continue;115 const parts = valueParts.join("=").split(".");116 let expStr: string;117 let sig: string;118 let payload: string;119 if (parts.length === 2 && authVersion === 1) {120 // Cookies issued before auth_version existed remain valid until the site's121 // password policy first changes and increments the version.122 [expStr, sig] = parts;123 payload = `${address}.${expStr}`;124 } else if (parts.length === 3 && parts[0] === String(authVersion)) {125 [, expStr, sig] = parts;126 payload = `${address}.${authVersion}.${expStr}`;127 } else {128 continue;129 }130 const exp = parseInt(expStr, 10);131 if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) {132 continue;133 }134 try {135 if (136 await crypto.subtle.verify(137 "HMAC",138 await hmacKey(env),139 hexToBytes(sig) as BufferSource,140 new TextEncoder().encode(payload),141 )142 ) {143 return true;144 }145 } catch {146 // Ignore malformed or cookie-shadowing candidates and try the next value.147 }148 }149 return false;150}