dorfteich/packages/shared/src/token-crypto.ts
Claude Fable 5 3d1f4fda53
All checks were successful
CI / Build container images (pull_request) Successful in 3m51s
CI / Auth e2e pack (pull_request) Successful in 7m49s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CI / Lint, typecheck, test (pull_request) Successful in 4m43s
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m39s
CI / Import/export fidelity gate (push) Successful in 59s
#188: purpose-bound token keys via HKDF, jose replaces the homegrown JWT
COLLAB_TOKEN_SECRET becomes a root key: every purpose derives its own
HKDF-SHA-256 subkey (deriveTokenKey), and no code path signs with the
root key directly. Collaboration tokens are signed and verified by jose
with HS256 as an explicit allowlist; the sign/verify API turns async at
its three call sites. Unsubscribe tokens move from a purpose-prefix
string to the structural subkey, with a documented dual-verify window
(legacy derivation accepted until 2026-11-01, covering the 90-day TTL
of links in already-sent mail).

The cross-runtime property that justified the homegrown implementation
is now proven by a test: the built CJS and ESM dist artefacts round-trip
tokens in both directions in child processes (jose v6 reaches CJS via
Node's require(esm), pinned Node 22 images). Negative tests cover
cross-purpose subkeys, root-key-signed tokens, alg:none and RS256.

Refs #188 (ADR 0020)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 06:41:11 +02:00

105 lines
4.0 KiB
TypeScript

import { hkdfSync } from 'node:crypto';
import { errors as joseErrors, jwtVerify, SignJWT } from 'jose';
import { z } from 'zod';
import {
collabTokenClaimsSchema,
type CollabTokenClaims,
type CollabTokenVerification,
} from './collab-token';
/**
* Purpose-bound token keys and collaboration-token signing (issue #34,
* ADR 0007; key separation and `jose` per ADR 0020, issue #188).
*
* The configured `COLLAB_TOKEN_SECRET` is a ROOT key: every purpose derives
* its own subkey via HKDF-SHA-256, and no code path signs with the root key
* directly. A token signed for one purpose cannot verify under another
* because the keys differ — the separation is structural, not a string
* prefix in the signed payload.
*
* Signing and verification go through `jose` (vetted, dependency-free)
* with HS256 as an explicit algorithm allowlist. The same module must work
* in the CommonJS api and the ESM collab server — proven by
* `token-crypto.crossruntime.test.ts` rather than assumed (jose v6 is
* ESM-only and reaches CJS via Node's `require(esm)`, available in the
* pinned Node 22 images). This module pulls in Node built-ins, so it lives
* outside the browser-safe package barrel and is imported via
* `@dorfteich/shared/token-crypto`.
*/
/** Every purpose a subkey is derived for. Add here — never reuse a key. */
export type TokenPurpose = 'collab' | 'unsubscribe';
/** Fixed HKDF salt: domain-separates this application's key hierarchy. */
const HKDF_SALT = 'dorfteich-token-keys';
/**
* Derive the 32-byte subkey for `purpose` from the configured root secret.
* Deterministic: rotation of the root secret rotates every subkey at once,
* which is the intended behaviour (ADR 0020).
*/
export function deriveTokenKey(rootSecret: string, purpose: TokenPurpose): Uint8Array {
return new Uint8Array(hkdfSync('sha256', rootSecret, HKDF_SALT, `dorfteich/${purpose}/v1`, 32));
}
/** Full JWT payload: the claims plus standard `iat`/`exp` (seconds since epoch). */
const payloadSchema = collabTokenClaimsSchema.extend({
iat: z.number().int().nonnegative(),
exp: z.number().int(),
});
/** Sign a collaboration token that expires `ttlSeconds` from now. */
export async function signCollabToken(
claims: CollabTokenClaims,
rootSecret: string,
ttlSeconds: number,
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return await new SignJWT({ ...collabTokenClaimsSchema.parse(claims) })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt(now)
.setExpirationTime(now + ttlSeconds)
.sign(deriveTokenKey(rootSecret, 'collab'));
}
/** Map a `jose` verification error onto the stable rejection reasons. */
function reasonOf(error: unknown): Exclude<CollabTokenVerification, { valid: true }>['reason'] {
if (error instanceof joseErrors.JWTExpired) return 'expired';
if (error instanceof joseErrors.JOSEAlgNotAllowed) return 'bad_algorithm';
if (error instanceof joseErrors.JWSSignatureVerificationFailed) return 'bad_signature';
if (error instanceof joseErrors.JWTClaimValidationFailed) return 'invalid_claims';
return 'malformed';
}
/** Verify signature, algorithm, claims, and expiry. Never throws. */
export async function verifyCollabToken(
token: string,
rootSecret: string,
now: number = Date.now(),
): Promise<CollabTokenVerification> {
let payload: unknown;
try {
({ payload } = await jwtVerify(token, deriveTokenKey(rootSecret, 'collab'), {
algorithms: ['HS256'],
currentDate: new Date(now),
clockTolerance: 0,
}));
} catch (error) {
return { valid: false, reason: reasonOf(error) };
}
// `jose` treats `exp` as optional; the schema makes it mandatory so a
// token without an expiry can never verify.
const parsed = payloadSchema.safeParse(payload);
if (!parsed.success) {
return { valid: false, reason: 'invalid_claims' };
}
if (parsed.data.exp * 1000 <= now) {
return { valid: false, reason: 'expired' };
}
const { userId, pageId, mode } = parsed.data;
return { valid: true, claims: { userId, pageId, mode } };
}