From 7dc9dbaa4437f37b076c68eee0637e37f5d6dead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 27 May 2026 00:12:36 +0200 Subject: [PATCH] fix(crypto): cover the encrypted columns the rotation script was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/rotate-encryption-key.ts` enumerated columns manually and predated three of them: - `User.aiOpenaiKeyEncrypted` (`prisma/schema.prisma:86`, added in v1.4.3 alongside the other AI provider keys but never threaded into the rotation list). - `IntegrationStatus.lastError` (`prisma/schema.prisma:1441`) — the AES-256-GCM ciphertext of upstream error payloads from the Withings / moodLog sync paths. Skipped on rotation, every row stayed on whichever key wrote it. - `CoachMessage.encryptedContent` (`prisma/schema.prisma:1983`) — the only `Bytes` column in the encrypted set, so it needed a Buffer round-trip rather than the standard string path. Mirrors the `encryptToBytes` / `decryptFromBytes` pattern at `src/lib/ai/coach/persistence.ts:60-71`. Without these three, the documented rotation flow ends with the operator removing `v1` from `ENCRYPTION_KEYS` while rows still live on the legacy key — and the next read of an affected row throws, breaking AI Settings, the integration-status panel, or the Coach history page depending on which column was missed. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/rotate-encryption-key.ts | 64 ++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index af3fd3405..0976002c2 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -11,6 +11,7 @@ * pnpm tsx scripts/rotate-encryption-key.ts v2 */ import "dotenv/config"; +import { Buffer } from "node:buffer"; import { PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto"; @@ -92,6 +93,7 @@ async function main() { codexRefreshTokenEncrypted: true, aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, + aiOpenaiKeyEncrypted: true, moodLogWebhookSecret: true, telegramBotToken: true, moodLogUrlEncrypted: true, @@ -106,6 +108,7 @@ async function main() { "codexRefreshTokenEncrypted", "aiAnthropicKeyEncrypted", "aiLocalKeyEncrypted", + "aiOpenaiKeyEncrypted", "moodLogWebhookSecret", "telegramBotToken", "moodLogUrlEncrypted", @@ -224,6 +227,67 @@ async function main() { ); } + // ───── IntegrationStatus.lastError ───── + // Per `prisma/schema.prisma:1441` — AES-256-GCM ciphertext of an + // upstream error payload. Skipped historically; drop the legacy + // key while a row still lives here and the admin status view 500s. + const statuses = await prisma.integrationStatus.findMany({ + select: { id: true, lastError: true }, + }); + results.push( + await rotateField( + "IntegrationStatus", + "lastError", + statuses, + (s) => s.lastError, + async (id, ciphertext) => { + await prisma.integrationStatus.update({ + where: { id }, + data: { lastError: ciphertext }, + }); + }, + ), + ); + + // ───── CoachMessage.encryptedContent ───── + // Per `prisma/schema.prisma:1983` — `Bytes` column carrying the + // UTF-8 ciphertext of every persisted Coach message. The encrypt / + // decrypt helpers operate on strings, so we go through a Buffer + // round-trip identical to `src/lib/ai/coach/persistence.ts:60-71`. + const coachMessages = await prisma.coachMessage.findMany({ + select: { id: true, encryptedContent: true }, + }); + const coachResult: RotationResult = { + table: "CoachMessage", + field: "encryptedContent", + scanned: coachMessages.length, + rotated: 0, + errors: 0, + }; + for (const row of coachMessages) { + const buf = row.encryptedContent; + if (!buf || buf.byteLength === 0) continue; + const asString = Buffer.from(buf).toString("utf8"); + if (!shouldRotate(asString)) continue; + try { + const rotated = encrypt(decrypt(asString)); + const encoded = Buffer.from(rotated, "utf8"); + const next = new Uint8Array(new ArrayBuffer(encoded.byteLength)); + next.set(encoded); + await prisma.coachMessage.update({ + where: { id: row.id }, + data: { encryptedContent: next }, + }); + coachResult.rotated++; + } catch (err) { + coachResult.errors++; + console.error( + `[CoachMessage.encryptedContent] row ${row.id}: ${(err as Error).message}`, + ); + } + } + results.push(coachResult); + console.log("\n=== Rotation summary ==="); let totalRotated = 0; let totalErrors = 0;