chore: initial deploy bundle
This commit is contained in:
130
apps/api/src/services/csrf.service.ts
Normal file
130
apps/api/src/services/csrf.service.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import crypto from 'crypto';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
let csrfSecret: string | null = null;
|
||||
let csrfSalt = 'itsdangerous.Signer';
|
||||
let csrfDigest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
|
||||
let csrfMaxAgeSec = 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
export async function loadCsrfSecret(
|
||||
addr: string,
|
||||
token: string,
|
||||
mount: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
const { fetchVaultKV2 } = await import('../config/vault');
|
||||
|
||||
const secrets = await fetchVaultKV2(addr, token, mount, path);
|
||||
if (!secrets) {
|
||||
logger.warn('Failed to load CSRF secret from Vault');
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
|
||||
if (!secret) {
|
||||
logger.warn('CSRF secret not found in Vault payload (expected key: secret_key)');
|
||||
return;
|
||||
}
|
||||
|
||||
csrfSecret = secret;
|
||||
if (secrets.salt) csrfSalt = secrets.salt;
|
||||
if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||
csrfDigest = secrets.digest as 'sha1' | 'sha256' | 'sha512';
|
||||
}
|
||||
if (secrets.max_age_sec) {
|
||||
const n = parseInt(secrets.max_age_sec);
|
||||
if (!Number.isNaN(n) && n > 0) csrfMaxAgeSec = n;
|
||||
}
|
||||
|
||||
logger.info(`CSRF secret loaded (digest=${csrfDigest}, salt="${csrfSalt}", max_age=${csrfMaxAgeSec}s)`);
|
||||
}
|
||||
|
||||
export function isCsrfConfigured(): boolean {
|
||||
return csrfSecret !== null;
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): Buffer {
|
||||
// itsdangerous strips padding
|
||||
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 {
|
||||
// itsdangerous `Signer.derive_key`: HMAC(secret, salt + "signer")
|
||||
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 (!csrfSecret) 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); // "<payload>.<timestamp>"
|
||||
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(csrfSecret, csrfSalt, csrfDigest);
|
||||
const expectedSig = crypto.createHmac(csrfDigest, 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' };
|
||||
}
|
||||
|
||||
// Timestamp check
|
||||
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 > csrfMaxAgeSec) return { valid: false, reason: 'Token expired' };
|
||||
} catch {
|
||||
return { valid: false, reason: 'Invalid timestamp' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
70
apps/api/src/services/key-rotation.service.ts
Normal file
70
apps/api/src/services/key-rotation.service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { env, getVaultToken } from '../config/env';
|
||||
import { vaultAppRoleLogin } from '../config/vault';
|
||||
import { loadJwtKeysFromVault } from './jwt.service';
|
||||
import { loadCsrfSecret } from './csrf.service';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let currentVaultToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Refresh JWT public keys (active + previous) and CSRF secret from Vault.
|
||||
* Errors are logged but do NOT throw — старые значения остаются в памяти,
|
||||
* сервис продолжает работать до следующего успешного refresh.
|
||||
*/
|
||||
export async function refreshAllKeys(): Promise<void> {
|
||||
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault;
|
||||
|
||||
if (!addr || !roleId || !secretId) {
|
||||
logger.warn('Vault not configured, skipping key refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use token from initEnv first call; re-login only if we don't have one yet.
|
||||
let token = currentVaultToken || getVaultToken();
|
||||
if (!token) {
|
||||
const fresh = await vaultAppRoleLogin(addr, roleId, secretId);
|
||||
if (!fresh) {
|
||||
logger.error('Key refresh: Vault AppRole login failed');
|
||||
return;
|
||||
}
|
||||
token = fresh;
|
||||
currentVaultToken = fresh;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to refresh JWT keys: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await loadCsrfSecret(addr, token, mount, csrfPath);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to refresh CSRF secret: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void {
|
||||
if (timer) return;
|
||||
timer = setInterval(() => {
|
||||
logger.info('Refreshing keys from Vault...');
|
||||
void refreshAllKeys().catch((err) =>
|
||||
logger.error(`Key rotation tick failed: ${err?.message || err}`)
|
||||
);
|
||||
// On token expiry Vault will return 403 — we need to re-login.
|
||||
// Reset cached token so refreshAllKeys re-logs in on next call.
|
||||
currentVaultToken = null;
|
||||
}, intervalMs);
|
||||
logger.info(`Key rotation scheduled (every ${intervalMs}ms)`);
|
||||
}
|
||||
|
||||
export function stopKeyRotation(): void {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
logger.info('Key rotation stopped');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user