spam.ts

3.1 KB · raw

1import type { Database } from "bun:sqlite";2import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";3import type { Context } from "hono";4import { getCookie, setCookie } from "hono/cookie";5import { clientIp } from "../server/client.ts";6import { setting } from "../server/db.ts";78// §23's spam defenses, all server-side: nothing here trusts a clock or a value from the browser.9export const MIN_SECONDS_ON_PAGE = 3;10const STAMP_MAX_AGE = 86400;11export const FILINGS_PER_HOUR = 5;12export const REPLIES_PER_HOUR = 20;13export const MAX_CHARS = 20_000;14const FORM_COOKIE = "kepr_form";15const HEX = /^[0-9a-f]{32,64}$/;1617/** One random key, made on first use and kept in kepr.db, for the IP hash and the form stamps. */18function secret(db: Database): string {19	db.query("INSERT OR IGNORE INTO settings (key, value) VALUES ('issue_secret', ?)").run(20		randomBytes(32).toString("hex"),21	);22	return setting(db, "issue_secret") as string;23}2425function mac(db: Database, message: string): string {26	return createHmac("sha256", secret(db)).update(message).digest("hex");27}2829/** A keyed hash, so the stored value can't be reversed by hashing every IPv4 address. */30export function ipHash(db: Database, c: Context): string {31	return mac(db, `ip:${clientIp(c)}`);32}3334/**35 * What a follower's confirm and stop links carry (§23): only their inbox ever sees it. The36 * address is covered too, so a link whose row is gone cannot reach a later follower that reuses37 * the id — `issue_followers.id` is a rowid, and SQLite hands a deleted row's id back out.38 */39export function followToken(db: Database, followerId: number, email: string): string {40	return mac(db, `follow:${followerId}:${email.toLowerCase()}`);41}4243export function followTokenMatches(44	db: Database,45	followerId: number,46	email: string,47	token: string,48): boolean {49	const expected = Buffer.from(followToken(db, followerId, email));50	const given = Buffer.from(token);51	return given.length === expected.length && timingSafeEqual(given, expected);52}5354/**55 * SameSite=Strict so a cross-site post arrives without it, and a form stamp56 * `<unix time>.<HMAC(time, cookie)>`.57 */58export function formStamp(db: Database, c: Context, now: number): string {59	let cookie = getCookie(c, FORM_COOKIE);60	if (!cookie || !HEX.test(cookie)) {61		cookie = randomBytes(16).toString("hex");62		setCookie(c, FORM_COOKIE, cookie, {63			httpOnly: true,64			secure: true,65			sameSite: "Strict",66			path: "/",67		});68	}69	return `${now}.${mac(db, `form:${now}:${cookie}`)}`;70}7172export type StampCheck = "ok" | "invalid" | "too-fast";7374export function checkStamp(db: Database, c: Context, stamp: string, now: number): StampCheck {75	const cookie = getCookie(c, FORM_COOKIE);76	const [time, signature] = stamp.split(".");77	if (!cookie || !time || !signature || !/^\d{1,12}$/.test(time)) return "invalid";78	const expected = Buffer.from(mac(db, `form:${time}:${cookie}`));79	const given = Buffer.from(signature);80	if (given.length !== expected.length || !timingSafeEqual(given, expected)) return "invalid";81	const age = now - Number(time);82	if (age > STAMP_MAX_AGE || age < 0) return "invalid";83	return age < MIN_SECONDS_ON_PAGE ? "too-fast" : "ok";84}