diff --git a/.env.example b/.env.example index eb9d4ff..1d52801 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ VAULT_SECRET_PATH=database VAULT_JWT_KID_PATH=jwt/kid VAULT_JWT_KIDS_PREFIX=jwt/kids VAULT_CSRF_SECRET_PATH=cryptowallet/csrf +VAULT_SECRETS_REFRESH_MS=3600000 # CSRF (min 32 chars if not using Vault CSRF path) CSRF_SECRET_KEY=change-me-to-at-least-32-chars-long!! diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index de9fa83..9483129 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -36,6 +36,7 @@ export let env = { jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid', jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids', csrfSecretPath: p.VAULT_CSRF_SECRET_PATH || 'cryptowallet/csrf', + secretsRefreshMs: parseInt(p.VAULT_SECRETS_REFRESH_MS || '3600000', 10), }, csrf: { cookieSecure: p.CSRF_COOKIE_SECURE === 'true', @@ -86,6 +87,7 @@ export function getVaultToken(): string | null { return vaultToken; } + export async function initEnv(): Promise { const { addr, roleId, secretId, mount, secretPath } = env.vault; @@ -111,7 +113,7 @@ export async function initEnv(): Promise { 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) { const mod = await import('../services/csrf.service'); mod.setCsrfSigningKey(maybeCsrf); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 48abea5..120dc4e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,8 +4,17 @@ import app from './app'; import { env, initEnv, getVaultToken } from './config/env'; import { loadJwtKeysFromVault } from './services/jwt.service'; import { loadCsrfSecretFromVault, finalizeCsrfConfigFromEnv } from './services/csrf.service'; +import { startVaultSecretsScheduler, stopVaultSecretsScheduler } from './scheduler/vault-secrets.scheduler'; import { logger } from './lib/logger'; +process.on('SIGTERM', () => { + stopVaultSecretsScheduler(); +}); + +process.on('SIGINT', () => { + stopVaultSecretsScheduler(); +}); + async function main() { logger.info(`Wallet service instance started with id ${logger.instanceId}`); @@ -37,6 +46,7 @@ async function main() { app.listen(env.port, () => { logger.info(`Server running on port ${env.port}`); + startVaultSecretsScheduler(); }); } diff --git a/apps/api/src/scheduler/vault-secrets.scheduler.ts b/apps/api/src/scheduler/vault-secrets.scheduler.ts new file mode 100644 index 0000000..2dd1a0e --- /dev/null +++ b/apps/api/src/scheduler/vault-secrets.scheduler.ts @@ -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 | null = null; + + +async function runVaultSecretsRefresh(): Promise { + 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; + } +} diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index c30ec9b..9561e1f 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -83,19 +83,39 @@ export function getCsrfCookieMaxAgeMs(): number { } -export async function loadCsrfSecretFromVault(): Promise { +export async function loadCsrfSecretFromVault(opts?: { silent?: boolean }): Promise { + 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.csrfSecretPath); - const key = data?.CSRF_SECRET_KEY; - if (!key || key.length < 32) { - logger.warn('Vault CSRF secret missing or too short'); + const secret = data?.CSRF_SECRET_KEY || data?.key; + if (!secret || secret.length < 32) { + if (!silent) { + logger.warn('Vault CSRF secret missing or too short'); + } return false; } - setCsrfSigningKey(key); - logger.info('CSRF signing key loaded from Vault'); + setCsrfSigningKey(secret); + 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 { + 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; } diff --git a/apps/api/src/services/jwt.service.ts b/apps/api/src/services/jwt.service.ts index c440134..1949441 100644 --- a/apps/api/src/services/jwt.service.ts +++ b/apps/api/src/services/jwt.service.ts @@ -45,6 +45,8 @@ export async function loadJwtKeysFromVault( return; } + const nextMap = new Map>>(); + for (const kid of kids) { const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`); if (!kidSecret?.public_key) { @@ -54,13 +56,25 @@ export async function loadJwtKeysFromVault( try { 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}`); } catch (err: any) { 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)`); }