From 86e03f7b00bc0789bca46f6b8c74c0767126ddef Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Wed, 28 Jan 2026 17:44:26 -0600 Subject: [PATCH] fix(security): configure persistent JWT secret and add startup warnings --- .../telemetry/events-2026-01-28.ndjson | 4 + server/.veritas-kanban/activity.json | 73 +++++++++++++ server/src/config/security.ts | 103 ++++++++++++++---- server/src/index.ts | 13 +++ 4 files changed, 172 insertions(+), 21 deletions(-) diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index 06e11e93..ea96022f 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -3993,3 +3993,7 @@ {"type":"task.created","taskId":"task_20260128_DitDSJ","status":"todo","id":"evt_DlTp17BOGLes","timestamp":"2026-01-28T23:41:55.036Z"} {"type":"task.created","taskId":"task_20260128_SN_PU_","status":"todo","id":"evt__1cavZPnhbtN","timestamp":"2026-01-28T23:41:55.050Z"} {"type":"task.created","taskId":"task_20260128_EKURfN","status":"todo","id":"evt_aY2RTwNmT6xc","timestamp":"2026-01-28T23:41:55.068Z"} +{"type":"task.status_changed","taskId":"task_20260128_ilLJ0m","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_flSz3NXHtfju","timestamp":"2026-01-28T23:42:35.221Z"} +{"type":"task.status_changed","taskId":"task_20260128_HRYh0i","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_9fnaNGiBHc0j","timestamp":"2026-01-28T23:43:07.199Z"} +{"type":"task.status_changed","taskId":"task_20260128_MIuR31","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_vpomzr2xER-c","timestamp":"2026-01-28T23:43:15.220Z"} +{"type":"task.status_changed","taskId":"task_20260128_N5mwaB","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_E8bkQrPM61eB","timestamp":"2026-01-28T23:43:42.234Z"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index 45137c11..5388466f 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,77 @@ [ + { + "id": "activity_1769643822234_p72ucq50f", + "type": "status_changed", + "taskId": "task_20260128_N5mwaB", + "taskTitle": "SECURITY: Replace unsafe-inline/unsafe-eval CSP with nonces in dev mode", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T23:43:42.234Z" + }, + { + "id": "activity_1769643804595_uw3xbec7l", + "type": "task_deleted", + "taskId": "task_20260128_DnZzn9", + "taskTitle": "Test Task", + "timestamp": "2026-01-28T23:43:24.595Z" + }, + { + "id": "activity_1769643795220_e1itoqlgf", + "type": "status_changed", + "taskId": "task_20260128_MIuR31", + "taskTitle": "SECURITY: Configure production API keys and persistent JWT secret", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T23:43:15.220Z" + }, + { + "id": "activity_1769643787217_keetuu934", + "type": "comment_added", + "taskId": "task_20260128_HRYh0i", + "taskTitle": "SECURITY: Add server-side HTML sanitization for XSS payloads", + "details": { + "author": "Veritas", + "preview": "Added server-side HTML sanitization using sanitize..." + }, + "timestamp": "2026-01-28T23:43:07.217Z" + }, + { + "id": "activity_1769643787199_lmnmclef9", + "type": "status_changed", + "taskId": "task_20260128_HRYh0i", + "taskTitle": "SECURITY: Add server-side HTML sanitization for XSS payloads", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T23:43:07.199Z" + }, + { + "id": "activity_1769643755232_1bm32yfuf", + "type": "comment_added", + "taskId": "task_20260128_ilLJ0m", + "taskTitle": "SECURITY: Replace weak development admin key", + "details": { + "author": "Veritas", + "preview": "Replaced weak dev-admin-key with a cryptographical..." + }, + "timestamp": "2026-01-28T23:42:35.232Z" + }, + { + "id": "activity_1769643755221_5h1l14vqu", + "type": "status_changed", + "taskId": "task_20260128_ilLJ0m", + "taskTitle": "SECURITY: Replace weak development admin key", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T23:42:35.221Z" + }, { "id": "activity_1769643722992_qgjkxc0xc", "type": "task_deleted", diff --git a/server/src/config/security.ts b/server/src/config/security.ts index 3ac557b1..bf36de2b 100644 --- a/server/src/config/security.ts +++ b/server/src/config/security.ts @@ -54,12 +54,15 @@ let runtimeJwtSecret: string | null = null; */ export function getSecurityConfig(): SecurityConfig { const now = Date.now(); - + // Use cache in production, refresh in dev - if (cachedConfig && (process.env.NODE_ENV === 'production' || now - lastLoadTime < CACHE_TTL_MS)) { + if ( + cachedConfig && + (process.env.NODE_ENV === 'production' || now - lastLoadTime < CACHE_TTL_MS) + ) { return cachedConfig; } - + try { if (fs.existsSync(SECURITY_CONFIG_PATH)) { const data = fs.readFileSync(SECURITY_CONFIG_PATH, 'utf-8'); @@ -70,7 +73,7 @@ export function getSecurityConfig(): SecurityConfig { } catch (err) { console.error('Error loading security config:', err); } - + // Default config cachedConfig = { authEnabled: false, // Disabled until setup @@ -85,7 +88,7 @@ const SECRET_GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; /** * Get the current (latest) JWT signing secret. * Used for **signing** new tokens. - * + * * Priority: VERITAS_JWT_SECRET env var > jwtSecrets array (latest) > legacy jwtSecret > runtime-generated */ export function getJwtSecret(): string { @@ -135,7 +138,7 @@ export function getValidJwtSecrets(): string[] { // If we have the jwtSecrets array, filter out expired entries if (config.jwtSecrets && config.jwtSecrets.length > 0) { const validEntries = config.jwtSecrets - .filter(entry => { + .filter((entry) => { // No expiresAt means it's the current secret — always valid if (!entry.expiresAt) return true; // Check grace period @@ -144,7 +147,7 @@ export function getValidJwtSecrets(): string[] { .sort((a, b) => b.version - a.version); if (validEntries.length > 0) { - return validEntries.map(e => e.secret); + return validEntries.map((e) => e.secret); } } @@ -158,7 +161,7 @@ export function getValidJwtSecrets(): string[] { * - Moves the previous current secret to a grace period (default 7 days) * - Purges any secrets past their grace period * - Returns the new version number - * + * * NOTE: Has no effect when VERITAS_JWT_SECRET env var is set (rotation * must be done externally in that case). */ @@ -173,7 +176,8 @@ export function rotateJwtSecret(gracePeriodMs: number = SECRET_GRACE_PERIOD_MS): success: false, newVersion: 0, prunedCount: 0, - message: 'Cannot rotate: JWT secret is managed via VERITAS_JWT_SECRET environment variable. Rotate externally.', + message: + 'Cannot rotate: JWT secret is managed via VERITAS_JWT_SECRET environment variable. Rotate externally.', }; } @@ -196,7 +200,7 @@ export function rotateJwtSecret(gracePeriodMs: number = SECRET_GRACE_PERIOD_MS): } // 1. Set grace period on the current (latest) secret - const latestEntry = secrets.find(s => !s.expiresAt); + const latestEntry = secrets.find((s) => !s.expiresAt); if (latestEntry) { latestEntry.expiresAt = new Date(now.getTime() + gracePeriodMs).toISOString(); } @@ -213,7 +217,7 @@ export function rotateJwtSecret(gracePeriodMs: number = SECRET_GRACE_PERIOD_MS): // 3. Prune expired secrets const beforeCount = secrets.length; - secrets = secrets.filter(entry => { + secrets = secrets.filter((entry) => { if (!entry.expiresAt) return true; return new Date(entry.expiresAt).getTime() > now.getTime(); }); @@ -229,7 +233,9 @@ export function rotateJwtSecret(gracePeriodMs: number = SECRET_GRACE_PERIOD_MS): }; saveSecurityConfig(updatedConfig); - console.log(`JWT secret rotated to version ${newVersion}. ${prunedCount} expired secret(s) pruned.`); + console.log( + `JWT secret rotated to version ${newVersion}. ${prunedCount} expired secret(s) pruned.` + ); return { success: true, @@ -255,8 +261,8 @@ export function getJwtRotationStatus(): { if (usingEnvVar || !config.jwtSecrets || config.jwtSecrets.length === 0) { return { currentVersion: config.jwtSecretVersion || 0, - totalSecrets: usingEnvVar ? 1 : (config.jwtSecret ? 1 : 0), - validSecrets: usingEnvVar ? 1 : (config.jwtSecret ? 1 : 0), + totalSecrets: usingEnvVar ? 1 : config.jwtSecret ? 1 : 0, + validSecrets: usingEnvVar ? 1 : config.jwtSecret ? 1 : 0, usingEnvVar, secrets: [], }; @@ -268,9 +274,10 @@ export function getJwtRotationStatus(): { return { currentVersion, totalSecrets: sorted.length, - validSecrets: sorted.filter(s => !s.expiresAt || new Date(s.expiresAt).getTime() > now).length, + validSecrets: sorted.filter((s) => !s.expiresAt || new Date(s.expiresAt).getTime() > now) + .length, usingEnvVar, - secrets: sorted.map(s => ({ + secrets: sorted.map((s) => ({ version: s.version, createdAt: s.createdAt, expiresAt: s.expiresAt, @@ -288,16 +295,16 @@ export function saveSecurityConfig(config: SecurityConfig): void { if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } - + // Write atomically (write to temp, then rename) const tempPath = SECURITY_CONFIG_PATH + '.tmp'; fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), 'utf-8'); fs.renameSync(tempPath, SECURITY_CONFIG_PATH); - + // Update cache cachedConfig = config; lastLoadTime = Date.now(); - + console.log('Security config saved'); } catch (err) { console.error('Error saving security config:', err); @@ -313,14 +320,14 @@ export function generateRecoveryKey(): string { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Omit confusing chars (0/O, 1/I/L) let key = ''; const bytes = crypto.randomBytes(16); - + for (let i = 0; i < 16; i++) { key += chars[bytes[i] % chars.length]; if (i === 3 || i === 7 || i === 11) { key += '-'; } } - + return key; } @@ -349,6 +356,60 @@ export function resetSecurityConfig(): void { console.log('Security config reset. Next load will show setup screen.'); } +/** Warning about JWT secret configuration */ +export interface JwtSecretWarning { + level: 'critical' | 'warning' | 'info'; + message: string; +} + +/** + * Check JWT secret configuration and return startup warnings. + * Returns an array of warnings (empty if the secret is properly configured). + */ +export function checkJwtSecretConfig(): JwtSecretWarning[] { + const warnings: JwtSecretWarning[] = []; + const envSecret = process.env.VERITAS_JWT_SECRET; + + if (envSecret) { + // Env var set — best practice + if (envSecret.length < 64) { + warnings.push({ + level: 'warning', + message: `VERITAS_JWT_SECRET is only ${envSecret.length} characters. Recommend at least 64 characters (use: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))")`, + }); + } + return warnings; + } + + // Check if security.json has a persisted secret + const config = getSecurityConfig(); + if (config.jwtSecrets && config.jwtSecrets.length > 0) { + warnings.push({ + level: 'info', + message: + 'JWT secret loaded from security.json. Consider setting VERITAS_JWT_SECRET env var for explicit configuration.', + }); + return warnings; + } + if (config.jwtSecret) { + warnings.push({ + level: 'info', + message: + 'JWT secret loaded from security.json (legacy format). Consider setting VERITAS_JWT_SECRET env var.', + }); + return warnings; + } + + // No env var, no persisted config — will be ephemeral + warnings.push({ + level: 'critical', + message: + 'No VERITAS_JWT_SECRET env var set and no persisted secret found. JWT secret will be generated at runtime (ephemeral) — all sessions will be invalidated on server restart. Set VERITAS_JWT_SECRET in .env for persistence.', + }); + + return warnings; +} + /** * Check if password is configured */ diff --git a/server/src/index.ts b/server/src/index.ts index 99113ced..3bcc4df0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -30,6 +30,7 @@ import { type AuthenticatedWebSocket, } from './middleware/auth.js'; import authRoutes from './routes/auth.js'; +import { checkJwtSecretConfig } from './config/security.js'; import { apiRateLimit } from './middleware/rate-limit.js'; import { apiVersionMiddleware } from './middleware/api-version.js'; import { apiCacheHeaders } from './middleware/cache-control.js'; @@ -599,4 +600,16 @@ server.listen(PORT, () => { log.warn({ security: 'admin-key' }, warning.message); } } + + // Security warnings for JWT secret configuration + const jwtWarnings = checkJwtSecretConfig(); + for (const warning of jwtWarnings) { + if (warning.level === 'critical') { + log.warn({ security: 'jwt-secret' }, `⚠️ SECURITY: ${warning.message}`); + } else if (warning.level === 'warning') { + log.warn({ security: 'jwt-secret' }, warning.message); + } else { + log.info({ security: 'jwt-secret' }, warning.message); + } + } });