mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
117 lines
4.8 KiB
TypeScript
117 lines
4.8 KiB
TypeScript
/**
|
|
* Free-text health-note ↔ encrypted-column helpers (v1.23).
|
|
*
|
|
* `MoodEntry.note` and `Measurement.notes` are migrating from plaintext to the
|
|
* AES-256-GCM `Bytes` columns `noteEncrypted` / `notesEncrypted`. These helpers
|
|
* are the single read/write boundary every call site goes through so the
|
|
* 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`.
|
|
*
|
|
* FAIL-CLOSED: `readNote` decrypts a present ciphertext and throws on a bad key
|
|
* id / malformed payload rather than silently returning plaintext — the same
|
|
* posture as the rest of the crypto layer. The plaintext fallback only fires
|
|
* when there is NO ciphertext (a legacy row not yet covered by the backfill).
|
|
*/
|
|
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
|
|
/**
|
|
* Encrypt a free-text note for the `Bytes` ciphertext column. Returns `null`
|
|
* for a null / undefined / empty value so an absent note stores nothing (and
|
|
* the row reads back as "no note"). Whitespace is preserved verbatim — callers
|
|
* that want trimming trim before calling.
|
|
*/
|
|
export function encryptNote(
|
|
plaintext: string | null | undefined,
|
|
): Uint8Array<ArrayBuffer> | null {
|
|
if (plaintext === null || plaintext === undefined || plaintext.length === 0) {
|
|
return null;
|
|
}
|
|
return encryptToBytes(plaintext);
|
|
}
|
|
|
|
/**
|
|
* Read a free-text note, preferring the encrypted column. Falls back to the
|
|
* legacy plaintext column ONLY when there is no ciphertext (a row the backfill
|
|
* has not yet migrated). A present ciphertext is always decrypted; a bad key
|
|
* id / malformed payload throws (fail-closed) rather than leaking the plaintext
|
|
* fallback.
|
|
*/
|
|
export function readNote(
|
|
ciphertext: Uint8Array | null | undefined,
|
|
plaintextFallback: string | null | undefined,
|
|
): string | null {
|
|
if (ciphertext && ciphertext.byteLength > 0) {
|
|
return decryptFromBytes(ciphertext);
|
|
}
|
|
return plaintextFallback ?? null;
|
|
}
|
|
|
|
/**
|
|
* Shape a measurement row for a response/export: surface the decrypted note on
|
|
* `notes` and strip the raw `notesEncrypted` ciphertext so it never leaves the
|
|
* server. Keeps every other column untouched.
|
|
*/
|
|
export function shapeMeasurementNotes<
|
|
T extends { notes: string | null; notesEncrypted: Uint8Array | null },
|
|
>(row: T): Omit<T, "notesEncrypted"> & { notes: string | null } {
|
|
const { notesEncrypted, ...rest } = row;
|
|
return { ...rest, notes: readNote(notesEncrypted, rest.notes) };
|
|
}
|
|
|
|
/**
|
|
* Shape a mood row for a response/export: surface the decrypted note on `note`
|
|
* and strip the raw `noteEncrypted` ciphertext. Keeps every other column
|
|
* untouched.
|
|
*/
|
|
export function shapeMoodNote<
|
|
T extends { note: string | null; noteEncrypted: Uint8Array | null },
|
|
>(row: T): Omit<T, "noteEncrypted"> & { note: string | null } {
|
|
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<T, "notesEncrypted"> & { 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<T, "notesEncrypted"> & { 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<T, "noteEncrypted"> & { note: string | null } {
|
|
const { noteEncrypted, ...rest } = row;
|
|
return { ...rest, note: readNote(noteEncrypted, rest.note) };
|
|
}
|