mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
a4b53b1852
commit
7dc9dbaa44
1 file changed
+64
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user