import crypto from 'crypto'; import { fetchVaultKV2 } from '../config/vault'; /** * CSRF token validation compatible with Python's `itsdangerous` * `URLSafeTimedSerializer` (which Flask-WTF uses). * * Token format: .. * * Default algorithm (itsdangerous ≥ 2.0): * - digest: SHA-512 (HMAC) * - salt: "itsdangerous.Signer" (or app-specific, e.g. "csrf-token") * - derived_key = HMAC(secret, salt + "signer").digest() * - signature = HMAC(derived_key, payload + "." + timestamp).digest() * * Timestamp: seconds since 2011-01-01 (itsdangerous epoch), base64url-encoded big-endian. */ const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time export interface CsrfConfig { secret: string; salt: string; digest: 'sha1' | 'sha256' | 'sha512'; maxAgeSec: number; } // Live config — атомарно подменяется через swapCsrfConfig() let current: CsrfConfig | null = null; export function swapCsrfConfig(cfg: CsrfConfig | null): void { current = cfg; } export function isCsrfConfigured(): boolean { return current !== null; } /** * Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект. */ export async function fetchCsrfConfig( addr: string, token: string, mount: string, path: string, ): Promise { const secrets = await fetchVaultKV2(addr, token, mount, path); if (!secrets) { throw new Error('Failed to load CSRF secret from Vault'); } const secret = secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret; if (!secret || typeof secret !== 'string' || secret.length < 32) { throw new Error('CSRF secret invalid: must be string >= 32 chars'); } const salt = secrets.salt || 'itsdangerous.Signer'; if (typeof salt !== 'string' || salt.length < 8) { throw new Error('CSRF salt invalid: must be string >= 8 chars'); } let digest: 'sha1' | 'sha256' | 'sha512' = 'sha512'; if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') { digest = secrets.digest; } let maxAgeSec = 60 * 60 * 24 * 7; // 7 days if (secrets.max_age_sec) { const n = parseInt(secrets.max_age_sec); if (!Number.isNaN(n) && n > 0) maxAgeSec = n; } return { secret, salt, digest, maxAgeSec }; } function b64urlDecode(s: string): Buffer { const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4); const padded = s + '='.repeat(pad); return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); } function deriveKey(secret: string, salt: string, digest: string): Buffer { return crypto.createHmac(digest, secret).update(salt + 'signer').digest(); } function decodeTimestamp(encoded: string): number { const raw = b64urlDecode(encoded); let ts = 0; for (const b of raw) ts = (ts << 8) | b; return ts + ITSDANGEROUS_EPOCH; } export interface CsrfVerifyResult { valid: boolean; reason?: string; } export function verifyCsrfToken(token: string): CsrfVerifyResult { if (!current) return { valid: false, reason: 'CSRF secret not loaded' }; if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' }; const lastDot = token.lastIndexOf('.'); if (lastDot < 0) return { valid: false, reason: 'Malformed token (no signature)' }; const payloadTs = token.slice(0, lastDot); const sigStr = token.slice(lastDot + 1); const prevDot = payloadTs.lastIndexOf('.'); if (prevDot < 0) return { valid: false, reason: 'Malformed token (no timestamp)' }; const tsStr = payloadTs.slice(prevDot + 1); const derived = deriveKey(current.secret, current.salt, current.digest); const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest(); let actualSig: Buffer; try { actualSig = b64urlDecode(sigStr); } catch { return { valid: false, reason: 'Invalid signature encoding' }; } if (expectedSig.length !== actualSig.length) { return { valid: false, reason: 'Signature length mismatch' }; } if (!crypto.timingSafeEqual(expectedSig, actualSig)) { return { valid: false, reason: 'Signature mismatch' }; } try { const issuedAt = decodeTimestamp(tsStr); const now = Math.floor(Date.now() / 1000); if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' }; if (now - issuedAt > current.maxAgeSec) return { valid: false, reason: 'Token expired' }; } catch { return { valid: false, reason: 'Invalid timestamp' }; } return { valid: true }; }