import { createHmac, timingSafeEqual } from 'node:crypto'; import { deriveTokenKey } from '@dorfteich/shared/token-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-binding is structural since #188 (ADR 0020): * the HMAC key is the HKDF subkey for `unsubscribe`, so a token signed for * any other purpose cannot verify here regardless of its content. */ const TTL_SECONDS = 90 * 24 * 60 * 60; /** * Dual-verify window (#188, ADR 0020): before the key separation, tokens * were HMACed with the root secret over a `digest-unsubscribe.` prefix. * Those links live in digest mails that are already sent and stay valid * for their full 90-day TTL, so verification accepts the legacy derivation * until every pre-separation token has expired. Tokens are only ever * SIGNED with the new subkey; the legacy path is verify-only and goes dead * automatically on the date below (last possible legacy expiry, rounded up). */ export const LEGACY_VERIFY_UNTIL = Date.parse('2026-11-01T00:00:00Z'); const LEGACY_PURPOSE = 'digest-unsubscribe'; function signature(body: string, rootSecret: string): Buffer { return createHmac('sha256', deriveTokenKey(rootSecret, 'unsubscribe')).update(body).digest(); } function legacySignature(body: string, rootSecret: string): Buffer { return createHmac('sha256', rootSecret).update(`${LEGACY_PURPOSE}.${body}`).digest(); } export function signUnsubscribeToken(userId: string, rootSecret: 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, rootSecret).toString('base64url')}`; } function matches(provided: Buffer, expected: Buffer): boolean { return provided.length === expected.length && timingSafeEqual(provided, expected); } /** The user id, or null for anything invalid or expired. Never throws. */ export function verifyUnsubscribeToken( token: string, rootSecret: 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 current = matches(provided, signature(body, rootSecret)); const legacy = !current && now < LEGACY_VERIFY_UNTIL && matches(provided, legacySignature(body, rootSecret)); if (!current && !legacy) 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; } }