Files
cryptowallet/apps/api/src/services/csrf.service.ts

146 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}