File size: 4,025 Bytes
3d23b2d | 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 | /**
* Key Rotation Script β decrypt with OLD key, re-encrypt with NEW key.
*
* Usage:
* OLD_ENCRYPTION_SECRET=<old_key> NEW_ENCRYPTION_SECRET=<new_key> \
* DATABASE_URL=<prod_url> npx ts-node -e "require('./rotate_encryption_key.ts')"
*
* Or set vars in .env and run:
* npx ts-node apps/api/scratch/rotate_encryption_key.ts
*
* The script is idempotent: if a field is already encrypted with NEW_KEY it is skipped.
*/
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const OLD_SECRET = process.env.OLD_ENCRYPTION_SECRET;
const NEW_SECRET = process.env.NEW_ENCRYPTION_SECRET || process.env.ENCRYPTION_SECRET;
if (!OLD_SECRET || OLD_SECRET.length < 32) {
console.error('β OLD_ENCRYPTION_SECRET must be set and at least 32 chars');
process.exit(1);
}
if (!NEW_SECRET || NEW_SECRET.length < 32) {
console.error('β NEW_ENCRYPTION_SECRET (or ENCRYPTION_SECRET) must be set and at least 32 chars');
process.exit(1);
}
import crypto from 'crypto';
const ALGORITHM = 'aes-256-cbc';
const IV_LENGTH = 16;
function encryptWith(text: string, secret: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(secret.slice(0, 32)), iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return `enc:${iv.toString('hex')}:${encrypted.toString('hex')}`;
}
function decryptWith(text: string, secret: string): string | null {
if (!text.startsWith('enc:')) return text; // plaintext β return as-is
const [, ivHex, encryptedHex] = text.split(':');
if (!ivHex || !encryptedHex) return null;
try {
const iv = Buffer.from(ivHex, 'hex');
const encryptedText = Buffer.from(encryptedHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(secret.slice(0, 32)), iv);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
} catch {
return null; // wrong key
}
}
const FIELDS = ['systemUserToken', 'webhookSecret', 'openAiApiKey', 'googleAiApiKey'] as const;
async function rotateKeys() {
const orgs = await prisma.organization.findMany();
let rotated = 0;
let skipped = 0;
let failed = 0;
for (const org of orgs) {
const update: Record<string, string> = {};
for (const field of FIELDS) {
const raw = (org as any)[field] as string | null;
if (!raw) continue;
if (!raw.startsWith('enc:')) {
// Plaintext β just re-encrypt with new key
update[field] = encryptWith(raw, NEW_SECRET!);
continue;
}
// Try decrypting with new key first (idempotence check)
const withNew = decryptWith(raw, NEW_SECRET!);
if (withNew !== null && !withNew.startsWith('enc:')) {
// Already decryptable with new key β nothing to do
skipped++;
continue;
}
// Try old key
const withOld = decryptWith(raw, OLD_SECRET!);
if (withOld === null) {
console.warn(` β οΈ [${org.name}] ${field}: cannot decrypt with either key β skipping`);
failed++;
continue;
}
update[field] = encryptWith(withOld, NEW_SECRET!);
}
if (Object.keys(update).length > 0) {
await prisma.organization.update({ where: { id: org.id }, data: update });
console.log(`β
[${org.name}] rotated: ${Object.keys(update).join(', ')}`);
rotated++;
}
}
console.log(`\nDone β ${rotated} org(s) rotated, ${skipped} field(s) already on new key, ${failed} field(s) undecryptable.`);
await prisma.$disconnect();
}
rotateKeys().catch((err) => { console.error(err); process.exit(1); });
|