All checks were successful
CI / Build container images (pull_request) Successful in 3m51s
CI / Auth e2e pack (pull_request) Successful in 7m49s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CI / Lint, typecheck, test (pull_request) Successful in 4m43s
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m39s
CI / Import/export fidelity gate (push) Successful in 59s
COLLAB_TOKEN_SECRET becomes a root key: every purpose derives its own HKDF-SHA-256 subkey (deriveTokenKey), and no code path signs with the root key directly. Collaboration tokens are signed and verified by jose with HS256 as an explicit allowlist; the sign/verify API turns async at its three call sites. Unsubscribe tokens move from a purpose-prefix string to the structural subkey, with a documented dual-verify window (legacy derivation accepted until 2026-11-01, covering the 90-day TTL of links in already-sent mail). The cross-runtime property that justified the homegrown implementation is now proven by a test: the built CJS and ESM dist artefacts round-trip tokens in both directions in child processes (jose v6 reaches CJS via Node's require(esm), pinned Node 22 images). Negative tests cover cross-purpose subkeys, root-key-signed tokens, alg:none and RS256. Refs #188 (ADR 0020) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
78 lines
2.9 KiB
TypeScript
78 lines
2.9 KiB
TypeScript
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;
|
|
}
|
|
}
|