Add docs/vs-nfd/: the analysis brief, the as-is assessment (42 findings, all verified against the code), the prioritized action plan rev. 2 with issue references written back to every checkbox, the two-stage issue/ADR brief, and the full reviewed draft used to create the forge state. Add eight proposed ADRs 0019-0026 covering the VS-NfD architecture decisions: no security base functions (par. 52 VSA anchor), HKDF token key separation, external authentication, page classification, read-access audit trail (variant A), reproducible offline deployment, plugin trust model, and backup target restriction. Forge state created alongside this commit: 11 labels, milestones M24-M31, issues #188-#236 (docs-only change, no code touched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2.7 KiB
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
- 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.