mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(privacy): encrypt Coach conversation titles and custom-metric notes at rest
The title of a Coach conversation is the first 80 characters of the opening message, and the note on a custom-metric reading is free text; both were stored readable while every other note was encrypted. Migration 0357 adds coach_conversations.title_encrypted and custom_metric_entries.note_encrypted (the title loses its NOT NULL) plus partial indexes on the backfill's discovery predicate. Every writer now stores ciphertext only: conversation create, proactive nudge, reminder surfacing, rename, entry create and edit, and both restore formats. Readers prefer the ciphertext and fall back to the old column for rows not yet migrated; a ciphertext that does not open throws instead of falling back. The history title search decrypts in memory, since SQL can no longer match the title. free-text-encryption-backfill is a boot-time, idempotent pg-boss job (staggered past the startup storm, daily discovery tick) that seals each remaining readable value and nulls it in one transaction per row. A pass that sees a row twice stops with an error instead of looping. Both columns are registered for key rotation (registry, CLI script, and the in-app rotation that walks the registry). A disaster-recovery backup carries ciphertext only, sealing a not-yet-migrated row on the way out; a portable export carries readable text. The old columns are marked for removal in a later release.
This commit is contained in:
1 parent
4af6b87a2a
commit
f7b3ce6ee0
28 files changed
+1735
-83
No files matched your search
@@ -408,7 +408,7 @@ async function main() {
|
||||
}
|
||||
|
||||
// ───── Coach (Bytes columns) ─────
|
||||
// "encryptedContent" "summaryEncrypted" "factEncrypted"
|
||||
// "encryptedContent" "summaryEncrypted" "titleEncrypted" "factEncrypted"
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachMessage",
|
||||
@@ -423,6 +423,16 @@ async function main() {
|
||||
prisma.coachConversation,
|
||||
),
|
||||
);
|
||||
// v1.39.3 — the conversation title. NULL on a row the free-text backfill
|
||||
// has not reached yet (its title is still in the readable column), which
|
||||
// `rotateBytesColumn` skips.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachConversation",
|
||||
"titleEncrypted",
|
||||
prisma.coachConversation,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn("CoachFact", "factEncrypted", prisma.coachFact),
|
||||
);
|
||||
@@ -681,6 +691,17 @@ async function main() {
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.39.3 custom-metric reading note (Bytes column) ─────
|
||||
// NULL on a reading without a note, and on a row the free-text backfill has
|
||||
// not reached yet; `rotateBytesColumn` skips both.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CustomMetricEntry",
|
||||
"noteEncrypted",
|
||||
prisma.customMetricEntry,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.38.0 dose free text (Bytes column) ─────
|
||||
// The note on an administered dose — a reaction, a sore arm. Always
|
||||
// encrypted on write, so every non-null row rotates.
|
||||
|
||||
Reference in new issue
Block a user