82 lines
3.6 KiB
TypeScript
82 lines
3.6 KiB
TypeScript
// 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: <pageId>.<pwTag>.<exp>.<sig>
|
|
// pwTag = HMAC(SECRET, password_hash).slice(0, 16)
|
|
// sig = HMAC(SECRET, "<pageId>.<pwTag>.<exp>")
|
|
//
|
|
// 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<boolean> {
|
|
return await Bun.password.verify(plain, hash);
|
|
}
|