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