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
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
- 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.
josereplaces 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.- Purpose separation is structural, not textual. The existing
PURPOSEstring prefix inunsubscribe-token.tsis superseded by key separation; a token signed for one purpose cannot verify under another because the key differs. - 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:
josehas 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.