fix: persist encryption key to disk regardless of resolution source

Previously, when the JWT secret was used as a fallback encryption key,
nothing was written to data/.encryption_key. This meant that rotating
the JWT secret via the admin panel would silently break decryption of
all stored secrets on the next restart.

Now, whatever key is resolved — env var, JWT secret fallback, or
auto-generated — is immediately persisted to data/.encryption_key.
On all subsequent starts, the file is read directly and the fallback
chain is skipped entirely, making JWT rotation permanently safe.

The env var path also writes to the file so the key survives container
restarts if the env var is later removed.
This commit is contained in:
jubnl
2026-04-01 10:03:11 +02:00
parent c9e61859ce
commit 44e5f07f59
+43 -24
View File
@@ -41,41 +41,60 @@ export function updateJwtSecret(newSecret: string): void {
// //
// Resolution order: // Resolution order:
// 1. ENCRYPTION_KEY env var — explicit, always takes priority. // 1. ENCRYPTION_KEY env var — explicit, always takes priority.
// 2. data/.jwt_secret — used automatically for existing installs that upgrade // 2. data/.encryption_key file — present on any install that has started at
// without setting ENCRYPTION_KEY; encrypted data stays readable with no // least once (written automatically by cases 1b and 3 below).
// manual intervention required. // 3. data/.jwt_secret — one-time fallback for existing installs upgrading
// 3. data/.encryption_key — auto-generated and persisted on first start of a // without a pre-set ENCRYPTION_KEY. The value is immediately persisted to
// fresh install where neither of the above is available. // 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 || ''; let _encryptionKey: string = process.env.ENCRYPTION_KEY || '';
if (!_encryptionKey) { if (_encryptionKey) {
// Fallback 1: existing install — reuse the JWT secret so previously encrypted // Env var is set explicitly — persist it to file so the value survives
// values remain readable after an upgrade. // container restarts even if the env var is later removed.
try { try {
_encryptionKey = fs.readFileSync(jwtSecretFile, 'utf8').trim(); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
console.warn('WARNING: ENCRYPTION_KEY is not set. Falling back to JWT secret for at-rest encryption.'); fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
console.warn('Set ENCRYPTION_KEY explicitly to decouple encryption from JWT signing (recommended).');
} catch { } catch {
// JWT secret not found — must be a fresh install, fall through. // Non-fatal: env var is the source of truth when set.
} }
} } else {
// Try the dedicated key file first (covers all installs after first start).
if (!_encryptionKey) {
// Fallback 2: fresh install — auto-generate a dedicated key.
const encKeyFile = path.join(dataDir, '.encryption_key');
try { try {
_encryptionKey = fs.readFileSync(encKeyFile, 'utf8').trim(); _encryptionKey = fs.readFileSync(encKeyFile, 'utf8').trim();
} catch { } catch {
_encryptionKey = crypto.randomBytes(32).toString('hex'); // File not found — first start on an existing or fresh install.
}
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 { try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); _encryptionKey = fs.readFileSync(jwtSecretFile, 'utf8').trim();
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 }); console.warn('WARNING: ENCRYPTION_KEY is not set. Falling back to JWT secret for at-rest encryption.');
console.log('Generated and saved encryption key to', encKeyFile); console.warn('The value has been persisted to data/.encryption_key — JWT rotation is now safe.');
} catch (writeErr: unknown) { } catch {
console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr); // JWT secret not found — must be a fresh install.
console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
} }
} }
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; export const ENCRYPTION_KEY = _encryptionKey;