security: round 3 hardening (CSRF double-submit, TRX MITM, container hardening)

This commit is contained in:
ZOMBIIIIIII
2026-05-12 01:47:58 +03:00
parent c8bc40af97
commit 8dc0855827
37 changed files with 1852 additions and 318 deletions

View File

@@ -2,27 +2,40 @@ import { env, getVaultToken } from '../config/env';
import { vaultAppRoleLogin } from '../config/vault';
import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service';
import { fetchCsrfConfig, swapCsrfConfig } from './csrf.service';
import { fetchMasterKey, swapMasterKey, masterKeyMatches, isCryptoReady } from './crypto.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;
// Inflight guard — reentrant calls share the same promise (audit#4 C2/C3).
// Без этого две параллельные refreshAllKeys могут torn-state'ить keyMap/csrf/crypto.
let inflight: Promise<void> | null = null;
/**
* Atomic refresh: pre-fetch JWT keys + CSRF config, swap globals only if BOTH succeed.
* При любой ошибке оставляем старые значения в памяти, сервис продолжает работать.
* Atomic refresh: pre-fetch JWT/CSRF/crypto secrets, swap globals только если необходимые получены.
* Reentrant-safe.
*/
export async function refreshAllKeys(): Promise<void> {
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault;
if (inflight) return inflight;
inflight = doRefresh().finally(() => {
inflight = null;
});
return inflight;
}
async function doRefresh(): Promise<void> {
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath, cryptoKeyPath } = env.vault;
if (!addr || !roleId || !secretId) {
logger.warn('Vault not configured, skipping key refresh');
return;
}
// Vault token: используем закэшированный из initEnv, либо логинимся заново
let token = currentVaultToken || getVaultToken();
// Каждый refresh — свежий Vault token. Старый optimisation с `currentVaultToken`
// был dead code (синхронный reset перед использованием).
let token = getVaultToken();
if (!token) {
const fresh = await vaultAppRoleLogin(addr, roleId, secretId);
if (!fresh) {
@@ -30,16 +43,14 @@ export async function refreshAllKeys(): Promise<void> {
return;
}
token = fresh;
currentVaultToken = fresh;
}
// ── Pre-fetch обоих секретов параллельно (НЕ мутируя глобал) ───────────
const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
const csrfPromise = csrfPath ? fetchCsrfConfig(addr, token, mount, csrfPath) : Promise.resolve(null);
const cryptoPromise = cryptoKeyPath ? fetchMasterKey(addr, token, mount, cryptoKeyPath) : Promise.resolve(null);
const [jwtResult, csrfResult] = await Promise.allSettled([jwtPromise, csrfPromise]);
const [jwtResult, csrfResult, cryptoResult] = await Promise.allSettled([jwtPromise, csrfPromise, cryptoPromise]);
// ── Атомарность: если хоть один обязательный fetch упал — НИЧЕГО не меняем ──
if (jwtResult.status === 'rejected') {
logger.error(`Key refresh ABORTED — JWT keys fetch failed: ${jwtResult.reason?.message || jwtResult.reason}`);
return;
@@ -48,16 +59,34 @@ export async function refreshAllKeys(): Promise<void> {
logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`);
return;
}
// Master-key: первый load обязателен, дальнейшие failures толерантны.
if (cryptoKeyPath && !isCryptoReady() && cryptoResult.status === 'rejected') {
logger.error(`Key refresh ABORTED — Crypto master key fetch failed: ${cryptoResult.reason?.message || cryptoResult.reason}`);
return;
}
// ── Atomic swap (синхронные операции, нельзя прервать) ──────────────────
// Atomic synchronous swap. JS single-threaded — между swap'ами нет await,
// т.е. observers видят либо все старые, либо все новые значения.
swapKeyMap(jwtResult.value);
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
swapCsrfConfig(csrfResult.value);
}
if (cryptoResult.status === 'fulfilled' && cryptoResult.value) {
if (!isCryptoReady()) {
swapMasterKey(cryptoResult.value);
logger.info('Crypto master key loaded');
} else if (!masterKeyMatches(cryptoResult.value)) {
logger.warn(
'Vault crypto/master key DIFFERS from in-memory key. Service continues with old key. ' +
'Rotating master-key bricks all existing encrypted_mnemonic — revert Vault or plan re-encryption migration.'
);
}
}
logger.info(
`Keys refreshed atomically: JWT keys=${getKeyMapSize()}` +
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '')
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '') +
`, Crypto=${isCryptoReady() ? 'ready' : 'NOT-READY'}`
);
}
@@ -68,8 +97,6 @@ export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void
void refreshAllKeys().catch((err) =>
logger.error(`Key rotation tick failed: ${err?.message || err}`)
);
// На каждый тик — invalidate Vault token (он мог истечь), будет re-login
currentVaultToken = null;
}, intervalMs);
logger.info(`Key rotation scheduled (every ${intervalMs}ms)`);
}