fix(crypto): cover the encrypted columns the rotation script was missing

`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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-27 00:12:36 +02:00
1 parent a4b53b1852
commit 7dc9dbaa44
1 file changed
+64
+64
View File
@@ -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;