From 8ac3fddc3b9b6e9feafbff616b7835d87aee8824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 10:19:31 +0200 Subject: [PATCH] Carry visit and vaccination free text readable in a portable backup A practitioner's note, a visit's reason, outcome and body site and a vaccination's note travelled as stored ciphertext in a portable file, the same defect the cycle and mood sections had. They now go through the shared sealed-text helpers: readable in a portable file, sealed under the receiving host's key on restore, ciphertext kept only where this host opens it and otherwise reported as visitCiphertext or vaccinationCiphertext. The practitioner address and phone ciphertext of a disaster-recovery file gets the same check, since the key preflight now leaves these sections to it. --- docs/api/openapi.yaml | 20 ++ messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- src/components/admin/backups-section.tsx | 6 + src/lib/export/backup-key-ids.ts | 10 +- src/lib/export/restore-skips.ts | 10 +- src/lib/export/sealed-text.ts | 72 ++++- src/lib/export/vaccinations-backup.ts | 48 +-- src/lib/export/visits-backup.ts | 140 ++++++--- src/lib/validations/backup.ts | 12 +- .../backup-visits-cross-key-restore.test.ts | 296 ++++++++++++++++++ .../integration/encounter-procedures.test.ts | 5 +- 17 files changed, 559 insertions(+), 88 deletions(-) create mode 100644 tests/integration/backup-visits-cross-key-restore.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9f672d83b..59f2766f1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -38448,6 +38448,10 @@ components: - type: string minLength: 1 - type: "null" + note: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 @@ -38515,6 +38519,18 @@ components: - type: string minLength: 1 - type: "null" + reason: + anyOf: + - type: string + - type: "null" + outcome: + anyOf: + - type: string + - type: "null" + bodySite: + anyOf: + - type: string + - type: "null" laterality: anyOf: - type: string @@ -38671,6 +38687,10 @@ components: - type: string minLength: 1 - type: "null" + note: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 diff --git a/messages/de.json b/messages/de.json index d50600800..11af9ce58 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.", "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)", "restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)", - "restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)" + "restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)", + "restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)", + "restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index b2a3d92c6..e1d6f6fed 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.", "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)", "restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)", - "restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)" + "restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)", + "restoreSkippedVisitCiphertext": "Visit or practitioner text sealed with a key this server does not hold (kept without it)", + "restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index daf60d074..a5b8de087 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.", "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)", "restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)", - "restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)" + "restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)", + "restoreSkippedVisitCiphertext": "Texto de visita o profesional cifrado con una clave que este servidor no tiene (se conserva sin él)", + "restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 29378a5a1..7e023dc5a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.", "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)", "restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)", - "restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)" + "restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)", + "restoreSkippedVisitCiphertext": "Texte de consultation ou de praticien chiffré avec une clé que ce serveur ne possède pas (conservé sans)", + "restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 00a8957cf..9e4a387e9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.", "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)", "restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)", - "restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)" + "restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)", + "restoreSkippedVisitCiphertext": "Testo di visita o professionista cifrato con una chiave che questo server non possiede (mantenuto senza)", + "restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index 7a1991f25..9ef99ba16 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7535,7 +7535,9 @@ "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.", "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)", "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)", - "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)" + "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)", + "restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)", + "restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)" }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index d8cbfe8b5..d3c92bc44 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.", "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)", "restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)", - "restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)" + "restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)", + "restoreSkippedVisitCiphertext": "Tekst wizyty lub lekarza zaszyfrowany kluczem, którego ten serwer nie ma (zachowany bez niego)", + "restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 96f34bdd9..22b557d71 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -464,6 +464,12 @@ function catalogueLabel( if (catalogue === "symptomEpisodeReference") { return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); } + if (catalogue === "visitCiphertext") { + return t("admin.section.backups.restoreSkippedVisitCiphertext"); + } + if (catalogue === "vaccinationCiphertext") { + return t("admin.section.backups.restoreSkippedVaccinationCiphertext"); + } if (catalogue === "moodLabelCiphertext") { return t("admin.section.backups.restoreSkippedMoodLabelCiphertext"); } diff --git a/src/lib/export/backup-key-ids.ts b/src/lib/export/backup-key-ids.ts index c4113125e..88bb09ad6 100644 --- a/src/lib/export/backup-key-ids.ts +++ b/src/lib/export/backup-key-ids.ts @@ -101,9 +101,10 @@ export function isEncryptedMember(name: string): boolean { * Sections whose restore opens every inner value with this host's keys itself, * and keeps out (and names in the skip report) any value it cannot open. * - * The cycle day-logs, custom cycle symptoms and custom mood tags and mood - * categories are here because a portable file written before v1.40 carried - * their free text as the stored ciphertext, + * The cycle day-logs, custom cycle symptoms, custom mood tags and mood + * categories, practitioners, visits and vaccinations are here because a + * portable file written before v1.40 carried their free text as the stored + * ciphertext, * not as readable text. Such a file is still restorable onto another host: * what that host can open comes back, what it cannot stays out of the row and * is reported. Refusing the whole file over a note would cost every other @@ -117,6 +118,9 @@ export const RESTORE_SELF_VERIFIED_SECTIONS: ReadonlySet = new Set([ "customSymptoms", "customMoodTags", "customMoodTagCategories", + "practitioners", + "encounters", + "vaccinations", ]); interface KeyUse { diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index e292b78e4..c8bb1fd92 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -165,6 +165,12 @@ * tag or mood category the account created * (`customMoodTags..labelEncrypted`): the tag or category restores, under * its key, without the person's own name for it. + * + * The eighteenth and nineteenth, `visitCiphertext` and `vaccinationCiphertext`, + * are the same again for the free text of the visits record (a practitioner's + * note, address and phone; a visit's reason, outcome and body site) and of a + * vaccination's note (`vaccinations..noteEncrypted`). The row restores + * without the field. */ export type SkippedCatalogue = | "cycleSymptom" @@ -187,7 +193,9 @@ export type SkippedCatalogue = | "accountSetting" | "symptomEpisodeReference" | "cycleCiphertext" - | "moodLabelCiphertext"; + | "moodLabelCiphertext" + | "visitCiphertext" + | "vaccinationCiphertext"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/export/sealed-text.ts b/src/lib/export/sealed-text.ts index c1e28a3c7..4773d6186 100644 --- a/src/lib/export/sealed-text.ts +++ b/src/lib/export/sealed-text.ts @@ -1,8 +1,9 @@ /** - * Both ends of a sealed text column stored with the string codec - * (`.`), for the backup sections that carry one: the cycle - * day-log note and sensitive envelope, custom cycle symptom labels, and the - * custom mood tag and mood category labels. + * Both ends of a sealed free-text column in a backup section: the string + * codec (`.` in a text column — the cycle day-log note and + * sensitive envelope, custom cycle symptom and mood labels) and the shared + * bytes codec (the practitioner note, the visit's reason, outcome and body + * site, the vaccination note). * * The split is the one every other section uses. A disaster-recovery file * carries the stored ciphertext, which the same instance's key reads back. A @@ -18,6 +19,9 @@ * than store a value nobody can read. That per-value check is why the key * preflight leaves these sections out (`RESTORE_SELF_VERIFIED_SECTIONS`). */ +import { Buffer } from "node:buffer"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { decrypt, encrypt } from "@/lib/crypto"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { getEvent } from "@/lib/logging/context"; @@ -68,3 +72,63 @@ export function sealForRestore( } return readable ? encrypt(readable) : null; } + +/** + * The `Bytes` twin of {@link openSealedForExport}, for columns written with + * the shared bytes codec (`encryptToBytes`): the practitioner note, the + * visit's reason, outcome and body site, the vaccination note. + */ +export function openSealedBytesForExport( + sealed: Uint8Array | null, + field: string, +): string | null { + if (!sealed || sealed.byteLength === 0) return null; + try { + return decryptFromBytes(sealed); + } catch (err) { + getEvent()?.addWarning( + `${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNREADABLE_EXPORT_MARKER; + } +} + +/** The stored `Bytes` ciphertext as a disaster-recovery file carries it. */ +export function encodeSealedBytes(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + return Buffer.from(value).toString("base64"); +} + +function decodeBase64(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} + +/** + * The `Bytes` twin of {@link sealForRestore}. `sealed` is the base64 a + * disaster-recovery (or pre-v1.40 portable) file carries; it is written back + * only when this host opens it. A readable value is sealed under this host's + * active key. + */ +export function sealBytesForRestore( + sealed: string | null | undefined, + readable: string | null | undefined, + path: string, + unopened: string[], +): Uint8Array | null { + if (sealed) { + const bytes = decodeBase64(sealed); + try { + decryptFromBytes(bytes); + return bytes; + } catch { + unopened.push(path); + return null; + } + } + return readable ? encryptToBytes(readable) : null; +} diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts index f88bcc151..2e15242b0 100644 --- a/src/lib/export/vaccinations-backup.ts +++ b/src/lib/export/vaccinations-backup.ts @@ -35,7 +35,11 @@ * guarantee `src/lib/vaccinations/__tests__/catalog-integrity.test.ts` * holds. */ -import { Buffer } from "node:buffer"; +import { + encodeSealedBytes, + openSealedBytesForExport, + sealBytesForRestore, +} from "@/lib/export/sealed-text"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { VaccinationSite } from "@/generated/prisma/client"; @@ -72,8 +76,10 @@ export interface VaccinationBackupEntry { * lacks the reminder. */ reminderId: string | null; - /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ - noteEncrypted: string | null; + /** Base64 ciphertext; disaster-recovery payloads only. */ + noteEncrypted?: string | null; + /** The note readable; portable payloads only. */ + note?: string | null; createdAt: string; updatedAt: string; deletedAt?: string | null; @@ -96,12 +102,6 @@ export interface VaccinationsBackupCounts { vaccinationLinks: number; } -/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */ -function encodeCiphertext(value: Uint8Array | null): string | null { - if (!value || value.byteLength === 0) return null; - return Buffer.from(value).toString("base64"); -} - /** * Named select constants, one per model — a structural matcher binds a model * to the literal beside its delegate call, and an inline object inside a @@ -172,7 +172,14 @@ export async function buildVaccinationsBackupSection( practitionerId: row.practitionerId, encounterId: row.encounterId, reminderId: row.reminderId, - noteEncrypted: encodeCiphertext(row.noteEncrypted), + ...(disasterRecovery + ? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) } + : { + note: openSealedBytesForExport( + row.noteEncrypted, + "vaccination note", + ), + }), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), ...(disasterRecovery @@ -240,16 +247,10 @@ export type RestoredVaccination = Pick< | "encounterId" | "reminderId" | "noteEncrypted" + | "note" > > & { deletedAt?: string | null }; -function decodeCiphertext(encoded: string): Uint8Array { - const decoded = Buffer.from(encoded, "base64"); - const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); - bytes.set(decoded); - return bytes; -} - /** * Re-create the account's immunization log and its document links. * @@ -307,6 +308,8 @@ export async function restoreVaccinationsData( const droppedPractitioners: string[] = []; const droppedEncounters: string[] = []; const droppedReminders: string[] = []; + // Sealed notes this host's keys do not open, by file path. + const unopened: string[] = []; if (payload.vaccinations.length > 0) { await tx.vaccinationRecord.createMany({ @@ -349,10 +352,12 @@ export async function restoreVaccinationsData( practitionerId, encounterId, reminderId, - noteEncrypted: - entry.noteEncrypted == null - ? null - : decodeCiphertext(entry.noteEncrypted), + noteEncrypted: sealBytesForRestore( + entry.noteEncrypted, + entry.note, + `vaccinations.${entry.id}.noteEncrypted`, + unopened, + ), createdAt: new Date(entry.createdAt), updatedAt: new Date(entry.updatedAt), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, @@ -379,6 +384,7 @@ export async function restoreVaccinationsData( [...new Set(droppedReminders)], droppedReminders, ); + recordUnknownKeys(skips, "vaccinationCiphertext", unopened, unopened); const restoredVaccinations = new Set( payload.vaccinations.map((entry) => entry.id), diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts index 32ebca5f4..2957bb947 100644 --- a/src/lib/export/visits-backup.ts +++ b/src/lib/export/visits-backup.ts @@ -33,7 +33,6 @@ * result ids, so a lab link in one cannot resolve; a disaster-recovery * payload carries every id and they all do. */ -import { Buffer } from "node:buffer"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { EncounterKind, @@ -42,6 +41,11 @@ import type { } from "@/generated/prisma/client"; import { encryptNote } from "@/lib/crypto/note-cipher"; +import { + encodeSealedBytes, + openSealedBytesForExport, + sealBytesForRestore, +} from "@/lib/export/sealed-text"; import { readPractitionerContact } from "@/lib/practitioners/dto"; import { recordUnknownKeys, @@ -71,8 +75,10 @@ export interface PractitionerBackupEntry { locationEncrypted?: string | null; /** Base64 ciphertext of the phone number, disaster-recovery payloads only. */ phoneEncrypted?: string | null; - /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ - noteEncrypted: string | null; + /** Base64 ciphertext; disaster-recovery payloads only. */ + noteEncrypted?: string | null; + /** The note readable; portable payloads only. */ + note?: string | null; createdAt: string; updatedAt: string; deletedAt?: string | null; @@ -87,10 +93,15 @@ export interface EncounterBackupEntry { status: EncounterStatus; kind: EncounterKind; practitionerId: string | null; - reasonEncrypted: string | null; - outcomeEncrypted: string | null; + /** Base64 ciphertext; disaster-recovery payloads only. */ + reasonEncrypted?: string | null; + outcomeEncrypted?: string | null; /** The procedure's body site, ciphertext like the two above (v1.39.1). */ - bodySiteEncrypted: string | null; + bodySiteEncrypted?: string | null; + /** The three readable; portable payloads only. */ + reason?: string | null; + outcome?: string | null; + bodySite?: string | null; laterality: Laterality | null; /** * The appointment's reminder row, which restores in the same run since @@ -124,12 +135,6 @@ export interface VisitsBackupCounts { encounterLinks: number; } -/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */ -function encodeCiphertext(value: Uint8Array | null): string | null { - if (!value || value.byteLength === 0) return null; - return Buffer.from(value).toString("base64"); -} - /** * Named select constants, one per model. * @@ -256,10 +261,10 @@ export async function buildVisitsBackupSection( ? { location: null, phone: null, - locationEncrypted: encodeCiphertext( + locationEncrypted: encodeSealedBytes( row.locationEncrypted ?? encryptNote(row.location), ), - phoneEncrypted: encodeCiphertext( + phoneEncrypted: encodeSealedBytes( row.phoneEncrypted ?? encryptNote(row.phone), ), } @@ -275,7 +280,14 @@ export async function buildVisitsBackupSection( "phone", ), }), - noteEncrypted: encodeCiphertext(row.noteEncrypted), + ...(disasterRecovery + ? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) } + : { + note: openSealedBytesForExport( + row.noteEncrypted, + "practitioner note", + ), + }), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), ...(disasterRecovery @@ -288,9 +300,26 @@ export async function buildVisitsBackupSection( status: row.status, kind: row.kind, practitionerId: row.practitionerId, - reasonEncrypted: encodeCiphertext(row.reasonEncrypted), - outcomeEncrypted: encodeCiphertext(row.outcomeEncrypted), - bodySiteEncrypted: encodeCiphertext(row.bodySiteEncrypted), + ...(disasterRecovery + ? { + reasonEncrypted: encodeSealedBytes(row.reasonEncrypted), + outcomeEncrypted: encodeSealedBytes(row.outcomeEncrypted), + bodySiteEncrypted: encodeSealedBytes(row.bodySiteEncrypted), + } + : { + reason: openSealedBytesForExport( + row.reasonEncrypted, + "visit reason", + ), + outcome: openSealedBytesForExport( + row.outcomeEncrypted, + "visit outcome", + ), + bodySite: openSealedBytesForExport( + row.bodySiteEncrypted, + "visit body site", + ), + }), laterality: row.laterality, reminderId: row.reminderId, createdAt: row.createdAt.toISOString(), @@ -380,6 +409,7 @@ export type RestoredPractitioner = Pick< | "locationEncrypted" | "phoneEncrypted" | "noteEncrypted" + | "note" > > & { deletedAt?: string | null }; @@ -394,18 +424,14 @@ export type RestoredEncounter = Pick< | "reasonEncrypted" | "outcomeEncrypted" | "bodySiteEncrypted" + | "reason" + | "outcome" + | "bodySite" | "laterality" | "reminderId" > > & { deletedAt?: string | null }; -function decodeCiphertext(encoded: string): Uint8Array { - const decoded = Buffer.from(encoded, "base64"); - const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); - bytes.set(decoded); - return bytes; -} - /** * Re-create the account's visits, address book and link rows. * @@ -440,6 +466,9 @@ export async function restoreVisitsData( where: { userId: ownerId }, }); + // Sealed values this host's keys do not open, by file path. + const unopened: string[] = []; + if (payload.practitioners.length > 0) { await tx.practitioner.createMany({ data: payload.practitioners.map((entry) => ({ @@ -451,18 +480,24 @@ export async function restoreVisitsData( // v1.39.4 — ciphertext wins when a disaster-recovery file carried it; // a portable file's (or an older file's) readable value is sealed // under this instance's key. The readable columns are never written. - locationEncrypted: - entry.locationEncrypted != null - ? decodeCiphertext(entry.locationEncrypted) - : encryptNote(entry.location), - phoneEncrypted: - entry.phoneEncrypted != null - ? decodeCiphertext(entry.phoneEncrypted) - : encryptNote(entry.phone), - noteEncrypted: - entry.noteEncrypted == null - ? null - : decodeCiphertext(entry.noteEncrypted), + locationEncrypted: sealBytesForRestore( + entry.locationEncrypted, + entry.location, + `practitioners.${entry.id}.locationEncrypted`, + unopened, + ), + phoneEncrypted: sealBytesForRestore( + entry.phoneEncrypted, + entry.phone, + `practitioners.${entry.id}.phoneEncrypted`, + unopened, + ), + noteEncrypted: sealBytesForRestore( + entry.noteEncrypted, + entry.note, + `practitioners.${entry.id}.noteEncrypted`, + unopened, + ), createdAt: new Date(entry.createdAt), updatedAt: new Date(entry.updatedAt), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, @@ -517,20 +552,26 @@ export async function restoreVisitsData( status: entry.status, kind: entry.kind, practitionerId, - reasonEncrypted: - entry.reasonEncrypted == null - ? null - : decodeCiphertext(entry.reasonEncrypted), - outcomeEncrypted: - entry.outcomeEncrypted == null - ? null - : decodeCiphertext(entry.outcomeEncrypted), + reasonEncrypted: sealBytesForRestore( + entry.reasonEncrypted, + entry.reason, + `encounters.${entry.id}.reasonEncrypted`, + unopened, + ), + outcomeEncrypted: sealBytesForRestore( + entry.outcomeEncrypted, + entry.outcome, + `encounters.${entry.id}.outcomeEncrypted`, + unopened, + ), // Absent in a file written before v1.39.1: the visit comes back // with no site, which is what it had. - bodySiteEncrypted: - entry.bodySiteEncrypted == null - ? null - : decodeCiphertext(entry.bodySiteEncrypted), + bodySiteEncrypted: sealBytesForRestore( + entry.bodySiteEncrypted, + entry.bodySite, + `encounters.${entry.id}.bodySiteEncrypted`, + unopened, + ), laterality: entry.laterality ?? null, reminderId, createdAt: new Date(entry.createdAt), @@ -553,6 +594,7 @@ export async function restoreVisitsData( [...new Set(droppedReminders)], droppedReminders, ); + recordUnknownKeys(skips, "visitCiphertext", unopened, unopened); const restoredEncounters = new Set(payload.encounters.map((e) => e.id)); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index a96fa893a..f0a31c31f 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1207,8 +1207,9 @@ const symptomDefinitionBackupSchema = z * * `id` is required on both records, unlike the lab result above: a link * addresses an encounter and its far side by id and nothing else on either row - * is unique enough to rebuild the reference from. The ciphertext columns ride - * verbatim as base64 — a visit note is never decrypted into the file. + * is unique enough to rebuild the reference from. Free text follows the note + * contract: ciphertext as base64 on a disaster-recovery file, the readable + * value on a portable one (sealed under the receiving host's key on restore). */ const practitionerBackupSchema = z .object({ @@ -1222,6 +1223,7 @@ const practitionerBackupSchema = z locationEncrypted: base64BytesSchema.nullable().optional(), phoneEncrypted: base64BytesSchema.nullable().optional(), noteEncrypted: base64BytesSchema.nullable().optional(), + note: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), @@ -1240,6 +1242,10 @@ const encounterBackupSchema = z // v1.39.1 — the procedure's body site and side. Optional so a file written // before they existed still parses; the visit restores with neither. bodySiteEncrypted: base64BytesSchema.nullable().optional(), + // The readable reason, outcome and body site of a portable file. + reason: z.string().nullable().optional(), + outcome: z.string().nullable().optional(), + bodySite: z.string().nullable().optional(), laterality: z.enum(Laterality).nullable().optional(), // Remapped against the restored reminders (they travel since v1.37.20); // dropped to NULL, with the drop named, only when the file lacks the row. @@ -1281,7 +1287,9 @@ const vaccinationBackupSchema = z // Remapped against the restored reminders (they travel since v1.37.20); // dropped to NULL, with the drop named, only when the file lacks the row. reminderId: z.string().nullable().optional(), + // Ciphertext on a disaster-recovery file, readable on a portable one. noteEncrypted: base64BytesSchema.nullable().optional(), + note: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), diff --git a/tests/integration/backup-visits-cross-key-restore.test.ts b/tests/integration/backup-visits-cross-key-restore.test.ts new file mode 100644 index 000000000..234f6c136 --- /dev/null +++ b/tests/integration/backup-visits-cross-key-restore.test.ts @@ -0,0 +1,296 @@ +/** + * A portable file's visit and vaccination free text survives a move to a host + * with other keys. + * + * A practitioner's note, a visit's reason, outcome and body site and a + * vaccination's note are sealed under the writing host's key. A portable file + * used to carry them as the stored ciphertext, so a restore onto a host with + * a different key refused the file at the key check, or with a matching key id + * over other key material wrote values nobody could open. + * + * Both ends and the pipe: built by the real `buildFullBackupPayload` under key + * A, restored through the real route under key B, opened under key B. The + * second case is a file written before the fix: a value this host opens comes + * back, one it cannot open stays out and is named in the skip report. + */ +import { Buffer } from "node:buffer"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +const saved = { + key: process.env.ENCRYPTION_KEY, + keys: process.env.ENCRYPTION_KEYS, + active: process.env.ENCRYPTION_ACTIVE_KEY_ID, +}; + +/** Make this process the host holding exactly `keys`, writing under `active`. */ +function useKeys(keys: Record, active: string) { + process.env.ENCRYPTION_KEY = ""; + process.env.ENCRYPTION_KEYS = JSON.stringify(keys); + process.env.ENCRYPTION_ACTIVE_KEY_ID = active; + _resetCryptoCacheForTests(); +} + +const HOST_A = { a: "11".repeat(32) }; +const HOST_B = { b: "22".repeat(32) }; +/** Same key id as host B, different key material. */ +const HOST_B_IMPOSTOR = { b: "33".repeat(32) }; + +const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + process.env.ENCRYPTION_KEY = saved.key ?? ""; + process.env.ENCRYPTION_KEYS = saved.keys ?? ""; + process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? ""; + _resetCryptoCacheForTests(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +async function seedOwner() { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "visits-cross-key-owner", + email: "visits-cross-key-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return owner; +} + +/** Store `payload` as a backup on this host and run the real restore. */ +async function restore(ownerId: string, payload: unknown) { + const prisma = getPrismaClient(); + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "VISITS_CROSS_KEY_RESTORE", + data: encrypt(JSON.stringify(payload)), + }, + }); + return POST(makeRequest(backup.id) as unknown as Parameters[0], { + params: Promise.resolve({ id: backup.id }), + }); +} + +describe("visit and vaccination free text restored under another key", () => { + it("comes back readable under the receiving host's key", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_A, "a"); + const practitioner = await prisma.practitioner.create({ + data: { + userId: owner.id, + name: "Practice", + noteEncrypted: encryptToBytes("ring the upper bell"), + locationEncrypted: encryptToBytes("Main street 1"), + }, + }); + const encounter = await prisma.encounter.create({ + data: { + userId: owner.id, + occurredAt: new Date("2026-06-30T08:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + practitionerId: practitioner.id, + reasonEncrypted: encryptToBytes("annual check"), + outcomeEncrypted: encryptToBytes("all fine"), + bodySiteEncrypted: encryptToBytes("left knee"), + }, + }); + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId: owner.id, + occurredAt: new Date("2026-05-14T00:00:00.000Z"), + vaccineName: "Tetanus", + noteEncrypted: encryptToBytes("sore arm for a day"), + }, + }); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as unknown; + + useKeys(HOST_B, "b"); + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const p = await prisma.practitioner.findUniqueOrThrow({ + where: { id: practitioner.id }, + }); + expect(decryptFromBytes(p.noteEncrypted!)).toBe("ring the upper bell"); + expect(decryptFromBytes(p.locationEncrypted!)).toBe("Main street 1"); + const e = await prisma.encounter.findUniqueOrThrow({ + where: { id: encounter.id }, + }); + expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check"); + expect(decryptFromBytes(e.outcomeEncrypted!)).toBe("all fine"); + expect(decryptFromBytes(e.bodySiteEncrypted!)).toBe("left knee"); + const v = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id: vaccination.id }, + }); + expect(decryptFromBytes(v.noteEncrypted!)).toBe("sore arm for a day"); + + const body = (await response.json()) as { + data: { skipped: { links: number } }; + }; + expect(body.data.skipped.links).toBe(0); + }); + + it("restores a legacy file's ciphertext only where this host can open it", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_B, "b"); + const ownReason = b64(encryptToBytes("annual check")); + useKeys(HOST_A, "a"); + const foreignNote = b64(encryptToBytes("written under key a")); + useKeys(HOST_B_IMPOSTOR, "b"); + const impostorOutcome = b64(encryptToBytes("same id, other key")); + const impostorVaccination = b64(encryptToBytes("same id, other key")); + + // The shape a portable file had before the fix: ciphertext, no text. + useKeys(HOST_B, "b"); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as Record; + const at = "2026-06-30T08:00:00.000Z"; + file.practitioners = [ + { + id: "legacy-practitioner", + name: "Practice", + specialty: null, + practice: null, + location: null, + phone: null, + noteEncrypted: foreignNote, + createdAt: at, + updatedAt: at, + }, + ]; + file.encounters = [ + { + id: "legacy-encounter", + occurredAt: at, + status: "DONE", + kind: "ROUTINE", + practitionerId: "legacy-practitioner", + reasonEncrypted: ownReason, + outcomeEncrypted: impostorOutcome, + bodySiteEncrypted: null, + laterality: null, + reminderId: null, + createdAt: at, + updatedAt: at, + }, + ]; + file.vaccinations = [ + { + id: "legacy-vaccination", + occurredAt: at, + vaccineName: "Tetanus", + noteEncrypted: impostorVaccination, + createdAt: at, + updatedAt: at, + }, + ]; + parseBackupPayload(JSON.stringify(file)); + + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const p = await prisma.practitioner.findUniqueOrThrow({ + where: { id: "legacy-practitioner" }, + }); + expect(p.noteEncrypted).toBeNull(); + const e = await prisma.encounter.findUniqueOrThrow({ + where: { id: "legacy-encounter" }, + }); + expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check"); + expect(e.outcomeEncrypted).toBeNull(); + const v = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id: "legacy-vaccination" }, + }); + expect(v.vaccineName).toBe("Tetanus"); + expect(v.noteEncrypted).toBeNull(); + + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + expect(body.data.skipped.catalogueKeys).toEqual([ + { + catalogue: "vaccinationCiphertext", + key: "vaccinations.legacy-vaccination.noteEncrypted", + links: 1, + }, + { + catalogue: "visitCiphertext", + key: "encounters.legacy-encounter.outcomeEncrypted", + links: 1, + }, + { + catalogue: "visitCiphertext", + key: "practitioners.legacy-practitioner.noteEncrypted", + links: 1, + }, + ]); + }); +}); diff --git a/tests/integration/encounter-procedures.test.ts b/tests/integration/encounter-procedures.test.ts index 31198c652..036aa2416 100644 --- a/tests/integration/encounter-procedures.test.ts +++ b/tests/integration/encounter-procedures.test.ts @@ -343,7 +343,10 @@ describe("the backup", () => { const entry = section.encounters.find((row) => row.id === created.id); expect(entry?.kind).toBe("PROCEDURE"); expect(entry?.laterality).toBe("BOTH"); - expect(entry?.bodySiteEncrypted).toEqual(expect.any(String)); + // A portable section carries the site readable, never the ciphertext; the + // restore seals it under this host's key. + expect(entry?.bodySite).toBe("Hüfte"); + expect(entry).not.toHaveProperty("bodySiteEncrypted"); // Through the file schema, as a restore reads it: a field the schema does // not know would be dropped here and come back as null.