From 4d596f2ff995a5a3f914ff2bac02fb75ddad936e Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 09:35:32 +0200 Subject: [PATCH] feat: add encryption key migration script and document it in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add server/scripts/migrate-encryption.ts — a standalone script that re-encrypts all at-rest secrets (OIDC client secret, SMTP password, Maps/OpenWeather/Immich API keys, MFA secrets) when rotating ENCRYPTION_KEY, without requiring the app to be running. - Prompts for old and new keys interactively; input is never echoed, handles copy-pasted keys correctly via a shared readline interface with a line queue to prevent race conditions on piped/pasted input - Creates a timestamped DB backup before any changes - Idempotent: detects already-migrated values by trying the new key - Exits non-zero and retains the backup if any field fails README updates: - Add .env setup step (openssl rand -hex 32) before the Docker Compose snippet so ENCRYPTION_KEY is set before first start - Add ENCRYPTION_KEY to the docker run one-liner - Add "Rotating the Encryption Key" section documenting the script, the docker exec command, and the upgrade path via ./data/.jwt_secret Co-Authored-By: Claude Sonnet 4.6 --- README.md | 25 ++- server/scripts/migrate-encryption.ts | 298 +++++++++++++++++++++++++++ 2 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 server/scripts/migrate-encryption.ts diff --git a/README.md b/README.md index 566a503..ebf7c6c 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ ## Quick Start ```bash -docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek +ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \ + -e ENCRYPTION_KEY=$ENCRYPTION_KEY \ + -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek ``` The app runs on port `3000`. The first user to register becomes the admin. @@ -115,6 +117,13 @@ TREK works as a Progressive Web App — no App Store needed:
Docker Compose (recommended for production) +First, create a `.env` file next to your `docker-compose.yml`: + +```bash +# Generate a random encryption key (required) +echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env +``` + ```yaml services: app: @@ -136,7 +145,7 @@ services: environment: - NODE_ENV=production - PORT=3000 - - ENCRYPTION_KEY=${ENCRYPTION_KEY} # Required. Generate with: openssl rand -hex 32. Upgrading? Set this to the contents of ./data/.jwt_secret to keep existing encrypted secrets readable. + - ENCRYPTION_KEY=${ENCRYPTION_KEY} # Required — see .env setup above - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details @@ -179,6 +188,18 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data. +### Rotating the Encryption Key + +If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app: + +```bash +docker exec -it trek node --import tsx scripts/migrate-encryption.ts +``` + +The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails. + +**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key". + ### Reverse Proxy (recommended) For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik). diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts new file mode 100644 index 0000000..f5d788f --- /dev/null +++ b/server/scripts/migrate-encryption.ts @@ -0,0 +1,298 @@ +/** + * Encryption key migration script. + * + * Re-encrypts all at-rest secrets in the TREK database from one ENCRYPTION_KEY + * to another without requiring the application to be running. + * + * Usage (host): + * cd server + * node --import tsx scripts/migrate-encryption.ts + * + * Usage (Docker): + * docker exec -it trek node --import tsx scripts/migrate-encryption.ts + * + * The script will prompt for the old and new keys interactively so they never + * appear in shell history, process arguments, or log output. + */ + +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import readline from 'readline'; +import Database from 'better-sqlite3'; + +// --------------------------------------------------------------------------- +// Crypto helpers — mirrors apiKeyCrypto.ts and mfaCrypto.ts but with +// explicit key arguments so the script is independent of config.ts / env vars. +// --------------------------------------------------------------------------- + +const ENCRYPTED_PREFIX = 'enc:v1:'; + +function apiKey(encryptionKey: string): Buffer { + return crypto.createHash('sha256').update(`${encryptionKey}:api_keys:v1`).digest(); +} + +function mfaKey(encryptionKey: string): Buffer { + return crypto.createHash('sha256').update(`${encryptionKey}:mfa:v1`).digest(); +} + +function encryptApiKey(plain: string, encryptionKey: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', apiKey(encryptionKey), iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${ENCRYPTED_PREFIX}${Buffer.concat([iv, tag, enc]).toString('base64')}`; +} + +function decryptApiKey(value: string, encryptionKey: string): string | null { + if (!value.startsWith(ENCRYPTED_PREFIX)) return null; + try { + const buf = Buffer.from(value.slice(ENCRYPTED_PREFIX.length), 'base64'); + const decipher = crypto.createDecipheriv('aes-256-gcm', apiKey(encryptionKey), buf.subarray(0, 12)); + decipher.setAuthTag(buf.subarray(12, 28)); + return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString('utf8'); + } catch { + return null; + } +} + +function encryptMfa(plain: string, encryptionKey: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', mfaKey(encryptionKey), iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString('base64'); +} + +function decryptMfa(value: string, encryptionKey: string): string | null { + try { + const buf = Buffer.from(value, 'base64'); + if (buf.length < 28) return null; + const decipher = crypto.createDecipheriv('aes-256-gcm', mfaKey(encryptionKey), buf.subarray(0, 12)); + decipher.setAuthTag(buf.subarray(12, 28)); + return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString('utf8'); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Prompt helpers +// --------------------------------------------------------------------------- +// A single readline interface is shared for the entire script lifetime so +// stdin is never paused between prompts. +// +// Lines are collected into a queue as soon as readline emits them — this +// prevents the race where a line event fires before the next listener is +// registered (common with piped / pasted input that arrives all at once). + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + +const lineQueue: string[] = []; +const lineWaiters: ((line: string) => void)[] = []; + +rl.on('line', (line) => { + if (lineWaiters.length > 0) { + lineWaiters.shift()!(line); + } else { + lineQueue.push(line); + } +}); + +function nextLine(): Promise { + return new Promise((resolve) => { + if (lineQueue.length > 0) { + resolve(lineQueue.shift()!); + } else { + lineWaiters.push(resolve); + } + }); +} + +// Muted prompt — typed/pasted characters are not echoed. +// _writeToOutput is suppressed only while waiting for this line. +async function promptSecret(question: string): Promise { + process.stdout.write(question); + (rl as any)._writeToOutput = () => {}; + const line = await nextLine(); + (rl as any)._writeToOutput = (s: string) => process.stdout.write(s); + process.stdout.write('\n'); + return line.trim(); +} + +async function prompt(question: string): Promise { + process.stdout.write(question); + const line = await nextLine(); + return line.trim(); +} + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +interface MigrationResult { + migrated: number; + alreadyMigrated: number; + skipped: number; + errors: string[]; +} + +async function main() { + console.log('=== TREK Encryption Key Migration ===\n'); + console.log('This script re-encrypts all stored secrets under a new ENCRYPTION_KEY.'); + console.log('A backup of the database will be created before any changes are made.\n'); + + // Resolve DB path + const dbPath = path.resolve( + process.env.DB_PATH ?? path.join(__dirname, '../data/travel.db') + ); + + if (!fs.existsSync(dbPath)) { + console.error(`Database not found at: ${dbPath}`); + console.error('Set DB_PATH env var if your database is in a non-standard location.'); + process.exit(1); + } + + console.log(`Database: ${dbPath}\n`); + + // Collect keys interactively + const oldKey = await promptSecret('Old ENCRYPTION_KEY: '); + const newKey = await promptSecret('New ENCRYPTION_KEY: '); + + if (!oldKey || !newKey) { + rl.close(); + console.error('Both keys are required.'); + process.exit(1); + } + + if (oldKey === newKey) { + rl.close(); + console.error('Old and new keys are identical — nothing to do.'); + process.exit(0); + } + + // Confirm + const confirm = await prompt('\nProceed with migration? This will modify the database. Type "yes" to confirm: '); + if (confirm.trim().toLowerCase() !== 'yes') { + rl.close(); + console.log('Aborted.'); + process.exit(0); + } + + // Backup + const backupPath = `${dbPath}.backup-${Date.now()}`; + fs.copyFileSync(dbPath, backupPath); + console.log(`\nBackup created: ${backupPath}`); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + const result: MigrationResult = { migrated: 0, alreadyMigrated: 0, skipped: 0, errors: [] }; + + // Helper: migrate a single api-key-style value (enc:v1: prefix) + function migrateApiKeyValue(raw: string, label: string): string | null { + if (!raw || !raw.startsWith(ENCRYPTED_PREFIX)) { + result.skipped++; + console.warn(` SKIP ${label}: not an encrypted value (missing enc:v1: prefix)`); + return null; + } + + const plain = decryptApiKey(raw, oldKey); + if (plain !== null) { + result.migrated++; + return encryptApiKey(plain, newKey); + } + + // Try new key — already migrated? + const check = decryptApiKey(raw, newKey); + if (check !== null) { + result.alreadyMigrated++; + return null; // no change needed + } + + result.errors.push(`${label}: decryption failed with both keys`); + console.error(` ERROR ${label}: could not decrypt with either key — skipping`); + return null; + } + + // Helper: migrate a single MFA value (no prefix, raw base64) + function migrateMfaValue(raw: string, label: string): string | null { + if (!raw) { result.skipped++; return null; } + + const plain = decryptMfa(raw, oldKey); + if (plain !== null) { + result.migrated++; + return encryptMfa(plain, newKey); + } + + const check = decryptMfa(raw, newKey); + if (check !== null) { + result.alreadyMigrated++; + return null; + } + + result.errors.push(`${label}: decryption failed with both keys`); + console.error(` ERROR ${label}: could not decrypt with either key — skipping`); + return null; + } + + db.transaction(() => { + // --- app_settings: oidc_client_secret, smtp_pass --- + for (const key of ['oidc_client_secret', 'smtp_pass']) { + const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined; + if (!row?.value) continue; + const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`); + if (newVal !== null) { + db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(newVal, key); + } + } + + // --- users: maps_api_key, openweather_api_key, immich_api_key --- + const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key']; + const users = db.prepare('SELECT id FROM users').all() as { id: number }[]; + + for (const user of users) { + const row = db.prepare(`SELECT ${apiKeyColumns.join(', ')} FROM users WHERE id = ?`).get(user.id) as Record; + + for (const col of apiKeyColumns) { + if (!row[col]) continue; + const newVal = migrateApiKeyValue(row[col], `users[${user.id}].${col}`); + if (newVal !== null) { + db.prepare(`UPDATE users SET ${col} = ? WHERE id = ?`).run(newVal, user.id); + } + } + + // mfa_secret (mfa crypto) + const mfaRow = db.prepare('SELECT mfa_secret FROM users WHERE id = ? AND mfa_secret IS NOT NULL').get(user.id) as { mfa_secret: string } | undefined; + if (mfaRow?.mfa_secret) { + const newVal = migrateMfaValue(mfaRow.mfa_secret, `users[${user.id}].mfa_secret`); + if (newVal !== null) { + db.prepare('UPDATE users SET mfa_secret = ? WHERE id = ?').run(newVal, user.id); + } + } + } + })(); + + db.close(); + rl.close(); + + console.log('\n=== Migration complete ==='); + console.log(` Migrated: ${result.migrated}`); + console.log(` Already on new key: ${result.alreadyMigrated}`); + console.log(` Skipped (empty): ${result.skipped}`); + if (result.errors.length > 0) { + console.warn(` Errors: ${result.errors.length}`); + result.errors.forEach(e => console.warn(` - ${e}`)); + console.warn('\nSome secrets could not be migrated. Check the errors above.'); + console.warn(`Your original database is backed up at: ${backupPath}`); + process.exit(1); + } else { + console.log('\nAll secrets successfully re-encrypted.'); + console.log(`Backup retained at: ${backupPath}`); + } +} + +main().catch((err) => { + console.error('Unexpected error:', err); + process.exit(1); +});