import { createHmac, timingSafeEqual } from 'node:crypto'; /** * Server-signed form token: proves a submission originates from a real page * load and anchors the time-trap to SERVER time instead of a client-supplied * timestamp (which bots can forge arbitrarily). * * Token format: `.` (base64url). */ export const MAX_FORM_AGE_MS = 24 * 60 * 60 * 1000; export type FormTokenFailureReason = 'malformed' | 'forged' | 'expired'; export interface FormTokenVerification { ok: boolean; issuedAt?: number; reason?: FormTokenFailureReason; } export function getFormTokenSecret(): string { return process.env.FORM_TOKEN_SECRET || 'insecure-dev-form-token-secret'; } function sign(issuedAt: number, secret: string): string { return createHmac('sha256', secret).update(String(issuedAt)).digest('base64url'); } export function createFormToken(now: number, secret: string = getFormTokenSecret()): string { return `${now}.${sign(now, secret)}`; } export function verifyFormToken( token: string | null, now: number, secret: string = getFormTokenSecret(), ): FormTokenVerification { if (!token) { return { ok: false, reason: 'malformed' }; } const parts = token.split('.'); if (parts.length !== 2) { return { ok: false, reason: 'malformed' }; } const issuedAt = Number(parts[0]); if (!Number.isFinite(issuedAt) || issuedAt <= 0) { return { ok: false, reason: 'malformed' }; } const expected = Buffer.from(sign(issuedAt, secret)); const provided = Buffer.from(parts[1]); if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) { return { ok: false, reason: 'forged' }; } const age = now - issuedAt; if (age < 0 || age > MAX_FORM_AGE_MS) { return { ok: false, reason: 'expired' }; } return { ok: true, issuedAt }; }