dorfteich/docs/architecture/adr/0020-token-crypto-key-separation.md
Claude Fable 5 404a3741c8
All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m34s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CI / Lint, typecheck, test (pull_request) Successful in 6m19s
CI / Build container images (pull_request) Successful in 1m14s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m25s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m24s
CI / Import/export fidelity gate (push) Successful in 59s
ADRs 0019-0027: accepted after explicit operator review (2026-07-31)
Stefan reviewed and accepted all nine VS-NfD ADRs one by one. Two
adjustments from the review: ADR 0021 decision 3 now states the #216
refinement in the decision itself (PAT/feed-token issuance stays
available to IdP-authenticated sessions — API authorization under its
own switches, not interactive sign-in) instead of contradicting the
later Decisions section; and the ADR 0020 dual-verify window will be
removed early (issue #296) rather than waiting for its stated expiry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 20:47:27 +02:00

60 lines
2.7 KiB
Markdown

# ADR 0020: Token crypto — HKDF key separation and a vetted JWT library
- Status: accepted (2026-07-31)
- 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.