import { createHmac, timingSafeEqual } from 'node:crypto'; import { z } from 'zod'; import { collabTokenClaimsSchema, type CollabTokenClaims, type CollabTokenVerification, } from './collab-token'; /** * Sign and verify collaboration tokens (issue #34, ADR 0007). These are the * ONLY JWTs in the system. Implemented with `node:crypto` rather than a library * so the exact same code runs in the CommonJS api and the ESM collab server * without module-interop or dependency-version drift. This module pulls in a * Node built-in, so it lives outside the browser-safe package barrel and is * imported via `@dorfteich/shared/token-crypto`. * * The token is a standard compact HS256 JWT. Only HS256 is ever produced or * accepted; the signature is checked in constant time before any untrusted * field is read. */ const HEADER = { alg: 'HS256', typ: 'JWT' } as const; /** 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().nonnegative(), }); function b64url(value: string): string { return Buffer.from(value, 'utf8').toString('base64url'); } function hmac(signingInput: string, secret: string): string { return createHmac('sha256', secret).update(signingInput).digest('base64url'); } /** Sign a collaboration token that expires `ttlSeconds` from now. */ export function signCollabToken( claims: CollabTokenClaims, secret: string, ttlSeconds: number, ): string { const now = Math.floor(Date.now() / 1000); const payload = { ...collabTokenClaimsSchema.parse(claims), iat: now, exp: now + ttlSeconds }; const signingInput = `${b64url(JSON.stringify(HEADER))}.${b64url(JSON.stringify(payload))}`; return `${signingInput}.${hmac(signingInput, secret)}`; } /** Verify signature, algorithm, claims, and expiry. Never throws. */ export function verifyCollabToken( token: string, secret: string, now: number = Date.now(), ): CollabTokenVerification { const parts = token.split('.'); const [headerPart, payloadPart, signaturePart] = parts; if ( parts.length !== 3 || headerPart === undefined || payloadPart === undefined || signaturePart === undefined ) { return { valid: false, reason: 'malformed' }; } const signingInput = `${headerPart}.${payloadPart}`; // Verify the signature (recomputed with HS256, ignoring the header's claimed // algorithm) before reading any field — defeats alg-confusion and forgery. const expected = Buffer.from(hmac(signingInput, secret), 'utf8'); const provided = Buffer.from(signaturePart, 'utf8'); if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) { return { valid: false, reason: 'bad_signature' }; } let header: unknown; let payload: unknown; try { header = JSON.parse(Buffer.from(headerPart, 'base64url').toString('utf8')); payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')); } catch { return { valid: false, reason: 'malformed' }; } // Defense in depth: even though the signature already pinned HS256, reject a // token whose header advertises anything else (e.g. `none`). if ( typeof header !== 'object' || header === null || (header as { alg?: unknown }).alg !== 'HS256' ) { return { valid: false, reason: 'bad_algorithm' }; } 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 } }; }