mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(crypto): wire every encrypted column into key rotation behind a registry guard
The rotation script omitted the four v1.18.1 clinical-spine note columns (LabResult.noteEncrypted, Biomarker.contextEncrypted, IllnessEpisode.noteEncrypted, IllnessDayLog.noteEncrypted) plus a longer pre-existing tail (Nightscout/Polar/Oura credentials, the KVNR field, the health-profile and cycle-day-log payloads, InsightNarrative content). Rotation reported zero legacy rows while these still held old-key ciphertext, so dropping the legacy key left them permanently undecryptable (decrypt is fail-closed). Introduce src/lib/crypto/encrypted-columns.ts as the canonical registry of every reversibly-encrypted column, drive the rotation script from it, and add a guard test that cross-checks the registry against every encrypted column in schema.prisma and asserts the script references each one. A new *Encrypted column not wired into rotation now fails CI by construction.
This commit is contained in:
1 parent
b28fa4d4a5
commit
e23f57448a
3 files changed
+562
-350
No files matched your search
+264
-350
@@ -5,6 +5,15 @@
|
||||
* active key (`ENCRYPTION_ACTIVE_KEY_ID`). Idempotent: rows whose ciphertext
|
||||
* already carries the active key id prefix are skipped.
|
||||
*
|
||||
* The set of columns this script rotates is the canonical registry in
|
||||
* `src/lib/crypto/encrypted-columns.ts`. The guard test
|
||||
* `src/lib/crypto/__tests__/encrypted-columns.test.ts` fails CI if any
|
||||
* encrypted column in `prisma/schema.prisma` is missing from the registry,
|
||||
* or if any registry column is not referenced here — so a new `*Encrypted`
|
||||
* column can never silently skip rotation (which would make those rows
|
||||
* permanently undecryptable once the legacy key is dropped, since decrypt is
|
||||
* fail-closed).
|
||||
*
|
||||
* Usage:
|
||||
* ENCRYPTION_KEYS='{"v1":"<old>","v2":"<new>"}' \
|
||||
* ENCRYPTION_ACTIVE_KEY_ID=v2 \
|
||||
@@ -50,13 +59,25 @@ function shouldRotate(value: string | null): boolean {
|
||||
return id !== getActiveKeyId();
|
||||
}
|
||||
|
||||
async function rotateField<T extends { id: string }>(
|
||||
/**
|
||||
* Rotate one `String` ciphertext column on a Prisma model. `delegate` is the
|
||||
* `prisma.<model>` accessor; `field` is the column. Reads only `id` + the
|
||||
* column, re-encrypts every row not already on the active key.
|
||||
*/
|
||||
async function rotateStringColumn(
|
||||
table: string,
|
||||
field: string,
|
||||
rows: T[],
|
||||
fieldGetter: (row: T) => string | null,
|
||||
updater: (id: string, ciphertext: string) => Promise<void>,
|
||||
delegate: {
|
||||
findMany: (args: {
|
||||
select: Record<string, true>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
data: Record<string, string>;
|
||||
}) => Promise<unknown>;
|
||||
},
|
||||
): Promise<RotationResult> {
|
||||
const rows = await delegate.findMany({ select: { id: true, [field]: true } });
|
||||
const result: RotationResult = {
|
||||
table,
|
||||
field,
|
||||
@@ -65,17 +86,63 @@ async function rotateField<T extends { id: string }>(
|
||||
errors: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const v = fieldGetter(row);
|
||||
const v = row[field] as string | null;
|
||||
if (!shouldRotate(v)) continue;
|
||||
const id = row.id as string;
|
||||
try {
|
||||
const re = encrypt(decrypt(v as string));
|
||||
await updater(row.id, re);
|
||||
await delegate.update({ where: { id }, data: { [field]: re } });
|
||||
result.rotated++;
|
||||
} catch (err) {
|
||||
result.errors++;
|
||||
console.error(
|
||||
`[${table}.${field}] row ${row.id}: ${(err as Error).message}`,
|
||||
);
|
||||
console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate one `Bytes` ciphertext column. The encrypt/decrypt helpers operate
|
||||
* on strings, so we go through a UTF-8 Buffer round-trip identical to the
|
||||
* persistence layer (`src/lib/ai/coach/persistence.ts:60-71`).
|
||||
*/
|
||||
async function rotateBytesColumn(
|
||||
table: string,
|
||||
field: string,
|
||||
delegate: {
|
||||
findMany: (args: {
|
||||
select: Record<string, true>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
data: Record<string, Uint8Array>;
|
||||
}) => Promise<unknown>;
|
||||
},
|
||||
): Promise<RotationResult> {
|
||||
const rows = await delegate.findMany({ select: { id: true, [field]: true } });
|
||||
const result: RotationResult = {
|
||||
table,
|
||||
field,
|
||||
scanned: rows.length,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const buf = row[field] as Uint8Array | null;
|
||||
if (!buf || buf.byteLength === 0) continue;
|
||||
const asString = Buffer.from(buf).toString("utf8");
|
||||
if (!shouldRotate(asString)) continue;
|
||||
const id = row.id as string;
|
||||
try {
|
||||
const rotated = encrypt(decrypt(asString));
|
||||
const encoded = Buffer.from(rotated, "utf8");
|
||||
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
|
||||
next.set(encoded);
|
||||
await delegate.update({ where: { id }, data: { [field]: next } });
|
||||
result.rotated++;
|
||||
} catch (err) {
|
||||
result.errors++;
|
||||
console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -85,36 +152,24 @@ async function main() {
|
||||
console.log(`Rotating encrypted rows to active key id: ${targetKeyId}`);
|
||||
const results: RotationResult[] = [];
|
||||
|
||||
// ───── User table ─────
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
codexAccessTokenEncrypted: true,
|
||||
codexRefreshTokenEncrypted: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
moodLogWebhookSecret: true,
|
||||
telegramBotToken: true,
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
withingsClientIdEncrypted: true,
|
||||
withingsClientSecretEncrypted: true,
|
||||
whoopClientIdEncrypted: true,
|
||||
whoopClientSecretEncrypted: true,
|
||||
fitbitClientIdEncrypted: true,
|
||||
fitbitClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userFields: Array<keyof (typeof users)[number]> = [
|
||||
// ───── User — integration credentials + AI keys + KVNR (String) ─────
|
||||
// Columns: "codexAccessTokenEncrypted" "codexRefreshTokenEncrypted"
|
||||
// "telegramBotToken" "moodLogWebhookSecret" "moodLogUrlEncrypted"
|
||||
// "moodLogApiKeyEncrypted" "withingsClientIdEncrypted"
|
||||
// "withingsClientSecretEncrypted" "whoopClientIdEncrypted"
|
||||
// "whoopClientSecretEncrypted" "fitbitClientIdEncrypted"
|
||||
// "fitbitClientSecretEncrypted" "nightscoutUrlEncrypted"
|
||||
// "nightscoutTokenEncrypted" "polarAccessTokenEncrypted"
|
||||
// "polarUserIdEncrypted" "polarClientIdEncrypted"
|
||||
// "polarClientSecretEncrypted" "ouraAccessTokenEncrypted"
|
||||
// "ouraRefreshTokenEncrypted" "ouraClientIdEncrypted"
|
||||
// "ouraClientSecretEncrypted" "aiAnthropicKeyEncrypted"
|
||||
// "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted"
|
||||
const userFields = [
|
||||
"codexAccessTokenEncrypted",
|
||||
"codexRefreshTokenEncrypted",
|
||||
"aiAnthropicKeyEncrypted",
|
||||
"aiLocalKeyEncrypted",
|
||||
"aiOpenaiKeyEncrypted",
|
||||
"moodLogWebhookSecret",
|
||||
"telegramBotToken",
|
||||
"moodLogWebhookSecret",
|
||||
"moodLogUrlEncrypted",
|
||||
"moodLogApiKeyEncrypted",
|
||||
"withingsClientIdEncrypted",
|
||||
@@ -123,338 +178,197 @@ async function main() {
|
||||
"whoopClientSecretEncrypted",
|
||||
"fitbitClientIdEncrypted",
|
||||
"fitbitClientSecretEncrypted",
|
||||
"nightscoutUrlEncrypted",
|
||||
"nightscoutTokenEncrypted",
|
||||
"polarAccessTokenEncrypted",
|
||||
"polarUserIdEncrypted",
|
||||
"polarClientIdEncrypted",
|
||||
"polarClientSecretEncrypted",
|
||||
"ouraAccessTokenEncrypted",
|
||||
"ouraRefreshTokenEncrypted",
|
||||
"ouraClientIdEncrypted",
|
||||
"ouraClientSecretEncrypted",
|
||||
"aiAnthropicKeyEncrypted",
|
||||
"aiLocalKeyEncrypted",
|
||||
"aiOpenaiKeyEncrypted",
|
||||
"insuranceNumberEncrypted",
|
||||
];
|
||||
for (const field of userFields) {
|
||||
const r = await rotateField(
|
||||
"User",
|
||||
field as string,
|
||||
users,
|
||||
(u) => (u as Record<string, unknown>)[field as string] as string | null,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
results.push(await rotateStringColumn("User", field, prisma.user));
|
||||
}
|
||||
|
||||
// ───── WithingsConnection table (accessToken / refreshToken) ─────
|
||||
const withings = await prisma.withingsConnection.findMany({
|
||||
select: { id: true, accessToken: true, refreshToken: true },
|
||||
});
|
||||
for (const field of ["accessToken", "refreshToken"] as const) {
|
||||
const r = await rotateField(
|
||||
"WithingsConnection",
|
||||
field,
|
||||
withings,
|
||||
(w) => w[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.withingsConnection.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── WhoopConnection table (accessToken / refreshToken) ─────
|
||||
const whoop = await prisma.whoopConnection.findMany({
|
||||
select: { id: true, accessToken: true, refreshToken: true },
|
||||
});
|
||||
for (const field of ["accessToken", "refreshToken"] as const) {
|
||||
const r = await rotateField(
|
||||
"WhoopConnection",
|
||||
field,
|
||||
whoop,
|
||||
(w) => w[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.whoopConnection.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── FitbitConnection table (accessToken / refreshToken) ─────
|
||||
const fitbit = await prisma.fitbitConnection.findMany({
|
||||
select: { id: true, accessToken: true, refreshToken: true },
|
||||
});
|
||||
for (const field of ["accessToken", "refreshToken"] as const) {
|
||||
const r = await rotateField(
|
||||
"FitbitConnection",
|
||||
field,
|
||||
fitbit,
|
||||
(f) => f[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.fitbitConnection.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── AppSettings table (singleton typically) ─────
|
||||
const settings = await prisma.appSettings.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
adminAiKeyEncrypted: true,
|
||||
webPushVapidPrivateKeyEncrypted: true,
|
||||
githubIssueTokenEncrypted: true,
|
||||
},
|
||||
});
|
||||
const appFields = [
|
||||
"adminAiKeyEncrypted",
|
||||
"webPushVapidPrivateKeyEncrypted",
|
||||
"githubIssueTokenEncrypted",
|
||||
] as const;
|
||||
for (const field of appFields) {
|
||||
const r = await rotateField(
|
||||
"AppSettings",
|
||||
field,
|
||||
settings,
|
||||
(s) => s[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.appSettings.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── MoodTag.labelEncrypted ─────
|
||||
// v1.13.0 — AES-256-GCM ciphertext of a custom mood tag's free-text
|
||||
// label (catalogue rows carry NULL and are skipped by `shouldRotate`).
|
||||
const moodTags = await prisma.moodTag.findMany({
|
||||
where: { labelEncrypted: { not: null } },
|
||||
select: { id: true, labelEncrypted: true },
|
||||
});
|
||||
results.push(
|
||||
await rotateField(
|
||||
"MoodTag",
|
||||
"labelEncrypted",
|
||||
moodTags,
|
||||
(t) => t.labelEncrypted,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.moodTag.update({
|
||||
where: { id },
|
||||
data: { labelEncrypted: ciphertext },
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ───── MoodTagCategory.labelEncrypted ─────
|
||||
// v1.17.0 — AES-256-GCM ciphertext of a custom mood-tag group's
|
||||
// free-text label (seeded rows carry NULL and are skipped).
|
||||
const moodTagCategories = await prisma.moodTagCategory.findMany({
|
||||
where: { labelEncrypted: { not: null } },
|
||||
select: { id: true, labelEncrypted: true },
|
||||
});
|
||||
results.push(
|
||||
await rotateField(
|
||||
"MoodTagCategory",
|
||||
"labelEncrypted",
|
||||
moodTagCategories,
|
||||
(c) => c.labelEncrypted,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.moodTagCategory.update({
|
||||
where: { id },
|
||||
data: { labelEncrypted: ciphertext },
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ───── NotificationChannel.config ─────
|
||||
// Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
|
||||
// Skipping these on rotation would leave channels permanently undecryptable
|
||||
// once the operator drops `v1` from the key map.
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
select: { id: true, config: true },
|
||||
});
|
||||
results.push(
|
||||
await rotateField(
|
||||
"NotificationChannel",
|
||||
"config",
|
||||
channels,
|
||||
(c) => c.config,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.notificationChannel.update({
|
||||
where: { id },
|
||||
data: { config: ciphertext },
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ───── PushSubscription.{p256dh, auth} ─────
|
||||
// Web-push routing secrets — without these, the push endpoint is reachable
|
||||
// but the browser ignores the message (auth tag mismatch).
|
||||
const subs = await prisma.pushSubscription.findMany({
|
||||
select: { id: true, p256dh: true, auth: true },
|
||||
});
|
||||
for (const field of ["p256dh", "auth"] as const) {
|
||||
// ───── OAuth token tables (String "accessToken" / "refreshToken") ─────
|
||||
for (const field of ["accessToken", "refreshToken"]) {
|
||||
results.push(
|
||||
await rotateField(
|
||||
"PushSubscription",
|
||||
await rotateStringColumn(
|
||||
"WithingsConnection",
|
||||
field,
|
||||
subs,
|
||||
(s) => s[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.pushSubscription.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
prisma.withingsConnection,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateStringColumn("WhoopConnection", field, prisma.whoopConnection),
|
||||
);
|
||||
results.push(
|
||||
await rotateStringColumn(
|
||||
"FitbitConnection",
|
||||
field,
|
||||
prisma.fitbitConnection,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ───── 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 },
|
||||
});
|
||||
// ───── AppSettings — operator credentials (String) ─────
|
||||
// Columns: "adminAiKeyEncrypted" "webPushVapidPrivateKeyEncrypted"
|
||||
// "githubIssueTokenEncrypted"
|
||||
for (const field of [
|
||||
"adminAiKeyEncrypted",
|
||||
"webPushVapidPrivateKeyEncrypted",
|
||||
"githubIssueTokenEncrypted",
|
||||
]) {
|
||||
results.push(
|
||||
await rotateStringColumn("AppSettings", field, prisma.appSettings),
|
||||
);
|
||||
}
|
||||
|
||||
// ───── Custom labels — mood + cycle (String "labelEncrypted") ─────
|
||||
// Catalogue rows carry NULL and are skipped by `shouldRotate`.
|
||||
results.push(
|
||||
await rotateField(
|
||||
"IntegrationStatus",
|
||||
"lastError",
|
||||
statuses,
|
||||
(s) => s.lastError,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.integrationStatus.update({
|
||||
where: { id },
|
||||
data: { lastError: ciphertext },
|
||||
});
|
||||
},
|
||||
await rotateStringColumn("MoodTag", "labelEncrypted", prisma.moodTag),
|
||||
);
|
||||
results.push(
|
||||
await rotateStringColumn(
|
||||
"MoodTagCategory",
|
||||
"labelEncrypted",
|
||||
prisma.moodTagCategory,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateStringColumn(
|
||||
"CycleSymptom",
|
||||
"labelEncrypted",
|
||||
prisma.cycleSymptom,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── 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);
|
||||
// ───── NotificationChannel."config" (encrypted JSON) ─────
|
||||
// Channel config (Telegram chat id, ntfy topic, etc.). Skipping these on
|
||||
// rotation would leave channels permanently undecryptable once the
|
||||
// operator drops `v1` from the key map.
|
||||
results.push(
|
||||
await rotateStringColumn(
|
||||
"NotificationChannel",
|
||||
"config",
|
||||
prisma.notificationChannel,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── CoachConversation.summaryEncrypted ─────
|
||||
// v1.11.1 — `Bytes` column carrying the UTF-8 ciphertext of the rolling
|
||||
// conversation summary. Same Buffer round-trip as CoachMessage; nullable, so
|
||||
// skip empty/absent rows.
|
||||
const coachConversations = await prisma.coachConversation.findMany({
|
||||
where: { summaryEncrypted: { not: null } },
|
||||
select: { id: true, summaryEncrypted: true },
|
||||
});
|
||||
const summaryResult: RotationResult = {
|
||||
table: "CoachConversation",
|
||||
field: "summaryEncrypted",
|
||||
scanned: coachConversations.length,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
for (const row of coachConversations) {
|
||||
const buf = row.summaryEncrypted;
|
||||
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.coachConversation.update({
|
||||
where: { id: row.id },
|
||||
data: { summaryEncrypted: next },
|
||||
});
|
||||
summaryResult.rotated++;
|
||||
} catch (err) {
|
||||
summaryResult.errors++;
|
||||
console.error(
|
||||
`[CoachConversation.summaryEncrypted] row ${row.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
// ───── PushSubscription."p256dh" / "auth" ─────
|
||||
// Web-push routing secrets — without these, the push endpoint is reachable
|
||||
// but the browser ignores the message (auth tag mismatch).
|
||||
for (const field of ["p256dh", "auth"]) {
|
||||
results.push(
|
||||
await rotateStringColumn(
|
||||
"PushSubscription",
|
||||
field,
|
||||
prisma.pushSubscription,
|
||||
),
|
||||
);
|
||||
}
|
||||
results.push(summaryResult);
|
||||
|
||||
// ───── CoachFact.factEncrypted ─────
|
||||
// v1.11.1 — `Bytes` column carrying the UTF-8 ciphertext of each durable
|
||||
// personal fact. Same Buffer round-trip as CoachMessage.
|
||||
const coachFacts = await prisma.coachFact.findMany({
|
||||
select: { id: true, factEncrypted: true },
|
||||
});
|
||||
const factResult: RotationResult = {
|
||||
table: "CoachFact",
|
||||
field: "factEncrypted",
|
||||
scanned: coachFacts.length,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
for (const row of coachFacts) {
|
||||
const buf = row.factEncrypted;
|
||||
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.coachFact.update({
|
||||
where: { id: row.id },
|
||||
data: { factEncrypted: next },
|
||||
});
|
||||
factResult.rotated++;
|
||||
} catch (err) {
|
||||
factResult.errors++;
|
||||
console.error(
|
||||
`[CoachFact.factEncrypted] row ${row.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
// ───── IntegrationStatus."lastError" ─────
|
||||
// AES-256-GCM ciphertext of an upstream error payload. Drop the legacy key
|
||||
// while a row still lives here and the admin status view 500s.
|
||||
results.push(
|
||||
await rotateStringColumn(
|
||||
"IntegrationStatus",
|
||||
"lastError",
|
||||
prisma.integrationStatus,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── CycleDayLog — "sensitiveEncrypted" / "notesEncrypted" (String) ─────
|
||||
for (const field of ["sensitiveEncrypted", "notesEncrypted"]) {
|
||||
results.push(
|
||||
await rotateStringColumn("CycleDayLog", field, prisma.cycleDayLog),
|
||||
);
|
||||
}
|
||||
results.push(factResult);
|
||||
|
||||
// ───── Coach (Bytes columns) ─────
|
||||
// "encryptedContent" "summaryEncrypted" "factEncrypted"
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachMessage",
|
||||
"encryptedContent",
|
||||
prisma.coachMessage,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachConversation",
|
||||
"summaryEncrypted",
|
||||
prisma.coachConversation,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn("CoachFact", "factEncrypted", prisma.coachFact),
|
||||
);
|
||||
|
||||
// ───── UserHealthProfile (Bytes columns) ─────
|
||||
// "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted"
|
||||
// "coachFocusEncrypted" "pendingQuestionsEncrypted"
|
||||
for (const field of [
|
||||
"aboutMeEncrypted",
|
||||
"conditionsEncrypted",
|
||||
"allergiesEncrypted",
|
||||
"coachFocusEncrypted",
|
||||
"pendingQuestionsEncrypted",
|
||||
]) {
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"UserHealthProfile",
|
||||
field,
|
||||
prisma.userHealthProfile,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ───── InsightNarrative."encryptedContent" (Bytes) ─────
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"InsightNarrative",
|
||||
"encryptedContent",
|
||||
prisma.insightNarrative,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.18.1 clinical-spine notes (Bytes columns) ─────
|
||||
// "noteEncrypted" (LabResult / IllnessEpisode / IllnessDayLog) +
|
||||
// "contextEncrypted" (Biomarker). Mirror the CoachFact.factEncrypted block.
|
||||
results.push(
|
||||
await rotateBytesColumn("LabResult", "noteEncrypted", prisma.labResult),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"Biomarker",
|
||||
"contextEncrypted",
|
||||
prisma.biomarker,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"IllnessEpisode",
|
||||
"noteEncrypted",
|
||||
prisma.illnessEpisode,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"IllnessDayLog",
|
||||
"noteEncrypted",
|
||||
prisma.illnessDayLog,
|
||||
),
|
||||
);
|
||||
|
||||
console.log("\n=== Rotation summary ===");
|
||||
let totalRotated = 0;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ENCRYPTED_COLUMNS,
|
||||
encryptedColumnKey,
|
||||
type EncryptedColumn,
|
||||
} from "../encrypted-columns";
|
||||
|
||||
const ROOT = join(__dirname, "../../../..");
|
||||
const SCHEMA_PATH = join(ROOT, "prisma", "schema.prisma");
|
||||
const ROTATION_SCRIPT_PATH = join(
|
||||
ROOT,
|
||||
"scripts",
|
||||
"rotate-encryption-key.ts",
|
||||
);
|
||||
|
||||
/**
|
||||
* Reversibly-encrypted columns whose Prisma field name does NOT end in
|
||||
* `Encrypted`. These are historically-named encrypted columns; they are
|
||||
* called out explicitly here so the schema scan can detect a NEW encrypted
|
||||
* column even when it follows the `*Encrypted` convention, without
|
||||
* mis-flagging unrelated columns. The scan's primary signal is the
|
||||
* `*Encrypted` suffix; this set covers the documented exceptions.
|
||||
*/
|
||||
const NON_SUFFIX_ENCRYPTED: ReadonlySet<string> = new Set([
|
||||
// WithingsConnection / WhoopConnection / FitbitConnection
|
||||
"accessToken",
|
||||
"refreshToken",
|
||||
// NotificationChannel
|
||||
"config",
|
||||
// PushSubscription
|
||||
"p256dh",
|
||||
"auth",
|
||||
// IntegrationStatus
|
||||
"lastError",
|
||||
// CoachMessage / InsightNarrative
|
||||
"encryptedContent",
|
||||
// User (Telegram + moodLog)
|
||||
"telegramBotToken",
|
||||
"moodLogWebhookSecret",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Columns that match the encrypted-column scan signal but are NOT
|
||||
* reversibly encrypted and therefore deliberately excluded from rotation.
|
||||
*/
|
||||
const NOT_ENCRYPTED: ReadonlySet<string> = new Set<string>([
|
||||
// RefreshToken.accessTokenHash — HMAC, one-way (no rotation path).
|
||||
"RefreshToken.accessTokenHash",
|
||||
]);
|
||||
|
||||
interface SchemaColumn {
|
||||
model: string;
|
||||
field: string;
|
||||
/** "String" | "Bytes" | ... — the declared Prisma scalar. */
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse schema.prisma into (model, field, type) triples. Lightweight: tracks
|
||||
* the current `model X {` block and reads the first two tokens of each field
|
||||
* line.
|
||||
*/
|
||||
function parseSchemaColumns(): SchemaColumn[] {
|
||||
const src = readFileSync(SCHEMA_PATH, "utf8");
|
||||
const columns: SchemaColumn[] = [];
|
||||
let model: string | null = null;
|
||||
for (const rawLine of src.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (line.startsWith("//") || line.startsWith("///") || line === "") {
|
||||
continue;
|
||||
}
|
||||
const modelMatch = /^model\s+(\w+)\s*\{/.exec(line);
|
||||
if (modelMatch) {
|
||||
model = modelMatch[1];
|
||||
continue;
|
||||
}
|
||||
if (line === "}") {
|
||||
model = null;
|
||||
continue;
|
||||
}
|
||||
if (!model) continue;
|
||||
// Field line: `<name> <Type>...`. Skip block attributes (`@@map`) and
|
||||
// relation/attribute-only lines.
|
||||
const fieldMatch = /^(\w+)\s+([A-Za-z]+)(\?|\[\])?/.exec(line);
|
||||
if (!fieldMatch) continue;
|
||||
columns.push({ model, field: fieldMatch[1], type: fieldMatch[2] });
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a schema column carries AES-256-GCM ciphertext. Signal:
|
||||
* the `*Encrypted` suffix, OR membership in the documented non-suffix set.
|
||||
*/
|
||||
function isEncryptedColumn(c: SchemaColumn): boolean {
|
||||
const key = `${c.model}.${c.field}`;
|
||||
if (NOT_ENCRYPTED.has(key)) return false;
|
||||
if (c.field.endsWith("Encrypted")) return true;
|
||||
return NON_SUFFIX_ENCRYPTED.has(c.field);
|
||||
}
|
||||
|
||||
describe("encrypted-column registry", () => {
|
||||
it("has no duplicate entries", () => {
|
||||
const keys = ENCRYPTED_COLUMNS.map(encryptedColumnKey);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("assigns the right storage kind for each column type", () => {
|
||||
const schema = parseSchemaColumns();
|
||||
const typeByKey = new Map<string, string>(
|
||||
schema.map((c) => [`${c.model}.${c.field}`, c.type]),
|
||||
);
|
||||
for (const col of ENCRYPTED_COLUMNS) {
|
||||
const key = encryptedColumnKey(col);
|
||||
const type = typeByKey.get(key);
|
||||
expect(type, `registry column ${key} not found in schema`).toBeDefined();
|
||||
const expectedKind = type === "Bytes" ? "bytes" : "string";
|
||||
expect(col.kind, `${key} declared ${type} but kind=${col.kind}`).toBe(
|
||||
expectedKind,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers EVERY encrypted column in prisma/schema.prisma", () => {
|
||||
const schemaEncrypted = parseSchemaColumns()
|
||||
.filter(isEncryptedColumn)
|
||||
.map((c) => `${c.model}.${c.field}`)
|
||||
.sort();
|
||||
const registered = ENCRYPTED_COLUMNS.map(encryptedColumnKey).sort();
|
||||
|
||||
// Any schema column the registry forgot is a rotation gap (data-loss
|
||||
// risk on key drop). Any registry column missing from the schema is a
|
||||
// stale entry. Both fail here.
|
||||
const missingFromRegistry = schemaEncrypted.filter(
|
||||
(k) => !registered.includes(k),
|
||||
);
|
||||
const staleInRegistry = registered.filter(
|
||||
(k) => !schemaEncrypted.includes(k),
|
||||
);
|
||||
expect(
|
||||
missingFromRegistry,
|
||||
"encrypted schema columns NOT wired into the rotation registry",
|
||||
).toEqual([]);
|
||||
expect(
|
||||
staleInRegistry,
|
||||
"registry columns that no longer exist in the schema",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("is fully referenced by the key-rotation script", () => {
|
||||
const script = readFileSync(ROTATION_SCRIPT_PATH, "utf8");
|
||||
const unreferenced = ENCRYPTED_COLUMNS.filter(
|
||||
(c: EncryptedColumn) => !script.includes(`"${c.field}"`),
|
||||
).map(encryptedColumnKey);
|
||||
expect(
|
||||
unreferenced,
|
||||
"registry columns NOT referenced by scripts/rotate-encryption-key.ts",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Canonical registry of every database column that holds AES-256-GCM
|
||||
* ciphertext (via `src/lib/crypto.ts`).
|
||||
*
|
||||
* This is the single source of truth for the key-rotation script
|
||||
* (`scripts/rotate-encryption-key.ts`): the script MUST re-encrypt every
|
||||
* column listed here, and the guard test
|
||||
* (`encrypted-columns.test.ts`) asserts three invariants:
|
||||
*
|
||||
* 1. Every encrypted column in `prisma/schema.prisma` (any `*Encrypted`
|
||||
* column, plus the small set of historically-named encrypted columns
|
||||
* enumerated below) appears in this registry.
|
||||
* 2. The rotation script references every column in this registry.
|
||||
* 3. No registry entry is missing a kind / duplicated.
|
||||
*
|
||||
* Adding a new encrypted column WITHOUT adding it here fails CI; adding it
|
||||
* here without wiring the rotation script also fails CI. Together this makes
|
||||
* "a new encrypted column silently skipped on rotation" structurally
|
||||
* impossible — dropping a legacy key would otherwise make those rows
|
||||
* permanently undecryptable (decrypt is fail-closed).
|
||||
*
|
||||
* `kind` distinguishes the storage shape the rotation script must use:
|
||||
* - "string" — the ciphertext is stored as a `String` column; the script
|
||||
* reads/writes the value directly.
|
||||
* - "bytes" — the ciphertext is stored as a `Bytes` column; the script
|
||||
* goes through a UTF-8 Buffer round-trip (matching the persistence layer).
|
||||
*/
|
||||
|
||||
export type EncryptedColumnKind = "string" | "bytes";
|
||||
|
||||
export interface EncryptedColumn {
|
||||
/** Prisma model name (PascalCase, as declared in schema.prisma). */
|
||||
readonly model: string;
|
||||
/** Prisma field name (camelCase, as declared in schema.prisma). */
|
||||
readonly field: string;
|
||||
readonly kind: EncryptedColumnKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every reversibly-encrypted column. HMAC-hashed columns (`*Hash`,
|
||||
* `tokenHash`) and the `refresh_token`-convention columns are deliberately
|
||||
* EXCLUDED — they are one-way and have no rotation path.
|
||||
*/
|
||||
export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
// ───── User — integration credentials + AI keys + KVNR ─────
|
||||
{ model: "User", field: "codexAccessTokenEncrypted", kind: "string" },
|
||||
{ model: "User", field: "codexRefreshTokenEncrypted", kind: "string" },
|
||||
{ model: "User", field: "telegramBotToken", kind: "string" },
|
||||
{ model: "User", field: "moodLogWebhookSecret", kind: "string" },
|
||||
{ model: "User", field: "moodLogUrlEncrypted", kind: "string" },
|
||||
{ model: "User", field: "moodLogApiKeyEncrypted", kind: "string" },
|
||||
{ model: "User", field: "withingsClientIdEncrypted", kind: "string" },
|
||||
{ model: "User", field: "withingsClientSecretEncrypted", kind: "string" },
|
||||
{ model: "User", field: "whoopClientIdEncrypted", kind: "string" },
|
||||
{ model: "User", field: "whoopClientSecretEncrypted", kind: "string" },
|
||||
{ model: "User", field: "fitbitClientIdEncrypted", kind: "string" },
|
||||
{ model: "User", field: "fitbitClientSecretEncrypted", kind: "string" },
|
||||
{ model: "User", field: "nightscoutUrlEncrypted", kind: "string" },
|
||||
{ model: "User", field: "nightscoutTokenEncrypted", kind: "string" },
|
||||
{ model: "User", field: "polarAccessTokenEncrypted", kind: "string" },
|
||||
{ model: "User", field: "polarUserIdEncrypted", kind: "string" },
|
||||
{ model: "User", field: "polarClientIdEncrypted", kind: "string" },
|
||||
{ model: "User", field: "polarClientSecretEncrypted", kind: "string" },
|
||||
{ model: "User", field: "ouraAccessTokenEncrypted", kind: "string" },
|
||||
{ model: "User", field: "ouraRefreshTokenEncrypted", kind: "string" },
|
||||
{ model: "User", field: "ouraClientIdEncrypted", kind: "string" },
|
||||
{ model: "User", field: "ouraClientSecretEncrypted", kind: "string" },
|
||||
{ model: "User", field: "aiAnthropicKeyEncrypted", kind: "string" },
|
||||
{ model: "User", field: "aiLocalKeyEncrypted", kind: "string" },
|
||||
{ model: "User", field: "aiOpenaiKeyEncrypted", kind: "string" },
|
||||
{ model: "User", field: "insuranceNumberEncrypted", kind: "string" },
|
||||
|
||||
// ───── OAuth token tables (`*_token`-convention columns) ─────
|
||||
{ model: "WithingsConnection", field: "accessToken", kind: "string" },
|
||||
{ model: "WithingsConnection", field: "refreshToken", kind: "string" },
|
||||
{ model: "WhoopConnection", field: "accessToken", kind: "string" },
|
||||
{ model: "WhoopConnection", field: "refreshToken", kind: "string" },
|
||||
{ model: "FitbitConnection", field: "accessToken", kind: "string" },
|
||||
{ model: "FitbitConnection", field: "refreshToken", kind: "string" },
|
||||
|
||||
// ───── AppSettings — operator credentials ─────
|
||||
{ model: "AppSettings", field: "adminAiKeyEncrypted", kind: "string" },
|
||||
{
|
||||
model: "AppSettings",
|
||||
field: "webPushVapidPrivateKeyEncrypted",
|
||||
kind: "string",
|
||||
},
|
||||
{ model: "AppSettings", field: "githubIssueTokenEncrypted", kind: "string" },
|
||||
|
||||
// ───── Custom labels (mood + cycle) ─────
|
||||
{ model: "MoodTag", field: "labelEncrypted", kind: "string" },
|
||||
{ model: "MoodTagCategory", field: "labelEncrypted", kind: "string" },
|
||||
{ model: "CycleSymptom", field: "labelEncrypted", kind: "string" },
|
||||
|
||||
// ───── Notification + push secrets ─────
|
||||
{ model: "NotificationChannel", field: "config", kind: "string" },
|
||||
{ model: "PushSubscription", field: "p256dh", kind: "string" },
|
||||
{ model: "PushSubscription", field: "auth", kind: "string" },
|
||||
|
||||
// ───── Integration status error payloads ─────
|
||||
{ model: "IntegrationStatus", field: "lastError", kind: "string" },
|
||||
|
||||
// ───── Cycle day-log sensitive payloads (String?) ─────
|
||||
{ model: "CycleDayLog", field: "sensitiveEncrypted", kind: "string" },
|
||||
{ model: "CycleDayLog", field: "notesEncrypted", kind: "string" },
|
||||
|
||||
// ───── Coach (Bytes columns) ─────
|
||||
{ model: "CoachMessage", field: "encryptedContent", kind: "bytes" },
|
||||
{ model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" },
|
||||
{ model: "CoachFact", field: "factEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── User health profile (Bytes columns) ─────
|
||||
{ model: "UserHealthProfile", field: "aboutMeEncrypted", kind: "bytes" },
|
||||
{ model: "UserHealthProfile", field: "conditionsEncrypted", kind: "bytes" },
|
||||
{ model: "UserHealthProfile", field: "allergiesEncrypted", kind: "bytes" },
|
||||
{ model: "UserHealthProfile", field: "coachFocusEncrypted", kind: "bytes" },
|
||||
{
|
||||
model: "UserHealthProfile",
|
||||
field: "pendingQuestionsEncrypted",
|
||||
kind: "bytes",
|
||||
},
|
||||
|
||||
// ───── Insight narratives (Bytes column) ─────
|
||||
{ model: "InsightNarrative", field: "encryptedContent", kind: "bytes" },
|
||||
|
||||
// ───── v1.18.1 clinical-spine notes (Bytes columns) ─────
|
||||
{ model: "LabResult", field: "noteEncrypted", kind: "bytes" },
|
||||
{ model: "Biomarker", field: "contextEncrypted", kind: "bytes" },
|
||||
{ model: "IllnessEpisode", field: "noteEncrypted", kind: "bytes" },
|
||||
{ model: "IllnessDayLog", field: "noteEncrypted", kind: "bytes" },
|
||||
] as const;
|
||||
|
||||
/** Stable `Model.field` key for a registry entry. */
|
||||
export function encryptedColumnKey(c: EncryptedColumn): string {
|
||||
return `${c.model}.${c.field}`;
|
||||
}
|
||||
Reference in new issue
Block a user