Spaces:
Sleeping
Sleeping
| /** | |
| * 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, admin_webhook_url, admin_ntfy_token --- | |
| for (const key of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) { | |
| 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: api key columns + synology credentials --- | |
| const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did']; | |
| 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); | |
| } | |
| } | |
| } | |
| // --- settings: per-user encrypted keys --- | |
| const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token']; | |
| const settingRows = db.prepare( | |
| `SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})` | |
| ).all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[]; | |
| for (const row of settingRows) { | |
| if (!row.value) continue; | |
| const newVal = migrateApiKeyValue(row.value, `settings[user=${row.user_id}].${row.key}`); | |
| if (newVal !== null) { | |
| db.prepare('UPDATE settings SET value = ? WHERE user_id = ? AND key = ?').run(newVal, row.user_id, row.key); | |
| } | |
| } | |
| // --- trip_album_links: passphrase --- | |
| const albumLinks = db.prepare('SELECT id, passphrase FROM trip_album_links WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[]; | |
| for (const row of albumLinks) { | |
| const newVal = migrateApiKeyValue(row.passphrase, `trip_album_links[${row.id}].passphrase`); | |
| if (newVal !== null) { | |
| db.prepare('UPDATE trip_album_links SET passphrase = ? WHERE id = ?').run(newVal, row.id); | |
| } | |
| } | |
| // --- trek_photos: passphrase --- | |
| const photos = db.prepare('SELECT id, passphrase FROM trek_photos WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[]; | |
| for (const row of photos) { | |
| const newVal = migrateApiKeyValue(row.passphrase, `trek_photos[${row.id}].passphrase`); | |
| if (newVal !== null) { | |
| db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ?').run(newVal, row.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); | |
| }); | |