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();
}
|