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

2.7 KiB

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.