From 7a71d63f7f73b0ec94abd976490053e81a4ebcb4 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 09:13:16 +0100 Subject: [PATCH] chore: add TOTP encryption key rotation script Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/rotate-totp-key.ts | 71 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 scripts/rotate-totp-key.ts diff --git a/scripts/rotate-totp-key.ts b/scripts/rotate-totp-key.ts new file mode 100644 index 0000000..fb2053d --- /dev/null +++ b/scripts/rotate-totp-key.ts @@ -0,0 +1,71 @@ +import crypto from 'crypto'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +function decrypt(ciphertext: string, keyHex: string): string { + const key = Buffer.from(keyHex, 'hex'); + const [ivHex, encHex, tagHex] = ciphertext.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const encrypted = Buffer.from(encHex, 'hex'); + const tag = Buffer.from(tagHex, 'hex'); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8'); +} + +function encrypt(plaintext: string, keyHex: string): string { + const key = Buffer.from(keyHex, 'hex'); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`; +} + +async function main() { + const oldKey = process.argv[2]; + const newKey = process.argv[3]; + const dryRun = process.argv.includes('--dry-run'); + + if (!oldKey || !newKey) { + console.error('Usage: tsx scripts/rotate-totp-key.ts [--dry-run]'); + process.exit(1); + } + + const users = await prisma.users.findMany({ + where: { totp_enabled: true, totp_secret: { not: null } }, + select: { id: true, username: true, totp_secret: true }, + }); + + console.log(`Found ${users.length} users with TOTP enabled`); + + if (dryRun) { + for (const user of users) { + try { + const decrypted = decrypt(user.totp_secret!, oldKey); + const reEncrypted = encrypt(decrypted, newKey); + decrypt(reEncrypted, newKey); // verify round-trip + console.log(` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`); + } catch (e) { + console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`); + } + } + console.log('\nDry run complete. No changes made.'); + return; + } + + await prisma.$transaction(async (tx) => { + for (const user of users) { + const decrypted = decrypt(user.totp_secret!, oldKey); + const reEncrypted = encrypt(decrypted, newKey); + await tx.users.update({ where: { id: user.id }, data: { totp_secret: reEncrypted } }); + console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`); + } + }); + + console.log('\nAll TOTP secrets re-encrypted successfully.'); + await prisma.$disconnect(); +} + +main().catch((e) => { console.error(e); process.exit(1); });