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";
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;
// The global backoff: past this many failures from anywhere, every sign-in waits out the window.
const FAILURES_ANYWHERE = 30;
// 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;
}
/**
* 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 sha256(`form:${getCookie(c, SESSION_COOKIE) ?? ""}`);
}
/**
* §24's login at the unlinked `/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, passwordHash: string): void {
const origin = `https://${ctx.domain}`;
const refuse = (c: Context, status: 401 | 403 | 429, text: string) => {
c.header("Cache-Control", "no-store");
const body = html`
${text}
`;
return c.html(page(ctx, { title: "admin", description: "", path: c.req.path, body }), status);
};
app.use("/admin/*", async (c, next) => {
const token = sessionToken(ctx, c);
if (!token) return refuse(c, 401, "Sign in first.");
if (c.req.method !== "GET") {
if (c.req.header("origin") !== origin) return refuse(c, 403, "Refused: wrong origin.");
const form = (await c.req.parseBody()).form;
if (typeof form !== "string" || !sameText(form, formToken(c))) {
return refuse(c, 403, "Refused: this form expired. Reload and try again.");
}
}
await next();
c.header("Cache-Control", "no-store");
});
app.get("/sign-in", (c) => signInPage(c, ctx, "", 200));
app.post("/sign-in", async (c) => {
const fields = await c.req.parseBody();
const cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";
const form = typeof fields.form === "string" ? fields.form : "";
if (c.req.header("origin") !== origin || !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 (failures.length >= FAILURES_ANYWHERE || mine >= FAILURES_PER_ADDRESS) {
c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000));
return signInPage(c, ctx, "Too many attempts. Try again later.", 429);
}
const password = typeof fields.password === "string" ? fields.password : "";
if (!(await Bun.password.verify(password, passwordHash))) {
failures.push({ at: now, address });
return signInPage(c, ctx, "That password is not right.", 401);
}
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 });
deleteCookie(c, SIGN_IN_COOKIE, COOKIE);
return c.redirect("/admin", 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);
return c.redirect("/", 303);
});
}
// A double-submit token: the form repeats a SameSite=Strict cookie that a cross-site post never has.
function signInPage(c: Context, ctx: PageContext, error: string, status: 200 | 401 | 429) {
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`${error}
`}
`;
return c.html(page(ctx, { title: "sign in", description: "", path: "/sign-in", body }), status);
}