File size: 12,222 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
/**
 * 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);
});