Files
HealthLog/src/lib/crypto/encrypted-columns.ts
T
Marc-André Bombeck 4d3a02cc1a feat(security): add second-factor schema and encrypt free-text health notes at rest
Lay the account-security and data-sovereignty substrate for v1.23. No
user-facing behaviour yet — schema, crypto registry, redaction, and the
note-encryption data path only.

Schema + migrations:
- 0199 — User totp_secret_encrypted / totp_confirmed_at / mfa_enforced /
  passkey_upgrade_nudge_dismissed; Session mfa_verified_at / last_active_at;
  Passkey last_used_at. All additive, nullable / defaulted.
- 0200 — mfa_challenges (single-use step-up ticket; only the ticket hash is
  stored, so no session or token is ever minted before the second factor),
  mfa_recovery_codes (Argon2id at rest), webauthn_mfa_credentials (second-factor
  keys, kept separate from passkeys, which remain the passwordless-primary set).
- 0201 — mood_entries.note_encrypted + measurements.notes_encrypted (BYTEA),
  matching the existing free-text encrypted-note convention.

Free-text health notes:
- New writes encrypt the mood / measurement note into the *_encrypted column and
  null the legacy plaintext column; reads prefer the ciphertext and fall back to
  plaintext only for not-yet-migrated rows, via a single read/write helper.
- A boot-time job migrates existing plaintext rows in an idempotent,
  transactional, fail-closed pass (encrypt, then null the plaintext, per row in
  one transaction; safe to re-run; a row with content is never left both-null).
- The legacy plaintext columns are kept this release and dropped in a follow-up
  once the backfill reports zero remaining rows — the same boundary the
  key-rotation playbook uses before dropping a legacy key.

Crypto coverage:
- Register the three new encrypted columns and wire them into the key-rotation
  script so a rotation never orphans them.
- Extend the observability and log redaction denylists so TOTP secrets, MFA /
  recovery / backup codes, and export passphrases never reach a wide event.
2026-06-27 19:33:10 +02:00

159 lines
8.3 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;
}
/**
* 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" },
// 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" },
// ───── 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" },
// 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" },
] as const;
/** Stable `Model.field` key for a registry entry. */
export function encryptedColumnKey(c: EncryptedColumn): string {
return `${c.model}.${c.field}`;
}