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 { 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['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 { 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 } }; }