From 5ea17398bc8f3f11109baa76420412deaf442efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:05:10 +0200 Subject: [PATCH] feat(security): encrypt medication free-text notes at rest + password-change MFA step-up Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext PHI columns left after the v1.23 note-encryption rollout. Add the three *Encrypted Bytes columns (migration 0208), register them in the encrypted- column rotation set, route every read/write through the note cipher, and add a transactional fail-closed boot-time backfill for legacy rows. Plaintext columns drop in a later release once the backfill reports zero remaining rows. Require a fresh second factor on password change for MFA-enrolled accounts, matching the existing step-up on MFA-disable and recovery-code regen. Redact free-text note values from the Zod validation excerpt that can reach a wide event. --- .../migration.sql | 35 ++ prisma/schema.prisma | 12 + scripts/rotate-encryption-key.ts | 27 ++ .../inventory-write-isolation.test.ts | 6 +- .../api/auth/password/__tests__/route.test.ts | 63 ++++ src/app/api/auth/password/route.ts | 13 +- src/app/api/dashboard/widgets/route.ts | 8 +- src/app/api/insights/glp1-timeline/route.ts | 3 +- src/app/api/insights/layout/route.ts | 6 +- src/app/api/measurements/series/route.ts | 6 +- src/app/api/medications/[id]/glp1/route.ts | 13 +- .../[id]/inventory/[itemId]/route.ts | 13 +- .../api/medications/[id]/inventory/route.ts | 12 +- .../[id]/side-effects/__tests__/route.test.ts | 43 ++- .../medications/[id]/side-effects/route.ts | 14 +- src/app/api/medications/layout/route.ts | 6 +- src/lib/ai/coach/glp1-snapshot.ts | 17 +- src/lib/crypto/__tests__/note-cipher.test.ts | 65 ++++ src/lib/crypto/encrypted-columns.ts | 7 + src/lib/crypto/note-cipher.ts | 41 +++ src/lib/doctor-report-data.ts | 3 +- .../med-notes-encryption-backfill.test.ts | 228 +++++++++++++ src/lib/jobs/med-notes-encryption-backfill.ts | 305 ++++++++++++++++++ src/lib/jobs/reminder/register-maintenance.ts | 97 ++++++ src/lib/medications/inventory/service.ts | 5 +- .../__tests__/redact-payload.test.ts | 58 ++++ src/lib/observability/redact-payload.ts | 65 +++- 27 files changed, 1111 insertions(+), 60 deletions(-) create mode 100644 prisma/migrations/0208_v125_med_notes_encryption/migration.sql create mode 100644 src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts create mode 100644 src/lib/jobs/med-notes-encryption-backfill.ts diff --git a/prisma/migrations/0208_v125_med_notes_encryption/migration.sql b/prisma/migrations/0208_v125_med_notes_encryption/migration.sql new file mode 100644 index 000000000..f711bdd0d --- /dev/null +++ b/prisma/migrations/0208_v125_med_notes_encryption/migration.sql @@ -0,0 +1,35 @@ +-- v1.25 — encrypt medication free-text notes at rest. +-- +-- Adds the AES-256-GCM ciphertext columns for the three medication +-- free-text fields that were still plaintext: the side-effect note +-- (`medication_side_effects.notes`), the dose-change titration note +-- (`medication_dose_changes.note`), and the inventory-item note +-- (`medication_inventory_items.notes`). These were the last plaintext +-- PHI columns left after the v1.23 note-encryption rollout. +-- +-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text +-- encrypted-note convention already in the schema (mood_entries.note_ +-- encrypted, measurements.notes_encrypted, illness_day_logs.note_encrypted, +-- labs.note_encrypted, coach_*.{...}_encrypted) — the `encrypt()` +-- ciphertext string stored UTF-8 as `bytea`, the shape the rotation +-- script's `rotateBytesColumn` already covers. All three columns are +-- registered in ENCRYPTED_COLUMNS + the rotation script in the same change. +-- +-- This migration is ADDITIVE ONLY. The legacy plaintext columns +-- (`medication_side_effects.notes`, `medication_dose_changes.note`, +-- `medication_inventory_items.notes`) are intentionally NOT dropped here: +-- an idempotent, transactional, fail-closed boot-time job encrypts the +-- existing rows (plaintext -> ciphertext, then nulls the plaintext) so the +-- read path can fall back to plaintext for not-yet-migrated rows. Dropping +-- the plaintext columns is a deliberate FOLLOW-UP release, once the backfill +-- reports zero remaining plaintext-but-no-ciphertext rows — the same +-- boundary the encryption-key-rotation playbook uses before dropping a +-- legacy key. +ALTER TABLE "medication_side_effects" + ADD COLUMN IF NOT EXISTS "notes_encrypted" BYTEA; + +ALTER TABLE "medication_dose_changes" + ADD COLUMN IF NOT EXISTS "note_encrypted" BYTEA; + +ALTER TABLE "medication_inventory_items" + ADD COLUMN IF NOT EXISTS "notes_encrypted" BYTEA; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5bbd4cb75..eb099fc3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2266,6 +2266,10 @@ model MedicationDoseChange { /// User-entered titration note ("stepped up after 4 weeks on 5 mg, /// tolerated 7.5 mg with mild nausea day 2"). note String? + /// v1.25 — AES-256-GCM ciphertext for `note`. The plaintext column + /// stays for read-fallback until the backfill reports zero remaining + /// rows; the plaintext drop is DEFERRED to a later release. + noteEncrypted Bytes? @map("note_encrypted") createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@ -2357,6 +2361,10 @@ model MedicationInventoryItem { purchasedAt DateTime? @map("purchased_at") /// Free-text note, application-layer max 200 chars. notes String? + /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column + /// stays for read-fallback until the backfill reports zero remaining + /// rows; the plaintext drop is DEFERRED to a later release. + notesEncrypted Bytes? @map("notes_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -2413,6 +2421,10 @@ model MedicationSideEffect { /// application layer. The DB column is unbounded TEXT for /// consistency with sibling "notes" columns on this schema. notes String? + /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column + /// stays for read-fallback until the backfill reports zero remaining + /// rows; the plaintext drop is DEFERRED to a later release. + notesEncrypted Bytes? @map("notes_encrypted") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 79e11d648..3663f3f56 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -421,6 +421,33 @@ async function main() { ), ); + // ───── v1.25 medication free-text notes (Bytes columns) ───── + // "notesEncrypted" (MedicationSideEffect + MedicationInventoryItem) + + // "noteEncrypted" (MedicationDoseChange). Same shared-codec shape as the + // other free-text note columns. NULL on rows whose note is still in the + // legacy plaintext column (pre-backfill) — `rotateBytesColumn` skips those. + results.push( + await rotateBytesColumn( + "MedicationSideEffect", + "notesEncrypted", + prisma.medicationSideEffect, + ), + ); + results.push( + await rotateBytesColumn( + "MedicationDoseChange", + "noteEncrypted", + prisma.medicationDoseChange, + ), + ); + results.push( + await rotateBytesColumn( + "MedicationInventoryItem", + "notesEncrypted", + prisma.medicationInventoryItem, + ), + ); + console.log("\n=== Rotation summary ==="); let totalRotated = 0; let totalErrors = 0; diff --git a/src/__tests__/inventory-write-isolation.test.ts b/src/__tests__/inventory-write-isolation.test.ts index 4c02bc53a..6e38fb16b 100644 --- a/src/__tests__/inventory-write-isolation.test.ts +++ b/src/__tests__/inventory-write-isolation.test.ts @@ -28,7 +28,10 @@ const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); * Files allowed to write `medicationInventoryItem` rows: * - the consumption module (intake-driven consume / restore); * - the inventory persistence helpers (expire cron `updateMany`); - * - the two inventory CRUD routes (register / mutate / delete). + * - the two inventory CRUD routes (register / mutate / delete); + * - the v1.25 note-encryption backfill, whose update only rewrites the + * `notes` / `notesEncrypted` columns (never units / state) and so cannot + * touch the consumption stamp this guard protects. */ const ALLOWED_WRITERS = new Set( [ @@ -36,6 +39,7 @@ const ALLOWED_WRITERS = new Set( "src/lib/medications/inventory/service.ts", "src/app/api/medications/[id]/inventory/route.ts", "src/app/api/medications/[id]/inventory/[itemId]/route.ts", + "src/lib/jobs/med-notes-encryption-backfill.ts", ].map((p) => p.split("/").join(sep)), ); diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts index 5f56f677e..f5fe53763 100644 --- a/src/app/api/auth/password/__tests__/route.test.ts +++ b/src/app/api/auth/password/__tests__/route.test.ts @@ -4,6 +4,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// v1.25 — password change requires a fresh MFA step-up for enrolled accounts. +// Spread the real module so `apiHandler` keeps working; override the step-up +// guard so each test drives the enrolled / fresh-factor outcome directly. +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + requireFreshMfaIfEnrolled: vi.fn(), + }; +}); + vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() }, @@ -36,6 +50,11 @@ vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn().mockResolvedValue("en"), })); +// Fail-open breach check: a null result (corpus unreachable) never blocks. +vi.mock("@/lib/auth/hibp", () => ({ + checkPasswordBreach: vi.fn().mockResolvedValue(null), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -50,8 +69,14 @@ vi.mock("next/headers", () => ({ })); import { POST } from "../route"; +import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password"; +import { + requireFreshMfaIfEnrolled, + StepUpRequiredError, +} from "@/lib/api-handler"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -78,6 +103,44 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + // Default: account clears the step-up (no MFA enrolled, or a fresh factor). + vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue(SESSION_OK as never); +}); + +describe("POST /api/auth/password — MFA step-up (v1.25)", () => { + it("blocks an MFA-enrolled account without a fresh second factor", async () => { + vi.mocked(requireFreshMfaIfEnrolled).mockRejectedValue( + new StepUpRequiredError(), + ); + const res = await POST( + postReq({ + currentPassword: "old-pass-123", + newPassword: "New-Pass-456!", + }), + ); + expect(res.status).toBe(401); + // The credential is never rotated when the step-up fails. + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("proceeds when the step-up is satisfied (fresh factor / no MFA)", async () => { + // vi.resetAllMocks() wiped the factory defaults; restore the success path. + vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(checkPasswordStrength).mockReturnValue({ + isAcceptable: true, + feedback: [], + } as never); + const res = await POST( + postReq({ + currentPassword: "old-pass-123", + newPassword: "New-Pass-456!", + confirmPassword: "New-Pass-456!", + }), + ); + expect(res.status).toBe(200); + expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number)); + expect(prisma.user.update).toHaveBeenCalled(); + }); }); describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => { diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 38cf11b42..3371b0a0c 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -15,7 +15,11 @@ import { safeJson, } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + requireFreshMfaIfEnrolled, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { destroyAllSessions, createSession } from "@/lib/auth/session"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -23,7 +27,12 @@ import { checkPasswordBreach } from "@/lib/auth/hibp"; import { getServerTranslator } from "@/lib/i18n/server-translator"; export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.25 — for an account with a second factor active a password change + // requires a fresh step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition + // to the current-password re-proof below, so a hijacked live session cannot + // rotate the credential. Accounts without MFA keep the current-password-only + // contract. Mirrors the step-up on MFA-disable + recovery-code regen. + const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); const rl = await checkRateLimit( `auth:password:${user.id}`, diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 591eae953..15121fc65 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -29,7 +29,7 @@ import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import type { NextRequest } from "next/server"; // Single source of truth — every widget id the layout accepts. The @@ -211,13 +211,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { // the full body — so PII / token-like fields cannot leak. The // diagnostic shape is built by the shared `buildPayloadDiagnostic` // helper (v1.4.49) so the widget + series routes can't drift; the - // body is routed through `redactSensitiveFields` first so any + // body is routed through `redactForExcerpt` first so any // future field matching the denylist (password / token / secret / // apiKey / authorization / csrfState / nonce) lands as the literal // `"[redacted]"` instead of its raw value. - const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(body), - ); + const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); annotate({ action: { name: "dashboard.widgets.validation-failed" }, meta: { diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index c5b8711d7..8992b233f 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -12,6 +12,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; +import { readNote } from "@/lib/crypto/note-cipher"; import { NextRequest } from "next/server"; const DEFAULT_LIMIT = 60; @@ -131,7 +132,7 @@ export const GET = apiHandler(async (request: NextRequest) => { doseValue: dc.doseValue, doseUnit: dc.doseUnit, doseDelta: delta, - note: dc.note, + note: readNote(dc.noteEncrypted, dc.note), }); } for (const intake of med.intakeEvents) { diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 88a242ece..5fb3c20ad 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -35,7 +35,7 @@ import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserInsightsLayout } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import { shouldEmitAuditRow } from "@/lib/audit-dedup"; import type { NextRequest } from "next/server"; @@ -129,9 +129,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { // round-trip; the wide-event line carries the redacted payload // shape for operator debugging without leaking the raw body. const issues = sanitiseZodIssues(parsed.error.issues); - const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(body), - ); + const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); annotate({ action: { name: "insights.layout.validation-failed" }, meta: { diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 23f16bc75..ca27049ec 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -20,7 +20,7 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { VALUE_RANGES } from "@/lib/validations/measurement"; @@ -158,11 +158,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // do not currently accept any but the truncation keeps that // invariant cheap) cannot leak. Shared helper via v1.4.49 so the // widget + series routes can't drift on the diagnostic shape; the - // query map is routed through `redactSensitiveFields` first so any + // query map is routed through `redactForExcerpt` first so any // future credential-shaped query string (`token`, `apiKey`, // `csrfState`) redacts to `"[redacted]"` before the truncate. const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(rawQuery), + redactForExcerpt(rawQuery), ); annotate({ action: { name: "measurements.series.validation-failed" }, diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index ab3de40cb..220c1d7b2 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -39,6 +39,11 @@ import { resolveReorderLeadDays, } from "@/lib/validations/notification-prefs"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { + encryptNote, + readNote, + shapeDoseChangeNote, +} from "@/lib/crypto/note-cipher"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -193,7 +198,7 @@ export const GET = apiHandler( effectiveFrom: dc.effectiveFrom.toISOString(), doseValue: dc.doseValue, doseUnit: dc.doseUnit, - note: dc.note, + note: readNote(dc.noteEncrypted, dc.note), })), recentIntakes: medication.intakeEvents.map((iv) => ({ takenAt: iv.takenAt ? iv.takenAt.toISOString() : null, @@ -261,7 +266,9 @@ export const POST = apiHandler( effectiveFrom, doseValue, doseUnit, - note: note ?? null, + // Encrypt the titration note at rest; the plaintext column stays null. + noteEncrypted: encryptNote(note), + note: null, }, }); @@ -291,7 +298,7 @@ export const POST = apiHandler( // buckets so the card reflects the new runway on the next read. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess({ doseChange: created }, 201); + return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201); } // The schema's XOR refinement guarantees `inventory` is present diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 2125b3c8f..df65ba109 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -37,6 +37,7 @@ import { } from "@/lib/medications/inventory/state-machine"; import { serializeInventoryItem } from "@/lib/medications/inventory/service"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { encryptNote, shapeInventoryItemNotes } from "@/lib/crypto/note-cipher"; type RouteParams = { params: Promise<{ id: string; itemId: string }> }; @@ -142,6 +143,14 @@ export const PATCH = apiHandler( Date.now(), ); + // Only touch the note columns when the caller supplied a value; an absent + // `notes` leaves the existing ciphertext untouched. When present, encrypt + // at rest and null the plaintext column. + const notesData = + notes === undefined + ? {} + : { notesEncrypted: encryptNote(notes), notes: null }; + const updated = await prisma.medicationInventoryItem.update({ where: { id: itemId }, data: { @@ -150,7 +159,7 @@ export const PATCH = apiHandler( unitsRemaining: nextUnitsRemaining, printedExpiry: nextPrintedExpiry, expiresAt: nextExpiresAt, - notes: notes === undefined ? existing.notes : notes, + ...notesData, }, }); @@ -180,7 +189,7 @@ export const PATCH = apiHandler( // read instead of after the `cachedSwr` stale window. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess(serializeInventoryItem(updated)); + return apiSuccess(serializeInventoryItem(shapeInventoryItemNotes(updated))); }, ); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index a5ce6d0f2..d553ddee7 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -35,6 +35,7 @@ import { } from "@/lib/medications/inventory/service"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { shapeInventoryItemNotes } from "@/lib/crypto/note-cipher"; type RouteParams = { params: Promise<{ id: string }> }; @@ -60,7 +61,11 @@ export const GET = apiHandler( }), ]); - const serialized = items.map(serializeInventoryItem); + // Decrypt each note and strip the raw `notesEncrypted` ciphertext before + // the decimal-unit serialisation runs. + const serialized = items.map((item) => + serializeInventoryItem(shapeInventoryItemNotes(item)), + ); // v1.19.0 (iOS#25) — the canonical supply summary is computed HERE, // server-side, through the one source of truth (`summariseSupply`). // The detail-page client renders these ready figures instead of @@ -162,6 +167,9 @@ export const POST = apiHandler( // serve the pre-write stock for the rest of the stale window. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess(serializeInventoryItem(created), 201); + return apiSuccess( + serializeInventoryItem(shapeInventoryItemNotes(created)), + 201, + ); }, ); diff --git a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts index edff25b8c..e30b8372a 100644 --- a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts @@ -57,6 +57,13 @@ import { DELETE } from "../[logId]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { readNote } from "@/lib/crypto/note-cipher"; + +// The POST path encrypts the free-text note at rest; drive the legacy single +// key so encryptNote has material to work with. +vi.stubEnv("ENCRYPTION_KEYS", ""); +vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); +vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64)); const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -233,17 +240,33 @@ describe("POST /api/medications/[id]/side-effects", () => { { params: Promise.resolve({ id: "med-1" }) }, ); expect(res.status).toBe(201); - expect(prisma.medicationSideEffect.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - userId: "user-1", - medicationId: "med-1", - category: "GI", - entry: "NAUSEA", - severity: 2, - occurredAt: new Date(occurredAt), - notes: "after breakfast", - }), + const createArgs = vi.mocked(prisma.medicationSideEffect.create).mock + .calls[0][0] as { + data: { + userId: string; + medicationId: string; + category: string; + entry: string; + severity: number; + occurredAt: Date; + notes: string | null; + notesEncrypted: Uint8Array | null; + }; + }; + expect(createArgs.data).toMatchObject({ + userId: "user-1", + medicationId: "med-1", + category: "GI", + entry: "NAUSEA", + severity: 2, + occurredAt: new Date(occurredAt), + // v1.25 — the plaintext column is nulled; the note lands encrypted. + notes: null, }); + expect(createArgs.data.notesEncrypted).toBeInstanceOf(Uint8Array); + expect(readNote(createArgs.data.notesEncrypted, null)).toBe( + "after breakfast", + ); }); it("defaults occurredAt to now when omitted", async () => { diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 899fa0067..5562e4873 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -36,6 +36,7 @@ import { } from "@/lib/medications/side-effects/validators"; import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher"; type RouteParams = { params: Promise<{ id: string }> }; @@ -85,7 +86,12 @@ export const GET = apiHandler( meta: { medication_id: id, total: items.length }, }); - return apiSuccess({ items, meta: { total: items.length } }); + // Decrypt each note and strip the raw `notesEncrypted` ciphertext so it + // never leaves the server. + return apiSuccess({ + items: items.map(shapeSideEffectNotes), + meta: { total: items.length }, + }); }, ); @@ -139,7 +145,9 @@ export const POST = apiHandler( entry, severity, occurredAt: occurredAt ?? new Date(), - notes: notes ?? null, + // Encrypt the free-text note at rest; the plaintext column stays null. + notesEncrypted: encryptNote(notes), + notes: null, }, }); @@ -163,6 +171,6 @@ export const POST = apiHandler( meta: { medication_id: id, entry, severity }, }); - return apiSuccess(created, 201); + return apiSuccess(shapeSideEffectNotes(created), 201); }, ); diff --git a/src/app/api/medications/layout/route.ts b/src/app/api/medications/layout/route.ts index 1fb39de3d..5f501a98d 100644 --- a/src/app/api/medications/layout/route.ts +++ b/src/app/api/medications/layout/route.ts @@ -34,7 +34,7 @@ import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserMedicationListLayout } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import { shouldEmitAuditRow } from "@/lib/audit-dedup"; import type { NextRequest } from "next/server"; @@ -92,9 +92,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { // layout routes; the wide-event line carries the redacted payload // shape for operator debugging without leaking the raw body. const issues = sanitiseZodIssues(parsed.error.issues); - const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(body), - ); + const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); annotate({ action: { name: "medication.layout.validation-failed" }, meta: { diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 312f0385e..316114f2a 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -20,6 +20,7 @@ */ import { prisma } from "@/lib/db"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; +import { readNote } from "@/lib/crypto/note-cipher"; /** * Recommended generic name for the canonical GLP-1 drug brand. The Coach @@ -325,12 +326,16 @@ export async function buildGlp1SnapshotBlock( const genericSafe = sanitizeForPrompt(generic, 80); const latestChange = med.doseChanges[med.doseChanges.length - 1] ?? null; - const doseHistory: DoseHistoryEntry[] = med.doseChanges.map((dc) => ({ - value: dc.doseValue, - unit: sanitizeForPrompt(dc.doseUnit, 20), - effectiveFrom: isoDate(dc.effectiveFrom), - note: dc.note === null ? null : sanitizeForPrompt(dc.note, 200), - })); + const doseHistory: DoseHistoryEntry[] = med.doseChanges.map((dc) => { + const decryptedNote = readNote(dc.noteEncrypted, dc.note); + return { + value: dc.doseValue, + unit: sanitizeForPrompt(dc.doseUnit, 20), + effectiveFrom: isoDate(dc.effectiveFrom), + note: + decryptedNote === null ? null : sanitizeForPrompt(decryptedNote, 200), + }; + }); const currentDose: CurrentDose | null = latestChange ? { diff --git a/src/lib/crypto/__tests__/note-cipher.test.ts b/src/lib/crypto/__tests__/note-cipher.test.ts index 8b5873ddb..1214701f0 100644 --- a/src/lib/crypto/__tests__/note-cipher.test.ts +++ b/src/lib/crypto/__tests__/note-cipher.test.ts @@ -4,6 +4,9 @@ import { readNote, shapeMeasurementNotes, shapeMoodNote, + shapeSideEffectNotes, + shapeInventoryItemNotes, + shapeDoseChangeNote, } from "../note-cipher"; // A 32-byte key as 64 hex chars (the legacy single-key path). @@ -102,3 +105,65 @@ describe("shapeMeasurementNotes / shapeMoodNote", () => { expect(shaped.notes).toBe("legacy plaintext"); }); }); + +describe("v1.25 medication note shapers", () => { + it("shapeSideEffectNotes decrypts onto `notes` and strips the ciphertext", () => { + const notesEncrypted = encryptNote("nausea on day 2"); + const row = { + id: "se1", + entry: "NAUSEA", + severity: 3, + notes: null, + notesEncrypted, + }; + const shaped = shapeSideEffectNotes(row); + expect(shaped.notes).toBe("nausea on day 2"); + expect("notesEncrypted" in shaped).toBe(false); + expect(shaped.entry).toBe("NAUSEA"); + expect(shaped.severity).toBe(3); + }); + + it("shapeInventoryItemNotes decrypts onto `notes` and strips the ciphertext", () => { + const notesEncrypted = encryptNote("opened pen #2"); + const row = { id: "i1", state: "IN_USE", notes: null, notesEncrypted }; + const shaped = shapeInventoryItemNotes(row); + expect(shaped.notes).toBe("opened pen #2"); + expect("notesEncrypted" in shaped).toBe(false); + expect(shaped.state).toBe("IN_USE"); + }); + + it("shapeDoseChangeNote decrypts onto `note` and strips the ciphertext", () => { + const noteEncrypted = encryptNote("stepped up to 7.5 mg"); + const row = { id: "dc1", doseValue: 7.5, note: null, noteEncrypted }; + const shaped = shapeDoseChangeNote(row); + expect(shaped.note).toBe("stepped up to 7.5 mg"); + expect("noteEncrypted" in shaped).toBe(false); + expect(shaped.doseValue).toBe(7.5); + }); + + it("falls back to the legacy plaintext for not-yet-backfilled rows", () => { + expect( + shapeSideEffectNotes({ id: "se2", notes: "legacy", notesEncrypted: null }) + .notes, + ).toBe("legacy"); + expect( + shapeInventoryItemNotes({ + id: "i2", + notes: "legacy", + notesEncrypted: null, + }).notes, + ).toBe("legacy"); + expect( + shapeDoseChangeNote({ id: "dc2", note: "legacy", noteEncrypted: null }) + .note, + ).toBe("legacy"); + }); + + it("is fail-closed: a side-effect ciphertext under a rotated key throws", () => { + const notesEncrypted = encryptNote("secret note"); + vi.stubEnv("ENCRYPTION_KEY", KEY_B); + expect(() => + shapeSideEffectNotes({ id: "se3", notes: null, notesEncrypted }), + ).toThrow(); + }); +}); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index dfad22edc..4b0c91191 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -149,6 +149,13 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // 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" }, ] as const; /** Stable `Model.field` key for a registry entry. */ diff --git a/src/lib/crypto/note-cipher.ts b/src/lib/crypto/note-cipher.ts index f768e9a68..dc63001dd 100644 --- a/src/lib/crypto/note-cipher.ts +++ b/src/lib/crypto/note-cipher.ts @@ -7,6 +7,13 @@ * encrypt-on-write + prefer-ciphertext-fallback-plaintext contract stays * consistent. * + * v1.25 extends the same boundary to the three medication free-text columns — + * `MedicationSideEffect.notes` -> `notesEncrypted`, + * `MedicationDoseChange.note` -> `noteEncrypted`, and + * `MedicationInventoryItem.notes` -> `notesEncrypted` — the last plaintext PHI + * columns left after the v1.23 rollout. They reuse `encryptNote` / `readNote` + * unchanged and get their own row-shapers below. + * * Storage shape is the shared Bytes codec (`@/lib/ai/coach/bytes-codec`) used * by every other free-text encrypted-note column (IllnessDayLog, LabResult, * Coach memory) — the `encrypt()` ciphertext string stored UTF-8 as `bytea`. @@ -73,3 +80,37 @@ export function shapeMoodNote< const { noteEncrypted, ...rest } = row; return { ...rest, note: readNote(noteEncrypted, rest.note) }; } + +/** + * v1.25 — shape a medication side-effect row for a response/export: surface + * the decrypted note on `notes` and strip the raw `notesEncrypted` ciphertext + * so it never leaves the server. + */ +export function shapeSideEffectNotes< + T extends { notes: string | null; notesEncrypted: Uint8Array | null }, +>(row: T): Omit & { notes: string | null } { + const { notesEncrypted, ...rest } = row; + return { ...rest, notes: readNote(notesEncrypted, rest.notes) }; +} + +/** + * v1.25 — shape a medication inventory-item row for a response/export: surface + * the decrypted note on `notes` and strip the raw `notesEncrypted` ciphertext. + */ +export function shapeInventoryItemNotes< + T extends { notes: string | null; notesEncrypted: Uint8Array | null }, +>(row: T): Omit & { notes: string | null } { + const { notesEncrypted, ...rest } = row; + return { ...rest, notes: readNote(notesEncrypted, rest.notes) }; +} + +/** + * v1.25 — shape a medication dose-change row for a response/export: surface the + * decrypted note on `note` and strip the raw `noteEncrypted` ciphertext. + */ +export function shapeDoseChangeNote< + T extends { note: string | null; noteEncrypted: Uint8Array | null }, +>(row: T): Omit & { note: string | null } { + const { noteEncrypted, ...rest } = row; + return { ...rest, note: readNote(noteEncrypted, rest.note) }; +} diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index bc2fc0702..fda353ad3 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -28,6 +28,7 @@ import type { MeasurementType, } from "@/generated/prisma/client"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { readNote } from "@/lib/crypto/note-cipher"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; import { userDayKey } from "@/lib/tz/resolver"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; @@ -1244,7 +1245,7 @@ export async function collectDoctorReportData( value: dc.doseValue, unit: dc.doseUnit, effectiveFrom: dc.effectiveFrom.toISOString(), - note: dc.note, + note: readNote(dc.noteEncrypted, dc.note), })), lastInjection: lastIntake && lastIntake.takenAt diff --git a/src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts b/src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts new file mode 100644 index 000000000..23f5a9119 --- /dev/null +++ b/src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { readNote } from "@/lib/crypto/note-cipher"; + +// ── in-memory store backing a minimal prisma mock ────────────────────────── +interface SideEffectRow { + id: string; + userId: string; + notes: string | null; + notesEncrypted: Uint8Array | null; +} +interface DoseChangeRow { + id: string; + // Dose-changes carry no userId column — ownership is the parent medication's. + medicationUserId: string; + note: string | null; + noteEncrypted: Uint8Array | null; +} +interface InventoryRow { + id: string; + userId: string; + notes: string | null; + notesEncrypted: Uint8Array | null; +} + +const store = vi.hoisted(() => ({ + sideEffects: [] as SideEffectRow[], + doseChanges: [] as DoseChangeRow[], + inventory: [] as InventoryRow[], +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/db", () => { + const delegates = { + medicationSideEffect: { + findMany: async (args: { where: { userId: string }; take: number }) => + store.sideEffects + .filter( + (r) => + r.userId === args.where.userId && + r.notes !== null && + r.notesEncrypted === null, + ) + .slice(0, args.take) + .map((r) => ({ id: r.id })), + findUnique: async (args: { where: { id: string } }) => { + const r = store.sideEffects.find((x) => x.id === args.where.id); + return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null; + }, + update: async (args: { + where: { id: string }; + data: { notes: string | null; notesEncrypted: Uint8Array | null }; + }) => { + const r = store.sideEffects.find((x) => x.id === args.where.id)!; + r.notes = args.data.notes; + r.notesEncrypted = args.data.notesEncrypted; + return r; + }, + }, + medicationDoseChange: { + findMany: async (args: { + where: { medication: { userId: string } }; + take: number; + }) => + store.doseChanges + .filter( + (r) => + r.medicationUserId === args.where.medication.userId && + r.note !== null && + r.noteEncrypted === null, + ) + .slice(0, args.take) + .map((r) => ({ id: r.id })), + findUnique: async (args: { where: { id: string } }) => { + const r = store.doseChanges.find((x) => x.id === args.where.id); + return r ? { note: r.note, noteEncrypted: r.noteEncrypted } : null; + }, + update: async (args: { + where: { id: string }; + data: { note: string | null; noteEncrypted: Uint8Array | null }; + }) => { + const r = store.doseChanges.find((x) => x.id === args.where.id)!; + r.note = args.data.note; + r.noteEncrypted = args.data.noteEncrypted; + return r; + }, + }, + medicationInventoryItem: { + findMany: async (args: { where: { userId: string }; take: number }) => + store.inventory + .filter( + (r) => + r.userId === args.where.userId && + r.notes !== null && + r.notesEncrypted === null, + ) + .slice(0, args.take) + .map((r) => ({ id: r.id })), + findUnique: async (args: { where: { id: string } }) => { + const r = store.inventory.find((x) => x.id === args.where.id); + return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null; + }, + update: async (args: { + where: { id: string }; + data: { notes: string | null; notesEncrypted: Uint8Array | null }; + }) => { + const r = store.inventory.find((x) => x.id === args.where.id)!; + r.notes = args.data.notes; + r.notesEncrypted = args.data.notesEncrypted; + return r; + }, + }, + }; + const prisma = { + ...delegates, + $transaction: async (fn: (tx: typeof delegates) => unknown) => + fn(delegates), + }; + return { prisma }; +}); + +import { runMedNotesEncryptionBackfillForUser } from "@/lib/jobs/med-notes-encryption-backfill"; + +const KEY = "a".repeat(64); + +beforeEach(() => { + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", KEY); + store.sideEffects = [ + { id: "se1", userId: "u1", notes: "felt dizzy", notesEncrypted: null }, + { id: "se2", userId: "u1", notes: null, notesEncrypted: null }, // no note + { id: "se3", userId: "u2", notes: "other user", notesEncrypted: null }, + ]; + store.doseChanges = [ + { + id: "dc1", + medicationUserId: "u1", + note: "stepped up to 7.5mg", + noteEncrypted: null, + }, + { + id: "dc2", + medicationUserId: "u2", + note: "other user dose note", + noteEncrypted: null, + }, + ]; + store.inventory = [ + { id: "i1", userId: "u1", notes: "opened pen #2", notesEncrypted: null }, + { + id: "i2", + userId: "u1", + notes: null, + notesEncrypted: new Uint8Array(0), // already migrated-ish; left alone + }, + ]; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("runMedNotesEncryptionBackfillForUser", () => { + it("encrypts plaintext notes, nulls the plaintext, and round-trips each table", async () => { + const summary = await runMedNotesEncryptionBackfillForUser("u1"); + + expect(summary.sideEffectsMigrated).toBe(1); + expect(summary.doseChangesMigrated).toBe(1); + expect(summary.inventoryItemsMigrated).toBe(1); + + const se1 = store.sideEffects.find((r) => r.id === "se1")!; + expect(se1.notes).toBeNull(); + expect(readNote(se1.notesEncrypted, null)).toBe("felt dizzy"); + + const dc1 = store.doseChanges.find((r) => r.id === "dc1")!; + expect(dc1.note).toBeNull(); + expect(readNote(dc1.noteEncrypted, null)).toBe("stepped up to 7.5mg"); + + const i1 = store.inventory.find((r) => r.id === "i1")!; + expect(i1.notes).toBeNull(); + expect(readNote(i1.notesEncrypted, null)).toBe("opened pen #2"); + }); + + it("leaves note-less rows untouched and never writes a both-null content row", async () => { + await runMedNotesEncryptionBackfillForUser("u1"); + const se2 = store.sideEffects.find((r) => r.id === "se2")!; + expect(se2.notes).toBeNull(); + expect(se2.notesEncrypted).toBeNull(); + }); + + it("does not touch another user's rows (incl. dose-changes via the parent medication)", async () => { + await runMedNotesEncryptionBackfillForUser("u1"); + const se3 = store.sideEffects.find((r) => r.id === "se3")!; + expect(se3.notes).toBe("other user"); + expect(se3.notesEncrypted).toBeNull(); + const dc2 = store.doseChanges.find((r) => r.id === "dc2")!; + expect(dc2.note).toBe("other user dose note"); + expect(dc2.noteEncrypted).toBeNull(); + }); + + it("is idempotent: a second run migrates zero rows and keeps the ciphertext", async () => { + await runMedNotesEncryptionBackfillForUser("u1"); + const se1First = store.sideEffects.find( + (r) => r.id === "se1", + )!.notesEncrypted; + + const second = await runMedNotesEncryptionBackfillForUser("u1"); + expect(second.sideEffectsMigrated).toBe(0); + expect(second.doseChangesMigrated).toBe(0); + expect(second.inventoryItemsMigrated).toBe(0); + + const se1 = store.sideEffects.find((r) => r.id === "se1")!; + expect(se1.notesEncrypted).toBe(se1First); + expect(se1.notes).toBeNull(); + expect(readNote(se1.notesEncrypted, null)).toBe("felt dizzy"); + }); + + it("is fail-closed: a key error leaves every plaintext row intact (no data loss)", async () => { + vi.stubEnv("ENCRYPTION_KEY", ""); + vi.stubEnv("ENCRYPTION_KEYS", ""); + await expect(runMedNotesEncryptionBackfillForUser("u1")).rejects.toThrow(); + const se1 = store.sideEffects.find((r) => r.id === "se1")!; + expect(se1.notes).toBe("felt dizzy"); + expect(se1.notesEncrypted).toBeNull(); + }); +}); diff --git a/src/lib/jobs/med-notes-encryption-backfill.ts b/src/lib/jobs/med-notes-encryption-backfill.ts new file mode 100644 index 000000000..8e8bdb047 --- /dev/null +++ b/src/lib/jobs/med-notes-encryption-backfill.ts @@ -0,0 +1,305 @@ +/** + * v1.25 — boot-time converging backfill that migrates the three medication + * free-text note columns from plaintext to AES-256-GCM at rest: + * `MedicationSideEffect.notes` -> `notesEncrypted`, + * `MedicationDoseChange.note` -> `noteEncrypted`, and + * `MedicationInventoryItem.notes` -> `notesEncrypted`. These are the last + * plaintext PHI columns left after the v1.23 note-encryption rollout. + * + * Modelled on `note-encryption-backfill.ts`: a discovery query enqueues one + * job per user still holding an un-migrated row, the per-user handler walks + * that user's rows, and the pass is idempotent across reboots — once a row is + * migrated it drops off the discovery + candidate sets. + * + * OWNERSHIP: `MedicationSideEffect` and `MedicationInventoryItem` carry a + * direct `userId` column, so they page/discover on `userId` directly. + * `MedicationDoseChange` has no `userId` — it hangs off a parent `Medication` + * — so its candidate + discovery queries join through `medication: { userId }`. + * + * DATA-LOSS SAFETY (the property the security review must confirm): + * - Per row, the encrypt-then-null happens in a SINGLE interactive + * transaction guarded by a re-read inside the tx, so the plaintext column is + * only nulled AFTER the ciphertext is committed in the same atomic unit. A + * row that had content is never left both-null. + * - FAIL-CLOSED: `encryptNote` throws on a missing / malformed key. The throw + * aborts the transaction (the plaintext row is untouched) and propagates so + * pg-boss retries — the backfill never silently drops a note or writes a + * plaintext-shaped value into the ciphertext column. + * - IDEMPOTENT: the per-row guard (`*Encrypted IS NULL AND * != null`) is + * re-checked inside the tx, so a re-run — or two workers racing — migrates a + * row at most once; a second pass migrates zero rows. + * + * The legacy plaintext columns are intentionally NOT dropped in this release. + * That is a deliberate FOLLOW-UP release boundary, once this backfill reports + * zero remaining un-migrated rows on every instance — the same discipline the + * encryption-key-rotation playbook uses before dropping a legacy key. + * + * The queue name MUST be registered in the maintenance registrar + * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it at + * boot; an unregistered queue silently never drains. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { encryptNote } from "@/lib/crypto/note-cipher"; + +export const MED_NOTES_ENCRYPTION_BACKFILL_QUEUE = + "med-notes-encryption-backfill"; + +/** + * Serial concurrency — the populator walks a user's plaintext-note rows and + * writes one transaction per row; concurrency-1 keeps it off the request pool, + * matching the v1.23 note-encryption-backfill convention. + */ +export const MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY = 1; + +/** How many candidate rows to pull per page within a user's pass. */ +const PAGE_SIZE = 200; + +export interface MedNotesEncryptionBackfillPayload { + /** Absent on the daily discovery tick; present for a per-user job. */ + userId?: string; + enqueuedAt?: string; +} + +export interface MedNotesEncryptionBackfillSummary { + sideEffectsMigrated: number; + doseChangesMigrated: number; + inventoryItemsMigrated: number; +} + +/** + * Migrate one side-effect row's plaintext `notes` into `notesEncrypted` and + * null the plaintext — atomically and idempotently. Returns true if it + * migrated the row, false if a concurrent pass already did. + */ +async function migrateSideEffectRow(id: string): Promise { + return prisma.$transaction(async (tx) => { + const fresh = await tx.medicationSideEffect.findUnique({ + where: { id }, + select: { notes: true, notesEncrypted: true }, + }); + // Already migrated (ciphertext present) or nothing to migrate. + if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false; + // FAIL-CLOSED: a key error throws here and rolls the tx back, leaving the + // plaintext row untouched. + const encrypted = encryptNote(fresh.notes); + await tx.medicationSideEffect.update({ + where: { id }, + data: { notesEncrypted: encrypted, notes: null }, + }); + return true; + }); +} + +/** + * Migrate one dose-change row's plaintext `note` into `noteEncrypted` and null + * the plaintext — atomically and idempotently. + */ +async function migrateDoseChangeRow(id: string): Promise { + return prisma.$transaction(async (tx) => { + const fresh = await tx.medicationDoseChange.findUnique({ + where: { id }, + select: { note: true, noteEncrypted: true }, + }); + if (!fresh || fresh.noteEncrypted || fresh.note === null) return false; + const encrypted = encryptNote(fresh.note); + await tx.medicationDoseChange.update({ + where: { id }, + data: { noteEncrypted: encrypted, note: null }, + }); + return true; + }); +} + +/** + * Migrate one inventory-item row's plaintext `notes` into `notesEncrypted` and + * null the plaintext — atomically and idempotently. + */ +async function migrateInventoryItemRow(id: string): Promise { + return prisma.$transaction(async (tx) => { + const fresh = await tx.medicationInventoryItem.findUnique({ + where: { id }, + select: { notes: true, notesEncrypted: true }, + }); + if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false; + const encrypted = encryptNote(fresh.notes); + await tx.medicationInventoryItem.update({ + where: { id }, + data: { notesEncrypted: encrypted, notes: null }, + }); + return true; + }); +} + +/** + * Per-user queue handler. Walks every un-migrated medication note for one + * account across all three tables and migrates each in its own transaction. + * Safe to re-run. Dose-changes carry no `userId`, so they are scoped through + * the parent medication's owner. + */ +export async function runMedNotesEncryptionBackfillForUser( + userId: string, +): Promise { + let sideEffectsMigrated = 0; + let doseChangesMigrated = 0; + let inventoryItemsMigrated = 0; + + // Side-effects — direct userId. Each migration nulls `notes`, so a migrated + // row drops out of the next `notes: { not: null }, notesEncrypted: null` page. + for (;;) { + const rows = await prisma.medicationSideEffect.findMany({ + where: { userId, notes: { not: null }, notesEncrypted: null }, + select: { id: true }, + take: PAGE_SIZE, + }); + if (rows.length === 0) break; + let migratedThisPage = 0; + for (const { id } of rows) { + if (await migrateSideEffectRow(id)) { + sideEffectsMigrated += 1; + migratedThisPage += 1; + } + } + // Guard against an unexpected non-converging page (every row already + // migrated by a racing worker) so the loop always terminates. + if (migratedThisPage === 0) break; + } + + // Dose-changes — no userId column; scope through the parent medication owner. + for (;;) { + const rows = await prisma.medicationDoseChange.findMany({ + where: { + medication: { userId }, + note: { not: null }, + noteEncrypted: null, + }, + select: { id: true }, + take: PAGE_SIZE, + }); + if (rows.length === 0) break; + let migratedThisPage = 0; + for (const { id } of rows) { + if (await migrateDoseChangeRow(id)) { + doseChangesMigrated += 1; + migratedThisPage += 1; + } + } + if (migratedThisPage === 0) break; + } + + // Inventory items — direct userId. + for (;;) { + const rows = await prisma.medicationInventoryItem.findMany({ + where: { userId, notes: { not: null }, notesEncrypted: null }, + select: { id: true }, + take: PAGE_SIZE, + }); + if (rows.length === 0) break; + let migratedThisPage = 0; + for (const { id } of rows) { + if (await migrateInventoryItemRow(id)) { + inventoryItemsMigrated += 1; + migratedThisPage += 1; + } + } + if (migratedThisPage === 0) break; + } + + annotate({ + action: { + name: "med.notes.encryption.backfill", + details: { + side_effects_migrated: sideEffectsMigrated, + dose_changes_migrated: doseChangesMigrated, + inventory_items_migrated: inventoryItemsMigrated, + }, + }, + }); + + return { sideEffectsMigrated, doseChangesMigrated, inventoryItemsMigrated }; +} + +/** + * Boot-time discovery. Finds every user holding at least one medication note + * still in the legacy plaintext column (no ciphertext yet) across any of the + * three tables and enqueues one backfill job per account. + * + * Idempotent across reboots: once a user's rows are migrated, the + * `*Encrypted IS NULL AND * != null` predicate drops them from the discovery + * set. pg-boss `singletonKey` coalesces duplicate sends. Best-effort: errors + * come back through the result value so worker boot never fails on a miss. + */ +export async function enqueueBootTimeMedNotesEncryptionBackfill(): Promise<{ + enqueued: number; + skipped: number; + error: string | null; +}> { + const boss = getGlobalBoss(); + if (!boss) { + return { enqueued: 0, skipped: 0, error: null }; + } + + try { + const [sideEffectUsers, doseChangeRows, inventoryUsers] = await Promise.all( + [ + prisma.medicationSideEffect.findMany({ + where: { notes: { not: null }, notesEncrypted: null }, + select: { userId: true }, + distinct: ["userId"], + }), + // Dose-changes carry no userId — surface the parent medication's owner + // and dedupe in JS (distinct cannot span a relation field). + prisma.medicationDoseChange.findMany({ + where: { note: { not: null }, noteEncrypted: null }, + select: { medication: { select: { userId: true } } }, + }), + prisma.medicationInventoryItem.findMany({ + where: { notes: { not: null }, notesEncrypted: null }, + select: { userId: true }, + distinct: ["userId"], + }), + ], + ); + + const userIds = new Set(); + for (const { userId } of sideEffectUsers) userIds.add(userId); + for (const { medication } of doseChangeRows) userIds.add(medication.userId); + for (const { userId } of inventoryUsers) userIds.add(userId); + + if (userIds.size === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + let enqueued = 0; + let skipped = 0; + for (const userId of userIds) { + const payload: MedNotesEncryptionBackfillPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send( + MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, + payload, + { + retryLimit: 5, + retryDelay: 60, + retryBackoff: true, + singletonKey: `med-notes-encryption-backfill|${userId}`, + }, + ); + if (jobId) { + enqueued += 1; + } else { + skipped += 1; + } + } + return { enqueued, skipped, error: null }; + } catch (err) { + return { + enqueued: 0, + skipped: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index ac11b6eec..33ad4e751 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -62,6 +62,13 @@ import { enqueueBootTimeNoteEncryptionBackfill, type NoteEncryptionBackfillPayload, } from "@/lib/jobs/note-encryption-backfill"; +import { + MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, + MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY, + runMedNotesEncryptionBackfillForUser, + enqueueBootTimeMedNotesEncryptionBackfill, + type MedNotesEncryptionBackfillPayload, +} from "@/lib/jobs/med-notes-encryption-backfill"; import { ENCRYPTION_KEY_ROTATE_QUEUE, ENCRYPTION_KEY_ROTATE_CONCURRENCY, @@ -198,6 +205,15 @@ const NOTE_ENCRYPTION_BACKFILL_CRON = "55 3 * * *"; const MCP_TOKEN_CLEANUP_QUEUE = "mcp-token-cleanup"; const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *"; +// v1.25 — converging backfill that migrates the three medication free-text +// note columns (side-effect note, dose-change note, inventory-item note) from +// plaintext to AES-256-GCM at rest. Boot discovery enqueues one per-user job; +// a daily 04:05 Europe/Berlin discovery tick (empty payload) re-fans for any +// rows that landed between worker reboots. Idempotent + fail-closed per row. +// Slots after the MCP-token prune (04:00) so the two don't pile up on the same +// boss poll. + +const MED_NOTES_ENCRYPTION_BACKFILL_CRON = "5 4 * * *"; const allQueues = [ DATA_BACKUP_QUEUE, @@ -253,6 +269,11 @@ const allQueues = [ // entry the daily schedule silently no-ops and the api_tokens table grows // unbounded with dead 60-minute connector rows. MCP_TOKEN_CLEANUP_QUEUE, + // v1.25 — medication free-text note encryption backfill. Boot discovery + a + // daily discovery cron enqueue one per-user job per account still holding a + // plaintext medication note; without this entry pg-boss never provisions the + // queue and both the boot enqueue and the cron silently no-op. + MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -310,6 +331,11 @@ const schedules: ScheduleEntry[] = [ [NOTE_ENCRYPTION_BACKFILL_QUEUE, NOTE_ENCRYPTION_BACKFILL_CRON], // v1.24 — daily 04:00 Europe/Berlin prune for dead MCP connector tokens. [MCP_TOKEN_CLEANUP_QUEUE, MCP_TOKEN_CLEANUP_CRON], + // v1.25 — daily 04:05 Europe/Berlin medication-note encryption backfill + // discovery. The empty cron payload (no `userId`) is the handler's signal to + // fan out one per-user job per account still holding a plaintext medication + // note. + [MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, MED_NOTES_ENCRYPTION_BACKFILL_CRON], ]; /** @@ -531,6 +557,50 @@ export async function registerMaintenanceQueues( handleEncryptionKeyRotate, ); + // v1.25 — medication-note encryption backfill worker. The boot enqueue + // helper (and the daily 04:05 discovery tick) send one job per user still + // holding a plaintext medication note; this handler migrates that user's + // rows across all three tables to the encrypted columns. An empty-userId + // payload is the daily discovery tick and re-fans the per-user jobs + // (singletonKey-coalesced, identical to the boot pass). Serial concurrency + // so the migration never crowds the request pool. + await boss.work>( + MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, + { localConcurrency: MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + const result = await enqueueBootTimeMedNotesEncryptionBackfill(); + workerLog( + "info", + `[med-notes-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, + ); + continue; + } + try { + const { + sideEffectsMigrated, + doseChangesMigrated, + inventoryItemsMigrated, + } = await runMedNotesEncryptionBackfillForUser(userId); + workerLog( + "info", + `[med-notes-encryption-backfill] user=${userId} sideEffects=${sideEffectsMigrated} doseChanges=${doseChangesMigrated} inventoryItems=${inventoryItemsMigrated}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[med-notes-encryption-backfill] user=${userId} failed`, + err, + ); + throw err; + } + } + }, + ); + return allQueues; } @@ -586,4 +656,31 @@ export async function enqueueMaintenanceBootDiscovery(): Promise { err, ); } + + // v1.25 — medication free-text note encryption backfill. Finds every user + // still holding a plaintext side-effect / dose-change / inventory-item note + // (no ciphertext yet) and enqueues one migration job per account. Idempotent + // across reboots: a migrated row nulls its plaintext column and drops off the + // discovery predicate. + try { + const { enqueued, skipped, error } = + await enqueueBootTimeMedNotesEncryptionBackfill(); + if (error) { + workerLog( + "error", + `[med-notes-encryption-backfill] boot discovery failed: ${error}`, + ); + } else { + workerLog( + "info", + `[med-notes-encryption-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[med-notes-encryption-backfill] boot discovery threw an unexpected error", + err, + ); + } } diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts index f317b9551..13b4c4919 100644 --- a/src/lib/medications/inventory/service.ts +++ b/src/lib/medications/inventory/service.ts @@ -13,6 +13,7 @@ import { prisma } from "@/lib/db"; import type { MedicationContainerType } from "@/generated/prisma/client"; +import { encryptNote } from "@/lib/crypto/note-cipher"; import { computeExpiresAt } from "./state-machine"; import { summariseSupply, @@ -156,6 +157,8 @@ export function buildCreateInventoryInput(input: { printedExpiry: input.printedExpiry, purchasedAt: input.purchasedAt, expiresAt: computeExpiresAt(null, input.printedExpiry), - notes: input.notes, + // Encrypt the free-text note at rest; the plaintext column stays null. + notesEncrypted: encryptNote(input.notes), + notes: null, }; } diff --git a/src/lib/observability/__tests__/redact-payload.test.ts b/src/lib/observability/__tests__/redact-payload.test.ts index 0f9491efb..2e9c7728a 100644 --- a/src/lib/observability/__tests__/redact-payload.test.ts +++ b/src/lib/observability/__tests__/redact-payload.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from "vitest"; import { redactSensitiveFields, + redactForExcerpt, SENSITIVE_KEY_PATTERNS, } from "../redact-payload"; +import { buildPayloadDiagnostic } from "@/lib/api-response"; // v1.4.49 — pinned redactor for the wide-event `received_shape_excerpt` // surface introduced in v1.4.48 H-iOS-1 / H-iOS-2. The helper must @@ -178,3 +180,59 @@ describe("redactSensitiveFields", () => { }); }); }); + +describe("redactForExcerpt (v1.25 — note/notes excerpt-only redaction)", () => { + it("redacts a free-text `notes` value the generic denylist deliberately keeps", () => { + const body = { + entry: "NAUSEA", + severity: 3, + notes: "felt dizzy after the 7.5 mg step-up — Nürnberg", + }; + // The generic denylist leaves note/notes verbatim (too generic to add). + expect(redactSensitiveFields(body)).toEqual(body); + // The excerpt path redacts the value so it cannot reach a wide event. + expect(redactForExcerpt(body)).toEqual({ + entry: "NAUSEA", + severity: 3, + notes: "[redacted]", + }); + }); + + it("redacts a singular `note` key and recurses into nested objects + arrays", () => { + const body = { + doseChange: { doseValue: 7.5, note: "titration note" }, + logs: [ + { id: "1", note: "a" }, + { id: "2", note: "b" }, + ], + }; + expect(redactForExcerpt(body)).toEqual({ + doseChange: { doseValue: 7.5, note: "[redacted]" }, + logs: [ + { id: "1", note: "[redacted]" }, + { id: "2", note: "[redacted]" }, + ], + }); + }); + + it("does not over-redact note-adjacent keys (noteId / footnotes stay)", () => { + const body = { noteId: "abc", footnotes: "kept", noteCount: 3 }; + expect(redactForExcerpt(body)).toEqual(body); + }); + + it("still redacts the global sensitive keys on the excerpt path", () => { + const body = { note: "secret", token: "abc", keep: 1 }; + expect(redactForExcerpt(body)).toEqual({ + note: "[redacted]", + token: "[redacted]", + keep: 1, + }); + }); + + it("keeps a free-text note value out of the wide-event excerpt end to end", () => { + const body = { notes: "a very private free-text health note value" }; + const diag = buildPayloadDiagnostic(redactForExcerpt(body)); + expect(diag.received_shape_excerpt).not.toContain("private free-text"); + expect(diag.received_shape_excerpt).toContain("[redacted]"); + }); +}); diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts index ef66fff89..327ad6d68 100644 --- a/src/lib/observability/redact-payload.ts +++ b/src/lib/observability/redact-payload.ts @@ -98,6 +98,18 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [ /verifier/i, ]; +/** + * v1.25 — keys whose VALUE must be kept out of a wide-event excerpt but which + * are too generic to live on the global `SENSITIVE_KEY_PATTERNS` denylist: + * redacting every `note` / `notes` everywhere `redactSensitiveFields` runs + * would over-redact unrelated observability. These apply ONLY on the + * excerpt path via {@link redactForExcerpt}, where a verbatim free-text health + * note (now encrypted at rest) must never land in `received_shape_excerpt`. + * Anchored so only the exact `note` / `notes` keys match — not, say, `noteId` + * or `footnotes`. + */ +export const EXCERPT_ONLY_KEY_PATTERNS: readonly RegExp[] = [/^notes?$/i]; + const REDACTED = "[redacted]"; function keyIsSensitive(key: string): boolean { @@ -107,20 +119,28 @@ function keyIsSensitive(key: string): boolean { return false; } +function keyIsExcerptSensitive(key: string): boolean { + for (const re of EXCERPT_ONLY_KEY_PATTERNS) { + if (re.test(key)) return true; + } + return false; +} + /** - * Returns a deep clone of `body` with any key matching the sensitive - * denylist replaced by the literal `"[redacted]"`. Arrays + plain - * objects are recursed into; scalars, `null`, `undefined`, `Date` and - * other non-plain objects pass through unchanged. - * - * The clone is intentionally shallow on non-plain-object boundaries so - * the helper stays cheap on the hot 422 path; the only goal is to keep - * the credentialed key's value out of the eventual JSON excerpt. + * Deep clone of `body` with any key matching `isSensitive` replaced by the + * literal `"[redacted]"`. Arrays + plain objects are recursed into; scalars, + * `null`, `undefined`, `Date` and other non-plain objects pass through + * unchanged. The clone is intentionally shallow on non-plain-object boundaries + * so the helper stays cheap on the hot 422 path; the only goal is to keep the + * matched key's value out of the eventual JSON excerpt. */ -export function redactSensitiveFields(body: unknown): unknown { +function redactWith( + body: unknown, + isSensitive: (key: string) => boolean, +): unknown { if (body === null || body === undefined) return body; if (Array.isArray(body)) { - return body.map((entry) => redactSensitiveFields(entry)); + return body.map((entry) => redactWith(entry, isSensitive)); } if (typeof body !== "object") return body; // Skip non-plain objects (Date, Map, Set, Buffer, ...) — they'll @@ -131,11 +151,32 @@ export function redactSensitiveFields(body: unknown): unknown { const out: Record = {}; for (const [key, value] of Object.entries(body as Record)) { - if (keyIsSensitive(key)) { + if (isSensitive(key)) { out[key] = REDACTED; continue; } - out[key] = redactSensitiveFields(value); + out[key] = redactWith(value, isSensitive); } return out; } + +/** + * Returns a deep clone of `body` with any key matching the sensitive + * denylist replaced by the literal `"[redacted]"`. + */ +export function redactSensitiveFields(body: unknown): unknown { + return redactWith(body, keyIsSensitive); +} + +/** + * v1.25 — excerpt-path redactor. Applies the global sensitive denylist PLUS + * the excerpt-only `note` / `notes` keys, so a free-text health-note value can + * never reach the `received_shape_excerpt` verbatim. Use this — not + * `redactSensitiveFields` — for anything that feeds `buildPayloadDiagnostic`. + */ +export function redactForExcerpt(body: unknown): unknown { + return redactWith( + body, + (key) => keyIsSensitive(key) || keyIsExcerptSensitive(key), + ); +}