146 lines
4.7 KiB
TypeScript
146 lines
4.7 KiB
TypeScript
import crypto from 'crypto';
|
||
import { fetchVaultKV2 } from '../config/vault';
|
||
|
||
/**
|
||
* CSRF token validation compatible with Python's `itsdangerous`
|
||
* `URLSafeTimedSerializer` (which Flask-WTF uses).
|
||
*
|
||
* Token format: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
|
||
*
|
||
* 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: '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<CsrfConfig> {
|
||
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');
|
||
}
|
||
|
||
// sha1 deprecated — accept только sha256/sha512.
|
||
let digest: 'sha256' | 'sha512' = 'sha512';
|
||
if (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);
|
||
// Использовать арифметику вместо bitwise — bitwise overflowит на 32-bit signed
|
||
// после 2038 если timestamp encoding станет 5-байтным.
|
||
let ts = 0;
|
||
for (const b of raw) ts = ts * 256 + 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 };
|
||
}
|