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
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
105 lines
4.0 KiB
TypeScript
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 } };
|
|
}
|