File size: 13,079 Bytes
dcedb25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env node
/**
 * rotate-encryption-key β€” re-encrypt every stored secret under a new
 * ENCRYPTION_KEY without losing a single value.
 *
 * Why this exists: AES-256-GCM is authenticated, so a DB carried over to a
 * different ENCRYPTION_KEY fails loudly on the first decrypt β€” every stored
 * API key, proxy override, client-profile credential and the Fetch Relay
 * token becomes unreadable at once, and there is no recovery path short of
 * re-entering secrets by hand.
 * Rotating keys (compromise, policy expiry, onboarding) therefore has to walk
 * the ciphertext, decrypt each value with the OLD key and re-encrypt it with
 * the NEW one, atomically, before the new key ever becomes active.
 *
 * The cipher parameters deliberately mirror server/src/lib/crypto.ts
 * (aes-256-gcm, 32-byte key hex, 16-byte IV, 16-byte GCM tag) so a value
 * written here decrypts under the running server exactly like one written by
 * `encrypt()`. The script does NOT touch the module-level key cache β€” it
 * performs every decrypt with `--old-key` and every encrypt with `--new-key`
 * explicitly, so it is safe to run while the server is stopped (recommended)
 * or even live (a writer that fires mid-run sees only already-migrated rows
 * because both keys are valid until the process restarts with the new key).
 *
 * Usage:
 *   tsx src/scripts/rotate-encryption-key.ts --old-key <64hex> --new-key <64hex> [--db <path>] [--dry-run]
 *
 *   --old-key  the current key (hex, 64 chars). Falls back to ENCRYPTION_KEY.
 *   --new-key  the key to rotate to (hex, 64 chars). Required.
 *   --db       SQLite path (default: the server's data/freeapi.db).
 *   --dry-run  decrypt + print every affected row WITHOUT writing anything.
 *
 * Exits 0 on success (or a clean dry run), 1 on any decrypt/validation error.
 * Nothing is written unless EVERY row decrypts β€” a partial rotate would leave
 * the DB in a mixed-key state that is exactly the failure this script exists
 * to prevent.
 */
import Database from 'better-sqlite3';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const ALGORITHM = 'aes-256-gcm';
const KEY_HEX_LEN = 64;
const AUTH_TAG_BYTES = 16;

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_DB = path.resolve(__dirname, '../../data/freeapi.db');

function arg(name: string): string | undefined {
  const i = process.argv.indexOf(`--${name}`);
  return i !== -1 ? process.argv[i + 1] : undefined;
}
function flag(name: string): boolean {
  return process.argv.includes(`--${name}`);
}

function parseHexKey(value: string, source: string): Buffer {
  if (value.length !== KEY_HEX_LEN || !/^[0-9a-fA-F]+$/.test(value)) {
    throw new Error(
      `Invalid key (${source}): expected ${KEY_HEX_LEN} hex chars (32 bytes), got ${value.length} chars. ` +
      `Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`,
    );
  }
  return Buffer.from(value, 'hex');
}

export function encryptWith(key: Buffer, text: string): { encrypted: string; iv: string; authTag: string } {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  return { encrypted, iv: iv.toString('hex'), authTag: cipher.getAuthTag().toString('hex') };
}

export function decryptWith(key: Buffer, encrypted: string, iv: string, authTag: string): string {
  const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex'), { authTagLength: AUTH_TAG_BYTES });
  decipher.setAuthTag(Buffer.from(authTag, 'hex'));
  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

/**
 * Ciphertext stored in dedicated columns: the IV and tag live next to their
 * ciphertext in sibling columns, all TEXT.
 */
interface ColumnGroup {
  kind: 'columns';
  table: string;
  idColumn: string;
  encrypted: string;
  iv: string;
  authTag: string;
  label: string;
}

/**
 * Ciphertext stored as a JSON blob in a single `settings` value, in the shape
 * `JSON.stringify(encrypt(plaintext))` β€” i.e. `{"encrypted","iv","authTag"}`.
 * The Fetch Relay bearer token is written this way by
 * `encodeFetchRelayToken()` (server/src/lib/proxy.ts) and read back by
 * `decodeFetchRelayToken()`, which degrades to '' with a warning when the
 * value will not decrypt. Miss it here and a rotate silently drops the relay
 * credential on the next restart.
 */
interface SettingGroup {
  kind: 'setting';
  table: 'settings';
  settingKey: string;
  label: string;
}

type EncryptionGroup = ColumnGroup | SettingGroup;

// Every place a secret is persisted. Keep this in step with the `encrypt()`
// call sites in server/src/lib/crypto.ts consumers: api_keys (keys.ts,
// custom-endpoint.ts, declarative-config.ts), api_keys proxy columns
// (lib/key-proxy.ts), client_profiles (routes/client-profiles.ts) and the
// settings blob (lib/proxy.ts).
const ENCRYPTION_GROUPS: EncryptionGroup[] = [
  { kind: 'columns', table: 'api_keys', idColumn: 'id', encrypted: 'encrypted_key', iv: 'iv', authTag: 'auth_tag', label: 'api key' },
  { kind: 'columns', table: 'api_keys', idColumn: 'id', encrypted: 'proxy_encrypted', iv: 'proxy_iv', authTag: 'proxy_auth_tag', label: 'per-key proxy override' },
  { kind: 'columns', table: 'client_profiles', idColumn: 'id', encrypted: 'encrypted_key', iv: 'iv', authTag: 'auth_tag', label: 'client-profile credential' },
  { kind: 'setting', table: 'settings', settingKey: 'fetch_relay_token', label: 'fetch relay token' },
];

interface RowToRotate {
  group: EncryptionGroup;
  /** Row id for column groups, the settings key for settings blobs. */
  id: number | string;
  plaintext: string;
  reEncrypted: { encrypted: string; iv: string; authTag: string };
}

/** Result of a rotate attempt: rows re-encrypted, or an error that aborted it. */
export interface RotateResult {
  rows: RowToRotate[];
  error?: string;
}

/** The read surface rotateSecrets needs β€” better-sqlite3 and the app's Db both satisfy it. */
export interface RotateReadDb {
  prepare(sql: string): { all(...args: unknown[]): unknown[]; get(...args: unknown[]): unknown };
}

/** The write surface applyRotation needs, including better-sqlite3's transaction(). */
export interface RotateWriteDb {
  prepare(sql: string): { run(...args: unknown[]): unknown };
  // Deliberately phrased as "a callable taking the same arguments" rather than
  // "returns F": better-sqlite3 hands back a Transaction<F> (F plus .deferred
  // and friends) while the app's Db type returns F itself, and both satisfy
  // this shape.
  transaction<A extends unknown[]>(fn: (...args: A) => void): (...args: A) => unknown;
}

/**
 * Core rotate routine, exported so tests can drive it against an in-memory
 * DB. Decrypts every stored secret with `oldKey` and re-encrypts with
 * `newKey`. Returns `{ rows }` on success and `{ rows: [], error }` when ANY
 * value fails to decrypt β€” the caller must treat the error case as "nothing
 * written".
 */
export function rotateSecrets(
  db: RotateReadDb,
  oldKey: Buffer,
  newKey: Buffer,
): RotateResult {
  const rowsToRotate: RowToRotate[] = [];

  for (const group of ENCRYPTION_GROUPS) {
    const cols = db.prepare(`PRAGMA table_info(${group.table})`).all() as Array<{ name: string }>;
    const names = new Set(cols.map((c) => c.name));

    // Ciphertext held as (encrypted, iv, auth_tag) columns.
    if (group.kind === 'columns') {
      if (!names.has(group.encrypted) || !names.has(group.iv) || !names.has(group.authTag)) {
        continue;
      }

      const rows = db.prepare(
        `SELECT ${group.idColumn} AS id, ${group.encrypted} AS enc, ${group.iv} AS iv, ${group.authTag} AS tag ` +
        `FROM ${group.table} WHERE ${group.encrypted} IS NOT NULL`,
      ).all() as Array<{ id: number; enc: string; iv: string; tag: string }>;

      for (const row of rows) {
        let plaintext: string;
        try {
          plaintext = decryptWith(oldKey, row.enc, row.iv, row.tag);
        } catch (err) {
          return {
            rows: [],
            error: `cannot decrypt ${group.label} #${row.id} with --old-key β€” wrong key or corrupted ciphertext (${(err as Error).message}). Aborting; nothing was written.`,
          };
        }
        rowsToRotate.push({ group, id: row.id, plaintext, reEncrypted: encryptWith(newKey, plaintext) });
      }
      continue;
    }

    // Ciphertext held as a JSON blob in settings.value.
    if (!names.has('key') || !names.has('value')) continue;

    const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(group.settingKey) as { value: string } | undefined;
    // An unset or cleared setting stores '' rather than a blob β€” nothing to do.
    if (!row || !row.value) continue;

    let parsed: { encrypted?: string; iv?: string; authTag?: string };
    try {
      parsed = JSON.parse(row.value) as { encrypted?: string; iv?: string; authTag?: string };
    } catch {
      return {
        rows: [],
        error: `${group.label} (settings.${group.settingKey}) is not valid JSON β€” refusing to guess. Aborting; nothing was written.`,
      };
    }
    if (!parsed.encrypted || !parsed.iv || !parsed.authTag) {
      return {
        rows: [],
        error: `${group.label} (settings.${group.settingKey}) is missing encrypted/iv/authTag. Aborting; nothing was written.`,
      };
    }

    let plaintext: string;
    try {
      plaintext = decryptWith(oldKey, parsed.encrypted, parsed.iv, parsed.authTag);
    } catch (err) {
      return {
        rows: [],
        error: `cannot decrypt ${group.label} (settings.${group.settingKey}) with --old-key β€” wrong key or corrupted ciphertext (${(err as Error).message}). Aborting; nothing was written.`,
      };
    }
    rowsToRotate.push({ group, id: group.settingKey, plaintext, reEncrypted: encryptWith(newKey, plaintext) });
  }

  return { rows: rowsToRotate };
}

/**
 * Apply a rotate result: write every re-encrypted value back to its row, in
 * one transaction. Exported so main() and tests share the exact write path.
 */
export function applyRotation(
  db: RotateWriteDb,
  rows: RowToRotate[],
): void {
  const write = db.transaction((items: RowToRotate[]) => {
    for (const r of items) {
      if (r.group.kind === 'setting') {
        // Same encoding encodeFetchRelayToken() writes, so the running server
        // reads it back unchanged.
        db.prepare('UPDATE settings SET value = ? WHERE key = ?')
          .run(JSON.stringify(r.reEncrypted), r.group.settingKey);
        continue;
      }
      db.prepare(
        `UPDATE ${r.group.table} SET ${r.group.encrypted} = ?, ${r.group.iv} = ?, ${r.group.authTag} = ? WHERE ${r.group.idColumn} = ?`,
      ).run(r.reEncrypted.encrypted, r.reEncrypted.iv, r.reEncrypted.authTag, r.id);
    }
  });
  write(rows);
}

function main(): void {
  const oldKeyHex = arg('old-key') ?? process.env.ENCRYPTION_KEY;
  const newKeyHex = arg('new-key');
  const dbPath = arg('db') ?? DEFAULT_DB;
  const dryRun = flag('dry-run');

  if (!oldKeyHex) {
    console.error('error: --old-key is required (or set ENCRYPTION_KEY)');
    process.exit(1);
  }
  if (!newKeyHex) {
    console.error('error: --new-key is required');
    process.exit(1);
  }
  if (!fs.existsSync(dbPath)) {
    console.error(`error: database not found at ${dbPath}`);
    process.exit(1);
  }

  const oldKey = parseHexKey(oldKeyHex, 'old');
  const newKey = parseHexKey(newKeyHex, 'new');
  if (oldKey.equals(newKey)) {
    console.error('error: --old-key and --new-key are identical β€” nothing to rotate');
    process.exit(1);
  }

  const db = new Database(dbPath, { readonly: dryRun });
  try {
    const result = rotateSecrets(db, oldKey, newKey);
    if (result.error) {
      console.error(`error: ${result.error}`);
      process.exit(1);
    }

    const rowsToRotate = result.rows;
    if (rowsToRotate.length === 0) {
      console.log('No encrypted values found β€” nothing to rotate.');
      return;
    }

    if (dryRun) {
      console.log(`[dry-run] would rotate ${rowsToRotate.length} value(s):`);
      for (const r of rowsToRotate) {
        const where = r.group.kind === 'setting' ? `settings.${r.group.settingKey}` : `#${r.id}`;
        console.log(`  ${r.group.label} ${where} -> ${r.plaintext.slice(0, 3)}*** (${r.plaintext.length} chars)`);
      }
      return;
    }

    applyRotation(db, rowsToRotate);

    console.log(`Rotated ${rowsToRotate.length} value(s) to the new key.`);
    console.log('Next steps: set ENCRYPTION_KEY to the new key (or overwrite the .encryption-key file next to the DB) and restart the server.');
  } finally {
    db.close();
  }
}

// Only run when invoked directly (`tsx src/scripts/rotate-encryption-key.ts`),
// not when the module is imported by tests for `rotateSecrets`/`encryptWith`.
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  main();
}