mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The registry entry for InboundDocument.contentEncrypted gains a codecField marker: the column now holds two layouts (base64v1 string codec | binary2 binary codec), recorded per row, and its rows are up to cap-sized blobs. scan/rotate walk such a column with id-cursor pagination (25 rows per page — never an unbounded blob findMany) and re-encrypt each row under its OWN codec; an unknown codec value is counted as an error and the row left untouched, same fail-closed posture as a missing key. The walk is idempotent, so an interrupted run resumes on re-invocation with the already-active rows skipped. Both rotation paths share the logic: the admin-triggered pg-boss job via rotateCorpus() and the CLI (scripts/rotate-encryption-key.ts), which now calls the shared batched walk for this column. The registry guard test keeps schema, registry, and script in lock-step as before. Unit coverage over a mocked 500-row corpus asserts the batch boundedness (take + advancing cursor per page), both-codec rotation, per-row fail-closed on unknown codecs, and interrupted-run resume. Task: W1-T11.
217 lines
12 KiB
TypeScript
217 lines
12 KiB
TypeScript
/**
|
|
* 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;
|
|
/**
|
|
* Present when the column's ciphertext layout is dispatched by a sibling
|
|
* codec column (the document vault's `contentCodec`: "base64v1" = the
|
|
* `encrypt()`-string-as-UTF-8 shape every other Bytes column uses,
|
|
* "binary2" = the binary `encryptBytes()` layout). A codec-dispatched
|
|
* column also rotates in bounded id-cursor batches — its rows are
|
|
* multi-megabyte blobs, so an unbounded `findMany` would balloon memory.
|
|
*/
|
|
readonly codecField?: string;
|
|
}
|
|
|
|
/**
|
|
* 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" },
|
|
// v1.27.0 — Google Health (Fitbit / Pixel Watch) BYO OAuth client.
|
|
{ model: "User", field: "googleHealthClientIdEncrypted", kind: "string" },
|
|
{ model: "User", field: "googleHealthClientSecretEncrypted", 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" },
|
|
// v1.22 (#90) — dedicated document-scan (Lab-OCR) provider key.
|
|
{ model: "User", field: "aiOcrKeyEncrypted", kind: "string" },
|
|
{ model: "User", field: "insuranceNumberEncrypted", kind: "string" },
|
|
// v1.23 — TOTP shared secret (second factor). String ciphertext like the
|
|
// other User credential columns.
|
|
{ model: "User", field: "totpSecretEncrypted", 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" },
|
|
{ model: "GoogleHealthConnection", field: "accessToken", kind: "string" },
|
|
{ model: "GoogleHealthConnection", field: "refreshToken", kind: "string" },
|
|
|
|
// ───── AppSettings — operator credentials ─────
|
|
{ model: "AppSettings", field: "adminAiKeyEncrypted", kind: "string" },
|
|
{
|
|
model: "AppSettings",
|
|
field: "webPushVapidPrivateKeyEncrypted",
|
|
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" },
|
|
// v1.21.3 (B1) — Coach goal / if-then plan free-text columns.
|
|
{ model: "CoachPlan", field: "ifCueEncrypted", kind: "bytes" },
|
|
{ model: "CoachPlan", field: "thenActionEncrypted", kind: "bytes" },
|
|
{ model: "CoachPlan", field: "targetEncrypted", kind: "bytes" },
|
|
// v1.22 (W9, C2) — n-of-1 experiment outcome read-back prose.
|
|
{ model: "CoachPlan", field: "outcomeEncrypted", kind: "bytes" },
|
|
// v1.22 (B2/B6) — Coach episodic reminder free-text note.
|
|
{ model: "CoachReminder", field: "noteEncrypted", 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" },
|
|
|
|
// ───── v1.19.0 ECG waveform (Bytes column) ─────
|
|
{ model: "EcgRecording", field: "waveformEncrypted", kind: "bytes" },
|
|
|
|
// ───── v1.23 free-text health notes (Bytes columns) ─────
|
|
// Free-text mood diary + per-measurement notes. Same class as the
|
|
// IllnessDayLog / LabResult note columns above; Bytes via the shared codec.
|
|
{ model: "MoodEntry", field: "noteEncrypted", kind: "bytes" },
|
|
{ model: "Measurement", field: "notesEncrypted", kind: "bytes" },
|
|
|
|
// ───── v1.25 medication free-text notes (Bytes columns) ─────
|
|
// Side-effect log note, dose-change titration note, and inventory-item
|
|
// note — the last plaintext PHI columns left after the v1.23 rollout.
|
|
{ model: "MedicationSideEffect", field: "notesEncrypted", kind: "bytes" },
|
|
{ model: "MedicationDoseChange", field: "noteEncrypted", kind: "bytes" },
|
|
{ model: "MedicationInventoryItem", field: "notesEncrypted", kind: "bytes" },
|
|
|
|
// ───── v1.25 mental-health screener item answers (Bytes column) ─────
|
|
// The PHQ-9 / GAD-7 per-item responses (incl. the safety-critical item 9)
|
|
// ride a single AES-256-GCM blob. The most sensitive payload in the wave;
|
|
// never indexed, never logged, never in wide-event meta.
|
|
{
|
|
model: "MentalHealthAssessment",
|
|
field: "responsesEncrypted",
|
|
kind: "bytes",
|
|
},
|
|
// ───── v1.25 structured health records (Bytes columns) ─────
|
|
// Allergy free-text reaction + note, and the family-history note. The
|
|
// structured enum/label columns stay queryable plaintext; only the
|
|
// sensitive free-text fields are encrypted.
|
|
{ model: "Allergy", field: "reactionEncrypted", kind: "bytes" },
|
|
{ model: "Allergy", field: "notesEncrypted", kind: "bytes" },
|
|
{ model: "FamilyHistoryEntry", field: "notesEncrypted", kind: "bytes" },
|
|
|
|
// ───── Inbound clinical document (Bytes column, codec-dispatched) ─────
|
|
// The raw uploaded document (the most sensitive blob in the corpus; never
|
|
// logged, never in wide-event meta). Two layouts, recorded per row in
|
|
// `contentCodec`: "base64v1" (pre-vault rows — base64-of-binary →
|
|
// AES-256-GCM string → UTF-8 bytes) and "binary2" (vault uploads — the
|
|
// binary `encryptBytes()` layout). Rotation walks this column in bounded
|
|
// id-cursor batches and re-encrypts under the row's own codec.
|
|
{
|
|
model: "InboundDocument",
|
|
field: "contentEncrypted",
|
|
kind: "bytes",
|
|
codecField: "contentCodec",
|
|
},
|
|
// The staged extracted-fact payloads: the FHIR-staged clinical values
|
|
// (diagnosis text / lab values / medication names / stated codes) and the
|
|
// verbatim source-span provenance. Both are PHI transcribed from the source
|
|
// document, so the staging rows carry the same at-rest guarantee as the
|
|
// document itself rather than persisting as plaintext JSONB.
|
|
{ model: "ExtractedFact", field: "dataEncrypted", kind: "bytes" },
|
|
{ model: "ExtractedFact", field: "provenanceEncrypted", kind: "bytes" },
|
|
] as const;
|
|
|
|
/** Stable `Model.field` key for a registry entry. */
|
|
export function encryptedColumnKey(c: EncryptedColumn): string {
|
|
return `${c.model}.${c.field}`;
|
|
}
|