import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import type { Context, Hono } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { html } from "../pages/html.ts"; import { type PageContext, page } from "../pages/layout.ts"; import { clientIp } from "./client.ts"; const SESSION_SECONDS = 30 * 86400; const SESSION_COOKIE = "kepr_session"; const SIGN_IN_COOKIE = "kepr_sign_in"; // Readable by kepr.js, and saying only that someone signed in here: public pages stay the same // cacheable HTML for everyone, and the script asks the admin routes for the editing controls. const EDITOR_COOKIE = "kepr_editor"; const COOKIE = { httpOnly: true, secure: true, sameSite: "Strict", path: "/" } as const; const HEX64 = /^[0-9a-f]{64}$/; const FAILURE_WINDOW_MS = 15 * 60 * 1000; const FAILURES_PER_ADDRESS = 5; // Failed sign-ins, oldest first, in memory: a restart forgets them, but an attacker can't cause one. const failures: { at: number; address: string }[] = []; // Addresses are keyed hashes even in memory, as issue filings store them (§23). const addressKey = randomBytes(32); const nowSeconds = () => Math.floor(Date.now() / 1000); const sha256 = (text: string) => createHash("sha256").update(text).digest("hex"); function sameText(a: string, b: string): boolean { const [x, y] = [Buffer.from(a), Buffer.from(b)]; return x.length === y.length && timingSafeEqual(x, y); } /** The session cookie's token when it names a live session in kepr.db, else null. */ function sessionToken(ctx: PageContext, c: Context): string | null { const token = getCookie(c, SESSION_COOKIE); if (!token || !HEX64.test(token)) return null; const row = ctx.kepr .query<{ n: number }, [string, number]>( "SELECT 1 AS n FROM sessions WHERE token_hash = ? AND expires_at > ?", ) .get(sha256(token), nowSeconds()); return row ? token : null; } export function signedIn(ctx: PageContext, c: Context): boolean { return sessionToken(ctx, c) !== null; } /** How many sessions are live, this one included. */ export function liveSessions(ctx: PageContext): number { return ( ctx.kepr .query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM sessions WHERE expires_at > ?") .get(nowSeconds())?.n ?? 0 ); } /** Revokes every session but the one making this request. */ export function endOtherSessions(ctx: PageContext, c: Context): void { const token = sessionToken(ctx, c) ?? ""; ctx.kepr.query("DELETE FROM sessions WHERE token_hash != ?").run(sha256(token)); } /** * Whether a request came from this site. A localhost domain also accepts plain http, since a * local run has no TLS in front of it; browsers keep Secure cookies on http://localhost. */ function fromSite(c: Context, domain: string): boolean { const origin = c.req.header("origin"); if (origin === `https://${domain}`) return true; return origin === `http://${domain}` && /^localhost(:\d+)?$/.test(domain); } export async function adminPostRefusal(ctx: PageContext, c: Context): Promise { if (!fromSite(c, ctx.domain)) return "Refused: wrong origin."; const form = (await c.req.parseBody()).form; return typeof form !== "string" || !sameText(form, formToken(c)) ? "Refused: this form expired. Reload and try again." : ""; } /** * The same-site token every admin form carries (§25): derived from the session's secret token, so * a page on another site can neither read nor guess it, and it dies with the session. */ export function formToken(c: Context): string { return formTokenOf(getCookie(c, SESSION_COOKIE) ?? ""); } function formTokenOf(session: string): string { return sha256(`form:${session}`); } // The readable mark beside the session: its form token, so the navbar can offer log out without // asking the server (nav.js). Readable by the site's own scripts only, as any page's form is. function setEditorCookie(c: Context, session: string): void { setCookie(c, EDITOR_COOKIE, formTokenOf(session), { ...COOKIE, httpOnly: false, maxAge: SESSION_SECONDS, }); } /** * The refusal page behind every admin entry point, `/connect` included: one answer for a missing * session, a foreign origin, an expired form token or too many attempts, wherever it arrives. */ export function adminRefusal( c: Context, ctx: PageContext, status: 401 | 403 | 429, text: string, ): Response { c.header("Cache-Control", "no-store"); const body = html`

${text}

`; return c.html(page(ctx, { title: "admin", description: "", path: c.req.path, body }), status); } /** * §24's login at `/sign-in`, sessions, sign-out, and the guard in front of every * `/admin` route: a live session, and for anything but GET a matching `Origin` and form token. */ export function registerLogin( app: Hono, ctx: PageContext, // The live config, so a password changed in the console works at once. config: { adminPasswordHash: string }, ): void { const refuse = (c: Context, status: 401 | 403 | 429, text: string) => adminRefusal(c, ctx, status, text); app.use("/admin/*", async (c, next) => { const token = sessionToken(ctx, c); if (!token) return refuse(c, 401, "Sign in first."); // A mark set before it carried the token is brought up to date. if (getCookie(c, EDITOR_COOKIE) !== formTokenOf(token)) setEditorCookie(c, token); if (c.req.method !== "GET") { const error = await adminPostRefusal(ctx, c); if (error) return refuse(c, 403, error); } await next(); c.header("Cache-Control", "no-store"); }); app.get("/sign-in", (c) => signInPage(c, ctx, "", 200, nextPath(c.req.query("next")))); app.post("/sign-in", async (c) => { const fields = await c.req.parseBody(); const next = nextPath(fields.next); const cookie = getCookie(c, SIGN_IN_COOKIE) ?? ""; const form = typeof fields.form === "string" ? fields.form : ""; if (!fromSite(c, ctx.domain) || !HEX64.test(cookie) || !sameText(form, cookie)) { return refuse(c, 403, "Refused: this form expired. Reload and try again."); } const now = Date.now(); while (failures[0] && failures[0].at <= now - FAILURE_WINDOW_MS) failures.shift(); const address = createHash("sha256").update(addressKey).update(clientIp(c)).digest("hex"); const mine = failures.filter((failure) => failure.address === address).length; if (mine >= FAILURES_PER_ADDRESS) { c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000)); return signInPage(c, ctx, "Too many attempts. Try again later.", 429, next); } const password = typeof fields.password === "string" ? fields.password : ""; if (!(await Bun.password.verify(password, config.adminPasswordHash))) { failures.push({ at: now, address }); return signInPage(c, ctx, "That password is not right.", 401, next); } const token = randomBytes(32).toString("hex"); const at = nowSeconds(); ctx.kepr.query("DELETE FROM sessions WHERE expires_at <= ?").run(at); ctx.kepr .query("INSERT INTO sessions (token_hash, expires_at) VALUES (?, ?)") .run(sha256(token), at + SESSION_SECONDS); setCookie(c, SESSION_COOKIE, token, { ...COOKIE, maxAge: SESSION_SECONDS }); setEditorCookie(c, token); deleteCookie(c, SIGN_IN_COOKIE, COOKIE); return c.redirect(next, 303); }); app.post("/admin/sign-out", (c) => { ctx.kepr .query("DELETE FROM sessions WHERE token_hash = ?") .run(sha256(getCookie(c, SESSION_COOKIE) ?? "")); deleteCookie(c, SESSION_COOKIE, COOKIE); deleteCookie(c, EDITOR_COOKIE, { ...COOKIE, httpOnly: false }); return c.redirect("/", 303); }); } /** * Where a sign-in goes on to: a path on this site, or `/admin`. `//host` and `/\host` are other * sites to a browser, and a control character could split the Location header. */ function nextPath(value: unknown): string { if (typeof value !== "string" || !value.startsWith("/")) return "/admin"; if (value.startsWith("//") || value.startsWith("/\\")) return "/admin"; if ([...value].some((char) => char.charCodeAt(0) <= 31 || char.charCodeAt(0) === 127)) return "/admin"; return value; } // A double-submit token repeats a SameSite=Strict cookie that a cross-site post never has. function signInPage( c: Context, ctx: PageContext, error: string, status: 200 | 401 | 429, next: string, ) { let cookie = getCookie(c, SIGN_IN_COOKIE) ?? ""; if (!HEX64.test(cookie)) { cookie = randomBytes(32).toString("hex"); setCookie(c, SIGN_IN_COOKIE, cookie, COOKIE); } c.header("Cache-Control", "no-store"); const body = html`

sign in

${error && html``}
`; return c.html(page(ctx, { title: "sign in", description: "", path: "/sign-in", body }), status); }