mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(security): rotation covers all encrypted columns + CI guard; danger-zone typed confirm; admin rate-limit
This commit is contained in:
commit
a3217ff767
12 files changed
+643
-357
No files matched your search
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "Alle Daten wirklich löschen?",
|
||||
"deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Benutzer unwiderruflich. Benutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.",
|
||||
"deleteButton": "Alle Daten löschen",
|
||||
"deleteAllPromptLabel": "Tippe {token} zum Fortfahren",
|
||||
"finalDelete": "Endgültig löschen",
|
||||
"deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges, {notificationChannels} Benachrichtigungskanäle, {pushSubscriptions} Push-Abonnements, {telegramScheduledDeletions} eingeplante Telegram-Löschungen. Feedback bleibt erhalten.",
|
||||
"authRegister": "Registrierung",
|
||||
|
||||
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "Really delete all data?",
|
||||
"deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, open authentication challenges, notification channels (Telegram, ntfy, Web Push), push subscriptions, and queued Telegram message deletions for all users. User accounts, the audit log, and submitted feedback are preserved so the wipe itself remains traceable and bug reports stay triageable.",
|
||||
"deleteButton": "Delete all data",
|
||||
"deleteAllPromptLabel": "Type {token} to continue",
|
||||
"finalDelete": "Delete permanently",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
|
||||
"authRegister": "Registration",
|
||||
|
||||
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "¿Eliminar realmente todos los datos?",
|
||||
"deleteAllConfirmDescription": "Esto eliminará permanentemente todas las mediciones, medicamentos, eventos de toma, tokens de API, conexiones de Withings, retos de autenticación abiertos, canales de notificación (Telegram, ntfy, Web Push), suscripciones push y eliminaciones de mensajes de Telegram en cola de todos los usuarios. Las cuentas de usuario, el registro de auditoría y los comentarios enviados se conservan para que el borrado siga siendo rastreable y los informes de errores se puedan seguir clasificando.",
|
||||
"deleteButton": "Eliminar todos los datos",
|
||||
"deleteAllPromptLabel": "Escribe {token} para continuar",
|
||||
"finalDelete": "Eliminar permanentemente",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} Medicamentos,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
|
||||
"authRegister": "Registro",
|
||||
|
||||
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "Vraiment supprimer toutes les données ?",
|
||||
"deleteAllConfirmDescription": "Cette action supprimera définitivement toutes les mesures, médicaments, événements de prise, jetons d'API, connexions Withings, défis d'authentification ouverts, canaux de notification (Telegram, ntfy, Web Push), abonnements push et suppressions de messages Telegram en file d'attente pour tous les utilisateurs. Les comptes utilisateurs, le journal d'audit et les retours soumis sont conservés afin que l'effacement reste traçable et que les rapports de bug restent triables.",
|
||||
"deleteButton": "Supprimer toutes les données",
|
||||
"deleteAllPromptLabel": "Tapez {token} pour continuer",
|
||||
"finalDelete": "Supprimer définitivement",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} Médicaments,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
|
||||
"authRegister": "Inscription",
|
||||
|
||||
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "Eliminare davvero tutti i dati?",
|
||||
"deleteAllConfirmDescription": "Questa operazione eliminerà definitivamente tutte le misurazioni, i farmaci, gli eventi di assunzione, i token API, le connessioni Withings, le challenge di autenticazione aperte, i canali di notifica (Telegram, ntfy, Web Push), le sottoscrizioni push e le eliminazioni di messaggi Telegram in coda di tutti gli utenti. Gli account utente, il log di audit e i feedback inviati vengono conservati affinché la cancellazione resti tracciabile e le segnalazioni di bug restino gestibili.",
|
||||
"deleteButton": "Elimina tutti i dati",
|
||||
"deleteAllPromptLabel": "Digita {token} per continuare",
|
||||
"finalDelete": "Elimina definitivamente",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} Farmaci,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
|
||||
"authRegister": "Registrazione",
|
||||
|
||||
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "Na pewno usunąć wszystkie dane?",
|
||||
"deleteAllConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich pomiarów, leków, zdarzeń przyjęcia, tokenów API, połączeń Withings, otwartych wyzwań uwierzytelniania, kanałów powiadomień (Telegram, ntfy, Web Push), subskrypcji push oraz zakolejkowanych usunięć wiadomości Telegram wszystkich użytkowników. Konta użytkowników, dziennik audytu i przesłane opinie zostają zachowane, aby samo usunięcie pozostało możliwe do prześledzenia, a zgłoszenia błędów — do obsłużenia.",
|
||||
"deleteButton": "Usuń wszystkie dane",
|
||||
"deleteAllPromptLabel": "Wpisz {token}, aby kontynuować",
|
||||
"finalDelete": "Usuń trwale",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} Leki,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
|
||||
"authRegister": "Rejestracja",
|
||||
|
||||
+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;
|
||||
|
||||
@@ -26,6 +26,22 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.data.delete" } });
|
||||
|
||||
// v1.18.1 — the documented convention buckets authenticated admin
|
||||
// mutations on `userId`, not the (pre-auth, spoofable) client IP. The
|
||||
// IP bucket above is the anonymous first-line throttle; this is the
|
||||
// canonical per-admin bucket the rate-limit contract specifies.
|
||||
const userRl = await checkRateLimit(
|
||||
`admin-data-delete:${user.id}`,
|
||||
5,
|
||||
60 * 1000,
|
||||
);
|
||||
if (!userRl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Rate limit exceeded" },
|
||||
{ status: 429, headers: rateLimitHeaders(userRl) },
|
||||
);
|
||||
}
|
||||
|
||||
let confirm = "";
|
||||
try {
|
||||
const raw = await request.text();
|
||||
|
||||
@@ -39,7 +39,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -70,6 +69,7 @@ function RestoreRowDialog({
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const matched = typed.trim() === "RESTORE";
|
||||
@@ -108,7 +108,7 @@ function RestoreRowDialog({
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.backups.restoreDescription", {
|
||||
username: row.username,
|
||||
when: formatDateTime(row.createdAt),
|
||||
when: fmt.dateTime(row.createdAt),
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
@@ -501,7 +501,7 @@ export function BackupsSection() {
|
||||
{formatBytes(row.sizeBytes, fmt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(row.createdAt)}
|
||||
{fmt.dateTime(row.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
@@ -15,17 +15,36 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* The literal an admin must type to arm the wipe. Mirrors the Backups
|
||||
* `RESTORE` gate — and matches the server's typed-token defence at
|
||||
* `src/app/api/admin/data/route.ts` (which 422s any other `confirm`).
|
||||
*/
|
||||
const WIPE_CONFIRM_TOKEN = "DELETE ALL";
|
||||
|
||||
export function DangerZoneSection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [wipeMsg, setWipeMsg] = useState<string | null>(null);
|
||||
// Typed-confirmation gate. The destructive button stays disabled until
|
||||
// the admin types `DELETE ALL` verbatim, matching the Backups Restore
|
||||
// dialog (which forces typing `RESTORE`) so the FAR more destructive
|
||||
// global wipe is not the weaker gate. Reset whenever the dialog closes.
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const matched = typed.trim() === WIPE_CONFIRM_TOKEN;
|
||||
|
||||
const wipeAllData = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Idempotency-Key prevents a double-submit from re-running the
|
||||
// destructive transaction, matching the Backups restore path.
|
||||
const idempotencyKey = `admin-data-wipe-${crypto.randomUUID()}`;
|
||||
return apiDelete<{
|
||||
measurements: number;
|
||||
intakeEvents: number;
|
||||
@@ -36,7 +55,11 @@ export function DangerZoneSection() {
|
||||
notificationChannels: number;
|
||||
pushSubscriptions: number;
|
||||
telegramScheduledDeletions: number;
|
||||
}>("/api/admin/data", { confirm: "DELETE ALL" });
|
||||
}>(
|
||||
"/api/admin/data",
|
||||
{ confirm: WIPE_CONFIRM_TOKEN },
|
||||
{ headers: { "Idempotency-Key": idempotencyKey } },
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries();
|
||||
@@ -72,7 +95,13 @@ export function DangerZoneSection() {
|
||||
title={t("admin.deleteAllData")}
|
||||
description={t("admin.deleteAllDescription")}
|
||||
status={
|
||||
<AlertDialog>
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setTyped("");
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -97,13 +126,36 @@ export function DangerZoneSection() {
|
||||
{t("admin.deleteAllConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wipe-confirm-prompt">
|
||||
{t("admin.deleteAllPromptLabel", {
|
||||
token: WIPE_CONFIRM_TOKEN,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="wipe-confirm-prompt"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={WIPE_CONFIRM_TOKEN}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={wipeAllData.isPending}
|
||||
disabled={!matched || wipeAllData.isPending}
|
||||
aria-busy={wipeAllData.isPending || undefined}
|
||||
onClick={() => wipeAllData.mutate()}
|
||||
onClick={(e) => {
|
||||
if (!matched) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setTyped("");
|
||||
wipeAllData.mutate();
|
||||
}}
|
||||
>
|
||||
{wipeAllData.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -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