// Password gate for protected status pages. We sign a short-lived cookie with // the page id + a tag derived from the current password hash + an expiry, so // a successful password unlock survives across page loads without us having // to hit Postgres on every request - and so changing the page password // invalidates every cookie issued under the old password. // // Cookie format: ... // pwTag = HMAC(SECRET, password_hash).slice(0, 16) // sig = HMAC(SECRET, "..") // // Why include pwTag: without it, the signed payload would be tied only to // the page id, so a stolen / old cookie would remain valid for the full TTL // even after the operator rotated the password. With pwTag, the verifier // derives the expected tag from the *current* password_hash; old cookies // no longer match and are silently rejected. import { createHmac, timingSafeEqual } from "crypto"; const SECRET = process.env.STATUS_COOKIE_SECRET ?? process.env.MONITOR_TOKEN ?? "dev-secret-change-me"; const COOKIE = "pingql_status_auth"; const TTL_MS = 12 * 60 * 60 * 1000; // 12 hours function sign(payload: string): string { return createHmac("sha256", SECRET).update(payload).digest("hex"); } // Deterministic 16-hex-char tag derived from the current password hash. // Stable for as long as the password_hash row doesn't change. When the // operator changes the password, password_hash changes, pwTag changes, old // cookies' embedded pwTag stops matching → verification fails. function passwordTag(passwordHash: string): string { return createHmac("sha256", SECRET).update(`pw:${passwordHash}`).digest("hex").slice(0, 16); } export function makeAuthCookie(pageId: string, passwordHash: string): string { const exp = Date.now() + TTL_MS; const pwTag = passwordTag(passwordHash); const payload = `${pageId}.${pwTag}.${exp}`; const sig = sign(payload); const value = `${payload}.${sig}`; return `${COOKIE}=${value}; Path=/; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax${process.env.NODE_ENV !== "development" ? "; Secure" : ""}`; } export function verifyAuthCookie(cookieHeader: string | null | undefined, pageId: string, passwordHash: string): boolean { if (!cookieHeader) return false; const match = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`)); if (!match) return false; const value = match.slice(COOKIE.length + 1); const lastDot = value.lastIndexOf("."); if (lastDot < 0) return false; const payload = value.slice(0, lastDot); const sig = value.slice(lastDot + 1); const parts = payload.split("."); if (parts.length !== 3) return false; const [id, pwTag, expStr] = parts; if (id !== pageId) return false; // Compare the cookie's pwTag against the tag derived from the *current* // password hash. Mismatch = the password was rotated since this cookie was // issued, so the cookie is dead. Constant-time compare to avoid leaking // partial-match timing. const expectedPwTag = passwordTag(passwordHash); if (pwTag.length !== expectedPwTag.length) return false; try { if (!timingSafeEqual(Buffer.from(pwTag), Buffer.from(expectedPwTag))) return false; } catch { return false; } const exp = Number(expStr); if (!Number.isFinite(exp) || Date.now() > exp) return false; const expected = sign(payload); if (expected.length !== sig.length) return false; try { return timingSafeEqual(Buffer.from(expected), Buffer.from(sig)); } catch { return false; } } export async function checkPassword(plain: string, hash: string): Promise { return await Bun.password.verify(plain, hash); }