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; // The pre-#188 dual-verify window (root secret + `digest-unsubscribe.` // prefix) was removed EARLY by operator decision at the ADR 0020 // acceptance (issue #296): links in mails sent before the key separation // no longer work — recipients use the in-app notification settings. // Verification is subkey-only; the regression test pins that the legacy // derivation can never verify again. function signature(body: string, rootSecret: string): Buffer { return createHmac('sha256', deriveTokenKey(rootSecret, 'unsubscribe')).update(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; } if (!matches(provided, signature(body, rootSecret))) 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; } }