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.