import { createHash, randomBytes, timingSafeEqual } from 'crypto'; import { SignJWT, jwtVerify } from 'jose'; import { fetchVaultKV2 } from '../config/vault'; import { env, getVaultToken } from '../config/env'; import { logger } from '../lib/logger'; export const CSRF_COOKIE_NAME = 'csrf_token'; export const CSRF_HEADER_NAME = 'X-CSRF-Token'; const TTL_SECONDS = 3600; let signingKeyBytes: Uint8Array | null = null; export function setCsrfSigningKey(secret: string): void { if (secret.length < 32) { throw new Error('CSRF secret must be at least 32 characters'); } signingKeyBytes = new Uint8Array(createHash('sha256').update(secret, 'utf8').digest()); } export function hasCsrfSigningKey(): boolean { return signingKeyBytes !== null; } function getKey(): Uint8Array { if (!signingKeyBytes) { throw new Error('CSRF signing key not configured'); } return signingKeyBytes; } export async function issueCsrfToken(): Promise { const nonce = randomBytes(24).toString('base64url'); const now = Math.floor(Date.now() / 1000); return new SignJWT({ scope: 'csrf', nce: nonce }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt(now) .setNotBefore(now) .setExpirationTime(now + TTL_SECONDS) .sign(getKey()); } export async function verifyCsrfPair( cookieToken: string | undefined, headerToken: string | undefined, ): Promise { if (!cookieToken || !headerToken) { const e = new Error('CSRF token missing') as Error & { status: number }; e.status = 403; throw e; } const a = Buffer.from(cookieToken); const b = Buffer.from(headerToken); if (a.length !== b.length || !timingSafeEqual(a, b)) { const e = new Error('CSRF token mismatch') as Error & { status: number }; e.status = 403; throw e; } try { const { payload } = await jwtVerify(cookieToken, getKey(), { algorithms: ['HS256'], clockTolerance: 10, }); if (payload.scope !== 'csrf') { throw new Error('invalid'); } } catch { const e = new Error('CSRF token invalid') as Error & { status: number }; e.status = 403; throw e; } } export function getCsrfCookieMaxAgeMs(): number { return TTL_SECONDS * 1000; } export async function loadCsrfSecretFromVault(): Promise { const token = getVaultToken(); if (!token || !env.vault.addr) { return false; } const data = await fetchVaultKV2(env.vault.addr, token, env.vault.mount, env.vault.csrfSecretPath); const key = data?.CSRF_SECRET_KEY; if (!key || key.length < 32) { logger.warn('Vault CSRF secret missing or too short'); return false; } setCsrfSigningKey(key); logger.info('CSRF signing key loaded from Vault'); return true; } export function finalizeCsrfConfigFromEnv(): void { if (hasCsrfSigningKey()) { return; } const k = process.env.CSRF_SECRET_KEY; if (k && k.length >= 32) { setCsrfSigningKey(k); logger.info('CSRF signing key loaded from environment'); return; } logger.error('CSRF_SECRET_KEY is required (Vault path or env, min 32 characters)'); process.exit(1); }