Files
TREK/server/src/config.ts
T
Maurice a7aaa29c16 fix(db): stop losing secrets and notes to the boot and migration paths
An unreadable or empty encryption-key file was treated the same as a missing
one: regenerate and overwrite. That is the one case where regenerating is
the worst possible move - every secret already at rest becomes undecryptable,
permanently. Both now refuse to start and say which file to fix.

The key-rotation script advertised re-encrypting all stored secrets while its
hardcoded column lists had drifted: the AirTrail key, the per-user LLM key
and the plugin OAuth tokens were never migrated, so they silently stopped
decrypting after a rotation. The parity test now pins the full set against
what the code actually encrypts, rather than two tables of it.

The schema_version bump ran outside the migration's transaction, so a crash
in between left the schema advanced and the version stale - the next boot
replayed a non-idempotent step and exited, turning one crash into a boot
loop. And the TICKETJSON receipt migration matched with LIKE, which SQLite
treats case-insensitively, so a note somebody had typed starting 'ticketjson:'
was cut up and its text dropped.

Also: APP_VERSION was interpolated into migration SQL instead of bound, the
capture-metadata backfill passed (photoId, userId) to a (userId, photoId)
signature and its mock keyed on the wrong argument, TRUST_PROXY=0 was
accepted and silently coerced to 1, demo-reset hardcoded the db path instead
of honouring TREK_DB_FILE, and a demo instance with no DEMO_ADMIN_PASS set
now says out loud that its admin password is the published default.
2026-08-23 11:59:12 +02:00

150 lines
6.8 KiB
TypeScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { readEnv } from './app-config';
const dataDir = path.resolve(__dirname, '../data');
const jwtSecretFile = path.join(dataDir, '.jwt_secret');
// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets
// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.).
// Keeping it separate from JWT_SECRET means you can rotate session tokens without
// invalidating all stored encrypted data, and vice-versa.
//
// Resolution order:
// 1. ENCRYPTION_KEY env var — explicit, always takes priority.
// 2. data/.encryption_key file — present on any install that has started at
// least once (written automatically by cases 1b and 3 below).
// 3. data/.jwt_secret — one-time fallback for existing installs upgrading
// without a pre-set ENCRYPTION_KEY. The value is immediately persisted to
// data/.encryption_key so JWT rotation can never break decryption later.
// 4. Auto-generated — fresh install with none of the above; persisted to
// data/.encryption_key.
const encKeyFile = path.join(dataDir, '.encryption_key');
let _encryptionKey: string = process.env.ENCRYPTION_KEY || '';
if (_encryptionKey) {
// Env var is set explicitly — persist it to file so the value survives
// container restarts even if the env var is later removed.
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
} catch {
// Non-fatal: env var is the source of truth when set.
}
} else {
// Try the dedicated key file first (covers all installs after first start).
try {
_encryptionKey = fs.readFileSync(encKeyFile, 'utf8').trim();
} catch (readErr: unknown) {
if ((readErr as NodeJS.ErrnoException)?.code !== 'ENOENT') {
// The file is there, we just cannot read it (permissions, I/O error).
// Continuing would resolve a different key and overwrite this one below,
// which permanently orphans every stored secret. Refuse to start instead.
console.error(
'FATAL: could not read',
encKeyFile,
'-',
readErr instanceof Error ? readErr.message : readErr,
);
console.error('Fix the file permissions or set ENCRYPTION_KEY explicitly.');
process.exit(1);
}
// File not found — first start on an existing or fresh install.
}
if (!_encryptionKey && fs.existsSync(encKeyFile)) {
// Present but empty: a truncated write, a full disk, a broken restore.
// Same reasoning as above. Regenerating here would silently replace the real key.
console.error('FATAL:', encKeyFile, 'is empty. Restore it from backup or set ENCRYPTION_KEY.');
process.exit(1);
}
if (!_encryptionKey) {
// One-time migration: existing install upgrading for the first time.
// Use the JWT secret as the encryption key and immediately write it to
// .encryption_key so future JWT rotations cannot break decryption.
try {
_encryptionKey = fs.readFileSync(jwtSecretFile, 'utf8').trim();
console.warn('WARNING: ENCRYPTION_KEY is not set. Falling back to JWT secret for at-rest encryption.');
console.warn('The value has been persisted to data/.encryption_key — JWT rotation is now safe.');
} catch {
// JWT secret not found — must be a fresh install.
}
}
if (!_encryptionKey) {
// Fresh install — auto-generate a dedicated key.
_encryptionKey = crypto.randomBytes(32).toString('hex');
}
// Persist whatever key was resolved so subsequent starts skip the fallback chain.
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
console.log('Encryption key persisted to', encKeyFile);
} catch (writeErr: unknown) {
console.warn(
'WARNING: Could not persist encryption key to disk:',
writeErr instanceof Error ? writeErr.message : writeErr,
);
console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
}
}
export const ENCRYPTION_KEY = _encryptionKey;
// JWT_SECRET is always managed by the server — auto-generated on first start and
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
// via environment variable (env var would override a rotation on next restart).
let _jwtSecret: string;
try {
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
} catch {
_jwtSecret = crypto.randomBytes(32).toString('hex');
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
console.log('Generated and saved JWT secret to', jwtSecretFile);
} catch (writeErr: unknown) {
console.warn(
'WARNING: Could not persist JWT secret to disk:',
writeErr instanceof Error ? writeErr.message : writeErr,
);
console.warn('Sessions will reset on server restart.');
}
}
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
export let JWT_SECRET = _jwtSecret;
// Called by the admin rotate-jwt-secret endpoint to update the in-process
// binding that all middleware and route files reference.
export function updateJwtSecret(newSecret: string): void {
JWT_SECRET = newSecret;
}
// DEFAULT_LANGUAGE / SESSION_DURATION* parsing moved into src/app-config
// (env.schema.ts validates fail-fast at boot; derive.ts resolves/falls back).
// Re-exported here — frozen at import, exactly like before — so the many
// importers of this module (mcp, middleware, services, e2e harness) are
// untouched. This module now owns only key material (above).
const sessionEnv = readEnv().session;
/** Default login-page language, validated against the supported set ('en' fallback). */
export const DEFAULT_LANGUAGE = readEnv().app.defaultLanguage;
/** Human-readable session length actually in effect (for logs/diagnostics). */
export const SESSION_DURATION = sessionEnv.duration;
/** Session length in milliseconds — used for the cookie `maxAge`. */
export const SESSION_DURATION_MS = sessionEnv.durationMs;
/** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */
export const SESSION_DURATION_SECONDS = sessionEnv.durationSeconds;
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
export const SESSION_DURATION_REMEMBER = sessionEnv.durationRemember;
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
export const SESSION_DURATION_REMEMBER_MS = sessionEnv.durationRememberMs;
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
export const SESSION_DURATION_REMEMBER_SECONDS = sessionEnv.durationRememberSeconds;