import type { Database } from "bun:sqlite"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import type { Context } from "hono"; import { getCookie, setCookie } from "hono/cookie"; import { clientIp } from "../server/client.ts"; import { setting } from "../server/db.ts"; // §23's spam defenses, all server-side: nothing here trusts a clock or a value from the browser. export const MIN_SECONDS_ON_PAGE = 3; const STAMP_MAX_AGE = 86400; export const FILINGS_PER_HOUR = 5; export const REPLIES_PER_HOUR = 20; export const MAX_CHARS = 20_000; const FORM_COOKIE = "kepr_form"; const HEX = /^[0-9a-f]{32,64}$/; /** One random key, made on first use and kept in kepr.db, for the IP hash and the form stamps. */ function secret(db: Database): string { db.query("INSERT OR IGNORE INTO settings (key, value) VALUES ('issue_secret', ?)").run( randomBytes(32).toString("hex"), ); return setting(db, "issue_secret") as string; } function mac(db: Database, message: string): string { return createHmac("sha256", secret(db)).update(message).digest("hex"); } /** A keyed hash, so the stored value can't be reversed by hashing every IPv4 address. */ export function ipHash(db: Database, c: Context): string { return mac(db, `ip:${clientIp(c)}`); } /** * What a follower's confirm and stop links carry (§23): only their inbox ever sees it. The * address is covered too, so a link whose row is gone cannot reach a later follower that reuses * the id — `issue_followers.id` is a rowid, and SQLite hands a deleted row's id back out. */ export function followToken(db: Database, followerId: number, email: string): string { return mac(db, `follow:${followerId}:${email.toLowerCase()}`); } export function followTokenMatches( db: Database, followerId: number, email: string, token: string, ): boolean { const expected = Buffer.from(followToken(db, followerId, email)); const given = Buffer.from(token); return given.length === expected.length && timingSafeEqual(given, expected); } /** * SameSite=Strict so a cross-site post arrives without it, and a form stamp * `.`. */ export function formStamp(db: Database, c: Context, now: number): string { let cookie = getCookie(c, FORM_COOKIE); if (!cookie || !HEX.test(cookie)) { cookie = randomBytes(16).toString("hex"); setCookie(c, FORM_COOKIE, cookie, { httpOnly: true, secure: true, sameSite: "Strict", path: "/", }); } return `${now}.${mac(db, `form:${now}:${cookie}`)}`; } export type StampCheck = "ok" | "invalid" | "too-fast"; export function checkStamp(db: Database, c: Context, stamp: string, now: number): StampCheck { const cookie = getCookie(c, FORM_COOKIE); const [time, signature] = stamp.split("."); if (!cookie || !time || !signature || !/^\d{1,12}$/.test(time)) return "invalid"; const expected = Buffer.from(mac(db, `form:${time}:${cookie}`)); const given = Buffer.from(signature); if (given.length !== expected.length || !timingSafeEqual(given, expected)) return "invalid"; const age = now - Number(time); if (age > STAMP_MAX_AGE || age < 0) return "invalid"; return age < MIN_SECONDS_ON_PAGE ? "too-fast" : "ok"; }