feat: add encryption key migration script and document it in README
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 <noreply@anthropic.com>
This commit is contained in:
25
README.md
25
README.md
@@ -98,7 +98,9 @@
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```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.
|
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:
|
|||||||
<details>
|
<details>
|
||||||
<summary>Docker Compose (recommended for production)</summary>
|
<summary>Docker Compose (recommended for production)</summary>
|
||||||
|
|
||||||
|
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
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
@@ -136,7 +145,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- 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
|
- 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)
|
- 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
|
- 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.
|
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)
|
### Reverse Proxy (recommended)
|
||||||
|
|
||||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||||
|
|||||||
298
server/scripts/migrate-encryption.ts
Normal file
298
server/scripts/migrate-encryption.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string, string>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user