fix(security): configure persistent JWT secret and add startup warnings

This commit is contained in:
Brad Groux 2026-01-28 17:44:26 -06:00
parent d756889a1f
commit 86e03f7b00
4 changed files with 172 additions and 21 deletions

View file

@ -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"}

View file

@ -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": "<script>alert(1)</script>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",

View file

@ -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
*/

View file

@ -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);
}
}
});