import { createHmac, timingSafeEqual } from 'node:crypto'; /** * Single-purpose unsubscribe tokens for the digest mails (issue #95): a * signed `{userId, exp}` blob whose only power is flipping that user's * digest setting to `off` — it never creates a session (ADR 0007's "signed * tokens" pattern, purpose-bound so it cannot be replayed anywhere else). */ const PURPOSE = 'digest-unsubscribe'; const TTL_SECONDS = 90 * 24 * 60 * 60; function signature(body: string, secret: string): Buffer { return createHmac('sha256', secret).update(`${PURPOSE}.${body}`).digest(); } export function signUnsubscribeToken(userId: string, secret: string, now = Date.now()): string { const body = Buffer.from( JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }), 'utf8', ).toString('base64url'); return `${body}.${signature(body, secret).toString('base64url')}`; } /** The user id, or null for anything invalid or expired. Never throws. */ export function verifyUnsubscribeToken( token: string, secret: string, now = Date.now(), ): string | null { const [body, sig] = token.split('.'); if (!body || !sig) return null; let provided: Buffer; try { provided = Buffer.from(sig, 'base64url'); } catch { return null; } const expected = signature(body, secret); if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return null; try { const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as { userId?: string; exp?: number; }; if (typeof payload.userId !== 'string' || typeof payload.exp !== 'number') return null; if (payload.exp * 1000 < now) return null; return payload.userId; } catch { return null; } }