# ADR 0020: Token crypto — HKDF key separation and a vetted JWT library - Status: proposed - Date: 2026-07-29 ## Context `COLLAB_TOKEN_SECRET` currently signs two unrelated kinds of token: short-lived collaboration tokens (issue #34) and long-lived unsubscribe tokens in outgoing mail. A single secret across purposes means a compromise in one path is transferable to the other. `packages/shared/src/token-crypto.ts` implements the compact HS256 JWT by hand on `node:crypto`. The reason is documented in the file and is a good one: the identical code has to run in the CommonJS api and the ESM collab server without module-interop or dependency-version drift. The implementation is careful — HS256 only, constant-time comparison before any untrusted field is read. It is nevertheless hand-written crypto in the trust boundary, which is a finding in any assessment regardless of its quality. ADR 0019 states the application implements no security base function. Token signing is not one — but it is crypto we do perform, so it has to be minimal, purpose-bound and delegated to a vetted implementation. ## Decision 1. **Purpose-bound subkeys via HKDF.** The configured secret becomes a root key from which each purpose derives its own subkey (collaboration tokens, unsubscribe tokens, any future purpose). No code path signs with the root key. 2. **`jose` replaces the homegrown JWT.** It is maintained, audited, works in both module systems, and is dependency-free — which matters for the supply-chain argument. HS256 stays the only accepted algorithm, as an explicit allowlist rather than an implicit default. 3. **Purpose separation is structural, not textual.** The existing `PURPOSE` string prefix in `unsubscribe-token.ts` is superseded by key separation; a token signed for one purpose cannot verify under another because the key differs. 4. **Long-lived tokens get a documented dual-verify window.** Unsubscribe links live in mail that has already been sent, so both derivations are accepted for a stated period with a stated expiry date. The window is a documented fact, not an accident. ## Consequences - The cross-runtime property that motivated the hand-written code must be proven by a test, not assumed — otherwise the reason for the original decision is lost silently. - One new runtime dependency. Accepted: `jose` has no transitive dependencies, so the supply-chain delta is one package. - Rotating the root key invalidates all derived subkeys at once, which is the desired behaviour and needs documenting in the operations manual. - The key hierarchy becomes part of the security documentation (#228) and the delimitation statement's crypto section (#226). ## Implementing issues #188.