chore: add new csrf path and scheduler
This commit is contained in:
@@ -23,6 +23,7 @@ VAULT_SECRET_PATH=database
|
|||||||
VAULT_JWT_KID_PATH=jwt/kid
|
VAULT_JWT_KID_PATH=jwt/kid
|
||||||
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||||
VAULT_CSRF_SECRET_PATH=cryptowallet/csrf
|
VAULT_CSRF_SECRET_PATH=cryptowallet/csrf
|
||||||
|
VAULT_SECRETS_REFRESH_MS=3600000
|
||||||
|
|
||||||
# CSRF (min 32 chars if not using Vault CSRF path)
|
# CSRF (min 32 chars if not using Vault CSRF path)
|
||||||
CSRF_SECRET_KEY=change-me-to-at-least-32-chars-long!!
|
CSRF_SECRET_KEY=change-me-to-at-least-32-chars-long!!
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export let env = {
|
|||||||
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
||||||
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
||||||
csrfSecretPath: p.VAULT_CSRF_SECRET_PATH || 'cryptowallet/csrf',
|
csrfSecretPath: p.VAULT_CSRF_SECRET_PATH || 'cryptowallet/csrf',
|
||||||
|
secretsRefreshMs: parseInt(p.VAULT_SECRETS_REFRESH_MS || '3600000', 10),
|
||||||
},
|
},
|
||||||
csrf: {
|
csrf: {
|
||||||
cookieSecure: p.CSRF_COOKIE_SECURE === 'true',
|
cookieSecure: p.CSRF_COOKIE_SECURE === 'true',
|
||||||
@@ -86,6 +87,7 @@ export function getVaultToken(): string | null {
|
|||||||
return vaultToken;
|
return vaultToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function initEnv(): Promise<void> {
|
export async function initEnv(): Promise<void> {
|
||||||
const { addr, roleId, secretId, mount, secretPath } = env.vault;
|
const { addr, roleId, secretId, mount, secretPath } = env.vault;
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ export async function initEnv(): Promise<void> {
|
|||||||
|
|
||||||
logger.info('Loaded DB secrets from Vault');
|
logger.info('Loaded DB secrets from Vault');
|
||||||
|
|
||||||
const maybeCsrf = secrets.CSRF_SECRET_KEY;
|
const maybeCsrf = secrets.CSRF_SECRET_KEY || secrets.key;
|
||||||
if (maybeCsrf && maybeCsrf.length >= 32) {
|
if (maybeCsrf && maybeCsrf.length >= 32) {
|
||||||
const mod = await import('../services/csrf.service');
|
const mod = await import('../services/csrf.service');
|
||||||
mod.setCsrfSigningKey(maybeCsrf);
|
mod.setCsrfSigningKey(maybeCsrf);
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ import app from './app';
|
|||||||
import { env, initEnv, getVaultToken } from './config/env';
|
import { env, initEnv, getVaultToken } from './config/env';
|
||||||
import { loadJwtKeysFromVault } from './services/jwt.service';
|
import { loadJwtKeysFromVault } from './services/jwt.service';
|
||||||
import { loadCsrfSecretFromVault, finalizeCsrfConfigFromEnv } from './services/csrf.service';
|
import { loadCsrfSecretFromVault, finalizeCsrfConfigFromEnv } from './services/csrf.service';
|
||||||
|
import { startVaultSecretsScheduler, stopVaultSecretsScheduler } from './scheduler/vault-secrets.scheduler';
|
||||||
import { logger } from './lib/logger';
|
import { logger } from './lib/logger';
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
stopVaultSecretsScheduler();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
stopVaultSecretsScheduler();
|
||||||
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
||||||
|
|
||||||
@@ -37,6 +46,7 @@ async function main() {
|
|||||||
|
|
||||||
app.listen(env.port, () => {
|
app.listen(env.port, () => {
|
||||||
logger.info(`Server running on port ${env.port}`);
|
logger.info(`Server running on port ${env.port}`);
|
||||||
|
startVaultSecretsScheduler();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
59
apps/api/src/scheduler/vault-secrets.scheduler.ts
Normal file
59
apps/api/src/scheduler/vault-secrets.scheduler.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { env, getVaultToken } from '../config/env';
|
||||||
|
import {
|
||||||
|
loadCsrfSecretFromVault,
|
||||||
|
loadCsrfSecretFromPrimaryVault,
|
||||||
|
hasCsrfSigningKey,
|
||||||
|
} from '../services/csrf.service';
|
||||||
|
import { loadJwtKeysFromVault } from '../services/jwt.service';
|
||||||
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
|
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
async function runVaultSecretsRefresh(): Promise<void> {
|
||||||
|
if (!env.vault.addr || !env.vault.roleId || !env.vault.secretId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = getVaultToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dedicatedOk = await loadCsrfSecretFromVault({ silent: true });
|
||||||
|
if (!dedicatedOk) {
|
||||||
|
const primaryOk = await loadCsrfSecretFromPrimaryVault({ silent: true });
|
||||||
|
if (!primaryOk && hasCsrfSigningKey()) {
|
||||||
|
logger.warn('Scheduled CSRF reload found no valid secret in Vault, keeping existing key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadJwtKeysFromVault(
|
||||||
|
env.vault.addr,
|
||||||
|
token,
|
||||||
|
env.vault.mount,
|
||||||
|
env.vault.jwtKidPath,
|
||||||
|
env.vault.jwtKidsPrefix,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function startVaultSecretsScheduler(): void {
|
||||||
|
if (intervalHandle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!env.vault.addr || !env.vault.roleId || !env.vault.secretId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ms = env.vault.secretsRefreshMs;
|
||||||
|
const period = Number.isFinite(ms) && ms >= 60000 ? ms : 3600000;
|
||||||
|
intervalHandle = setInterval(() => {
|
||||||
|
void runVaultSecretsRefresh();
|
||||||
|
}, period);
|
||||||
|
logger.info(`Vault secrets refresh scheduled every ${period}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function stopVaultSecretsScheduler(): void {
|
||||||
|
if (intervalHandle) {
|
||||||
|
clearInterval(intervalHandle);
|
||||||
|
intervalHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,19 +83,39 @@ export function getCsrfCookieMaxAgeMs(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function loadCsrfSecretFromVault(): Promise<boolean> {
|
export async function loadCsrfSecretFromVault(opts?: { silent?: boolean }): Promise<boolean> {
|
||||||
|
const silent = opts?.silent ?? false;
|
||||||
const token = getVaultToken();
|
const token = getVaultToken();
|
||||||
if (!token || !env.vault.addr) {
|
if (!token || !env.vault.addr) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const data = await fetchVaultKV2(env.vault.addr, token, env.vault.mount, env.vault.csrfSecretPath);
|
const data = await fetchVaultKV2(env.vault.addr, token, env.vault.mount, env.vault.csrfSecretPath);
|
||||||
const key = data?.CSRF_SECRET_KEY;
|
const secret = data?.CSRF_SECRET_KEY || data?.key;
|
||||||
if (!key || key.length < 32) {
|
if (!secret || secret.length < 32) {
|
||||||
|
if (!silent) {
|
||||||
logger.warn('Vault CSRF secret missing or too short');
|
logger.warn('Vault CSRF secret missing or too short');
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
setCsrfSigningKey(key);
|
setCsrfSigningKey(secret);
|
||||||
logger.info('CSRF signing key loaded from Vault');
|
logger.info(silent ? 'CSRF signing key reloaded from Vault (dedicated path)' : 'CSRF signing key loaded from Vault');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function loadCsrfSecretFromPrimaryVault(opts?: { silent?: boolean }): Promise<boolean> {
|
||||||
|
const silent = opts?.silent ?? false;
|
||||||
|
const token = getVaultToken();
|
||||||
|
if (!token || !env.vault.addr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const data = await fetchVaultKV2(env.vault.addr, token, env.vault.mount, env.vault.secretPath);
|
||||||
|
const secret = data?.CSRF_SECRET_KEY || data?.key;
|
||||||
|
if (!secret || secret.length < 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setCsrfSigningKey(secret);
|
||||||
|
logger.info(silent ? 'CSRF signing key reloaded from Vault (primary secret)' : 'CSRF signing key loaded from Vault (primary secret)');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export async function loadJwtKeysFromVault(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextMap = new Map<string, Awaited<ReturnType<typeof jose.importSPKI>>>();
|
||||||
|
|
||||||
for (const kid of kids) {
|
for (const kid of kids) {
|
||||||
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
|
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
|
||||||
if (!kidSecret?.public_key) {
|
if (!kidSecret?.public_key) {
|
||||||
@@ -54,13 +56,25 @@ export async function loadJwtKeysFromVault(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
|
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
|
||||||
keyMap.set(kid, key);
|
nextMap.set(kid, key);
|
||||||
logger.info(`Loaded JWT public key for kid=${kid}`);
|
logger.info(`Loaded JWT public key for kid=${kid}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(`Failed to import public key for kid=${kid}: ${err.message}`);
|
logger.error(`Failed to import public key for kid=${kid}: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nextMap.size === 0) {
|
||||||
|
if (keyMap.size > 0) {
|
||||||
|
logger.warn('JWT Vault reload produced no importable keys, keeping previous cache');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyMap.clear();
|
||||||
|
for (const [k, v] of nextMap) {
|
||||||
|
keyMap.set(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`JWT key store loaded: ${keyMap.size} key(s)`);
|
logger.info(`JWT key store loaded: ${keyMap.size} key(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user