/** * Key Rotation Script — decrypt with OLD key, re-encrypt with NEW key. * * Usage: * OLD_ENCRYPTION_SECRET= NEW_ENCRYPTION_SECRET= \ * DATABASE_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 = {}; 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); });