mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Carry custom mood labels readable in a portable backup
The labels of the mood tags and mood categories an account created travelled as the stored ciphertext in a portable file, so a restore onto a host with another key refused the file or wrote labels nobody could open. They now follow the cycle note: 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 moodLabelCiphertext. The sealing helpers move to one module both sections share.
This commit is contained in:
1 parent
6bc764fa28
commit
43a53255c9
17 files changed
+431
-72
No files matched your search
@@ -36796,6 +36796,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
label:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
@@ -36853,6 +36857,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
label:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
scaleMin:
|
||||
default: 1
|
||||
type: integer
|
||||
|
||||
+2
-1
@@ -7555,7 +7555,8 @@
|
||||
"restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.",
|
||||
"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)"
|
||||
"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)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
|
||||
+2
-1
@@ -7555,7 +7555,8 @@
|
||||
"restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.",
|
||||
"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)"
|
||||
"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)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
|
||||
+2
-1
@@ -7555,7 +7555,8 @@
|
||||
"restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.",
|
||||
"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)"
|
||||
"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)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de peligro",
|
||||
|
||||
+2
-1
@@ -7555,7 +7555,8 @@
|
||||
"restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.",
|
||||
"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)"
|
||||
"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)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zone de danger",
|
||||
|
||||
+2
-1
@@ -7555,7 +7555,8 @@
|
||||
"restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.",
|
||||
"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)"
|
||||
"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)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona pericolosa",
|
||||
|
||||
+2
-1
@@ -7534,7 +7534,8 @@
|
||||
"restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.",
|
||||
"previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.",
|
||||
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)",
|
||||
"restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)"
|
||||
"restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)",
|
||||
"restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "위험 구역",
|
||||
|
||||
+2
-1
@@ -7555,7 +7555,8 @@
|
||||
"restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.",
|
||||
"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)"
|
||||
"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)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Strefa zagrożenia",
|
||||
|
||||
@@ -464,6 +464,9 @@ function catalogueLabel(
|
||||
if (catalogue === "symptomEpisodeReference") {
|
||||
return t("admin.section.backups.restoreSkippedSymptomEpisodeReference");
|
||||
}
|
||||
if (catalogue === "moodLabelCiphertext") {
|
||||
return t("admin.section.backups.restoreSkippedMoodLabelCiphertext");
|
||||
}
|
||||
if (catalogue === "cycleCiphertext") {
|
||||
return t("admin.section.backups.restoreSkippedCycleCiphertext");
|
||||
}
|
||||
|
||||
+8
-52
@@ -40,9 +40,12 @@ import type {
|
||||
PrismaClient,
|
||||
SecondarySymptom,
|
||||
} from "@/generated/prisma/client";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
openSealedForExport,
|
||||
sealForRestore,
|
||||
UNOPENED,
|
||||
} from "@/lib/export/sealed-text";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import type { BackupPayload } from "@/lib/validations/backup";
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
@@ -189,26 +192,12 @@ export interface CycleSensitiveFields {
|
||||
contraceptive?: string | null;
|
||||
}
|
||||
|
||||
/** Open a sealed value for a portable file, or the unreadable marker. */
|
||||
function openForExport(sealed: string, field: string): string {
|
||||
try {
|
||||
return decrypt(sealed);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`cycle ${field} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/** The sensitive envelope opened for a portable file, or the marker. */
|
||||
function openSensitiveForExport(
|
||||
sealed: string | null,
|
||||
): CycleSensitiveFields | string | null {
|
||||
if (!sealed) return null;
|
||||
const opened = openForExport(sealed, "sensitive envelope");
|
||||
const opened = openSealedForExport(sealed, "cycle sensitive envelope");
|
||||
if (opened === UNREADABLE_EXPORT_MARKER) return opened;
|
||||
try {
|
||||
return JSON.parse(opened) as CycleSensitiveFields;
|
||||
@@ -330,7 +319,7 @@ export async function buildCycleBackupSection(
|
||||
: {
|
||||
sensitive: openSensitiveForExport(d.sensitiveEncrypted),
|
||||
note: d.notesEncrypted
|
||||
? openForExport(d.notesEncrypted, "day-log note")
|
||||
? openSealedForExport(d.notesEncrypted, "cycle day-log note")
|
||||
: null,
|
||||
}),
|
||||
source: d.source,
|
||||
@@ -353,7 +342,7 @@ export async function buildCycleBackupSection(
|
||||
? { labelEncrypted: sym.labelEncrypted }
|
||||
: {
|
||||
label: sym.labelEncrypted
|
||||
? openForExport(sym.labelEncrypted, "symptom label")
|
||||
? openSealedForExport(sym.labelEncrypted, "cycle symptom label")
|
||||
: null,
|
||||
}),
|
||||
})),
|
||||
@@ -447,39 +436,6 @@ function severityOrNull(value: number | null | undefined): number | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** A readable value the file could not supply: the writer could not open it. */
|
||||
const UNOPENED = Symbol("unopened");
|
||||
|
||||
/**
|
||||
* The sealed column value for one free-text field from the file.
|
||||
*
|
||||
* Ciphertext is written back only when this host opens it — a value sealed
|
||||
* under a key this host does not hold (or the same key id over other key
|
||||
* material) would be a row nobody can read. A readable value is sealed under
|
||||
* this host's active key. Anything kept out is recorded under `path`.
|
||||
*/
|
||||
function sealForRestore(
|
||||
sealed: string | null | undefined,
|
||||
readable: string | null | undefined | typeof UNOPENED,
|
||||
path: string,
|
||||
unopened: string[],
|
||||
): string | null {
|
||||
if (sealed) {
|
||||
try {
|
||||
decrypt(sealed);
|
||||
return sealed;
|
||||
} catch {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (readable === UNOPENED) {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
return readable ? encrypt(readable) : null;
|
||||
}
|
||||
|
||||
function enumOrNull<T extends string>(
|
||||
value: string | null | undefined,
|
||||
allow: ReadonlySet<T>,
|
||||
|
||||
@@ -101,8 +101,9 @@ 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 and custom cycle symptoms 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 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,
|
||||
* 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
|
||||
@@ -114,6 +115,8 @@ export function isEncryptedMember(name: string): boolean {
|
||||
export const RESTORE_SELF_VERIFIED_SECTIONS: ReadonlySet<string> = new Set([
|
||||
"cycleDayLogs",
|
||||
"customSymptoms",
|
||||
"customMoodTags",
|
||||
"customMoodTagCategories",
|
||||
]);
|
||||
|
||||
interface KeyUse {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { openSealedForExport } from "@/lib/export/sealed-text";
|
||||
import { iterateMeasurementPages } from "@/lib/export/paged-measurements";
|
||||
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import {
|
||||
@@ -1284,8 +1285,18 @@ export async function buildFullBackupPayload(
|
||||
icon: category.icon,
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
// Ciphertext verbatim, like the tag label beside it.
|
||||
labelEncrypted: category.labelEncrypted,
|
||||
// The person's own words: ciphertext in a disaster-recovery payload,
|
||||
// readable in a portable one (see `sealed-text.ts`).
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: category.labelEncrypted }
|
||||
: {
|
||||
label: category.labelEncrypted
|
||||
? openSealedForExport(
|
||||
category.labelEncrypted,
|
||||
"mood category label",
|
||||
)
|
||||
: null,
|
||||
}),
|
||||
})),
|
||||
hiddenMoodTags: hiddenMoodTags.map((hidden) => ({
|
||||
key: hidden.moodTag.key,
|
||||
@@ -1300,9 +1311,15 @@ export async function buildFullBackupPayload(
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon,
|
||||
sortOrder: tag.sortOrder,
|
||||
// Ciphertext verbatim, like every other *Encrypted column in a DR
|
||||
// payload: the same instance's key reads it back unchanged.
|
||||
labelEncrypted: tag.labelEncrypted,
|
||||
// Ciphertext in a disaster-recovery payload (the same instance's key
|
||||
// reads it back), readable in a portable one (see `sealed-text.ts`).
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: tag.labelEncrypted }
|
||||
: {
|
||||
label: tag.labelEncrypted
|
||||
? openSealedForExport(tag.labelEncrypted, "mood tag label")
|
||||
: null,
|
||||
}),
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
listTenantEdges,
|
||||
} from "@/lib/export/tenant-boundary";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { sealForRestore } from "@/lib/export/sealed-text";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
@@ -1533,6 +1534,8 @@ export async function restoreBackup(
|
||||
// Upserted by key for the same reason the tags below are: `key` is
|
||||
// globally unique, so a seeded category that later claims the key
|
||||
// must be left alone rather than overwritten by a restore.
|
||||
// Labels this host's keys do not open, by file path.
|
||||
const unopenedMoodLabels: string[] = [];
|
||||
for (const category of payload.customMoodTagCategories) {
|
||||
await tx.moodTagCategory.upsert({
|
||||
where: { key: category.key },
|
||||
@@ -1544,7 +1547,12 @@ export async function restoreBackup(
|
||||
icon: category.icon ?? null,
|
||||
sortOrder: category.sortOrder ?? 0,
|
||||
isActive: category.isActive ?? true,
|
||||
labelEncrypted: category.labelEncrypted ?? null,
|
||||
labelEncrypted: sealForRestore(
|
||||
category.labelEncrypted,
|
||||
category.label,
|
||||
`customMoodTagCategories.${category.key}.labelEncrypted`,
|
||||
unopenedMoodLabels,
|
||||
),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
@@ -1567,7 +1575,12 @@ export async function restoreBackup(
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon ?? null,
|
||||
sortOrder: tag.sortOrder,
|
||||
labelEncrypted: tag.labelEncrypted ?? null,
|
||||
labelEncrypted: sealForRestore(
|
||||
tag.labelEncrypted,
|
||||
tag.label,
|
||||
`customMoodTags.${tag.key}.labelEncrypted`,
|
||||
unopenedMoodLabels,
|
||||
),
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
@@ -1577,6 +1590,12 @@ export async function restoreBackup(
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodLabelCiphertext",
|
||||
unopenedMoodLabels,
|
||||
unopenedMoodLabels,
|
||||
);
|
||||
|
||||
// Which tags the account hid. Delete-then-recreate like every
|
||||
// other section, and resolved by KEY: what people hide is almost
|
||||
|
||||
@@ -160,6 +160,11 @@
|
||||
* Writing it back would store a value no reader can open, so the FIELD is kept
|
||||
* out and the day or the symptom restores without it. Its key is the path in
|
||||
* the file (`cycleDayLogs.<date>.notesEncrypted`), never the value.
|
||||
*
|
||||
* The seventeenth, `moodLabelCiphertext`, is the same for the label of a mood
|
||||
* tag or mood category the account created
|
||||
* (`customMoodTags.<key>.labelEncrypted`): the tag or category restores, under
|
||||
* its key, without the person's own name for it.
|
||||
*/
|
||||
export type SkippedCatalogue =
|
||||
| "cycleSymptom"
|
||||
@@ -181,7 +186,8 @@ export type SkippedCatalogue =
|
||||
| "checkupClosure"
|
||||
| "accountSetting"
|
||||
| "symptomEpisodeReference"
|
||||
| "cycleCiphertext";
|
||||
| "cycleCiphertext"
|
||||
| "moodLabelCiphertext";
|
||||
|
||||
/** One key this instance does not know, and the links it cost. */
|
||||
export interface SkippedCatalogueKey {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Both ends of a sealed text column stored with the string codec
|
||||
* (`<keyId>.<base64>`), 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.
|
||||
*
|
||||
* 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
|
||||
* portable file carries the readable value (or the unreadable marker when
|
||||
* the writing host could not open it), and the restore seals it under the
|
||||
* receiving host's active key, because ciphertext from one host is noise on
|
||||
* another.
|
||||
*
|
||||
* Portable files written before v1.40 carried these columns as ciphertext.
|
||||
* They still restore: a ciphertext value is written back only when this
|
||||
* host's keys open it. One that does not open is kept out of the row and its
|
||||
* file path is recorded, so the caller can name it in the skip report rather
|
||||
* 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 { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** Open a sealed value for a portable file, or the unreadable marker. */
|
||||
export function openSealedForExport(sealed: string, field: string): string {
|
||||
try {
|
||||
return decrypt(sealed);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`${field} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/** A readable value the file could not supply: the writer could not open it. */
|
||||
export const UNOPENED: unique symbol = Symbol("unopened");
|
||||
|
||||
/**
|
||||
* The sealed column value for one free-text field from the file.
|
||||
*
|
||||
* Ciphertext is written back only when this host opens it — a value sealed
|
||||
* under a key this host does not hold (or the same key id over other key
|
||||
* material) would be a row nobody can read. A readable value is sealed under
|
||||
* this host's active key. Anything kept out is recorded under `path`.
|
||||
*/
|
||||
export function sealForRestore(
|
||||
sealed: string | null | undefined,
|
||||
readable: string | null | undefined | typeof UNOPENED,
|
||||
path: string,
|
||||
unopened: string[],
|
||||
): string | null {
|
||||
if (sealed) {
|
||||
try {
|
||||
decrypt(sealed);
|
||||
return sealed;
|
||||
} catch {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (readable === UNOPENED) {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
return readable ? encrypt(readable) : null;
|
||||
}
|
||||
@@ -717,10 +717,12 @@ const customMoodTagSchema = z
|
||||
isActive: z.boolean().default(true),
|
||||
icon: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
// The user's own words for the tag, encrypted at rest and carried
|
||||
// verbatim. Without it the tag comes back as a bare key and the person
|
||||
// who named it "Migräne" gets `custom:cm3x9…` instead.
|
||||
// The user's own words for the tag. Without it the tag comes back as a
|
||||
// bare key and the person who named it "Migräne" gets `custom:cm3x9…`
|
||||
// instead. Ciphertext on a disaster-recovery file, readable `label` on a
|
||||
// portable one; the restore seals the readable value under its own key.
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
// The scale a RATED factor was recorded on. `inverse` marks a factor
|
||||
// where a HIGH value is bad (stress, conflict). Dropping it does not lose
|
||||
// a label, it silently reverses the meaning of every rating already
|
||||
@@ -1642,7 +1644,9 @@ const customMoodTagCategorySchema = z
|
||||
icon: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
// Same split as the custom tag's label.
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* A portable file's custom mood labels survive a move to a host with other
|
||||
* keys.
|
||||
*
|
||||
* The labels of the mood tags and mood tag categories a person created 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 labels 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 label this host opens comes
|
||||
* back, one it cannot open stays out and is named in the skip report.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, decrypt, 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<string, string>, 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 CATEGORY_LABEL = "Evenings";
|
||||
const TAG_LABEL = "Migraine";
|
||||
|
||||
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: "mood-cross-key-owner",
|
||||
email: "mood-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: "MOOD_CROSS_KEY_RESTORE",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
return POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
|
||||
params: Promise.resolve({ id: backup.id }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One mood entry. The restore writes the account's own tags and categories
|
||||
* inside its mood-entry branch, so a file without entries never reaches them.
|
||||
*/
|
||||
async function seedMoodEntry(userId: string) {
|
||||
await getPrismaClient().moodEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
date: "2026-07-01",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
source: "MOODLOG",
|
||||
moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("custom mood labels in a portable file restored under another key", () => {
|
||||
it("come back readable under the receiving host's key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
useKeys(HOST_A, "a");
|
||||
await seedMoodEntry(owner.id);
|
||||
const category = await prisma.moodTagCategory.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
key: "customcat:cross-key",
|
||||
labelKey: "mood.category.custom",
|
||||
labelEncrypted: encrypt(CATEGORY_LABEL),
|
||||
},
|
||||
});
|
||||
await prisma.moodTag.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
categoryId: category.id,
|
||||
key: "custom:cross-key",
|
||||
labelKey: "mood.tag.custom",
|
||||
kind: "BINARY",
|
||||
labelEncrypted: encrypt(TAG_LABEL),
|
||||
},
|
||||
});
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as unknown;
|
||||
|
||||
// Host B is another database: the rows host A wrote are not there.
|
||||
useKeys(HOST_B, "b");
|
||||
await prisma.moodTag.deleteMany({ where: { userId: owner.id } });
|
||||
await prisma.moodTagCategory.deleteMany({ where: { userId: owner.id } });
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const restoredCategory = await prisma.moodTagCategory.findUniqueOrThrow({
|
||||
where: { key: "customcat:cross-key" },
|
||||
});
|
||||
expect(decrypt(restoredCategory.labelEncrypted!)).toBe(CATEGORY_LABEL);
|
||||
const restoredTag = await prisma.moodTag.findUniqueOrThrow({
|
||||
where: { key: "custom:cross-key" },
|
||||
});
|
||||
expect(decrypt(restoredTag.labelEncrypted!)).toBe(TAG_LABEL);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: { skipped: { links: number } };
|
||||
};
|
||||
expect(body.data.skipped.links).toBe(0);
|
||||
});
|
||||
|
||||
it("restores a legacy file's label ciphertext only where this host can open it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
useKeys(HOST_B, "b");
|
||||
const ownLabel = encrypt(TAG_LABEL);
|
||||
useKeys(HOST_B_IMPOSTOR, "b");
|
||||
const impostorLabel = encrypt(CATEGORY_LABEL);
|
||||
|
||||
// The shape a portable file had before the fix: labels as ciphertext.
|
||||
useKeys(HOST_B, "b");
|
||||
await seedMoodEntry(owner.id);
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
|
||||
file.customMoodTagCategories = [
|
||||
{
|
||||
id: "legacy-category-id",
|
||||
key: "customcat:legacy",
|
||||
labelKey: "mood.category.custom",
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: impostorLabel,
|
||||
},
|
||||
];
|
||||
file.customMoodTags = [
|
||||
{
|
||||
key: "custom:legacy",
|
||||
labelKey: "mood.tag.custom",
|
||||
categoryId: "legacy-category-id",
|
||||
kind: "BINARY",
|
||||
isActive: true,
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
labelEncrypted: ownLabel,
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
},
|
||||
];
|
||||
parseBackupPayload(JSON.stringify(file));
|
||||
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const category = await prisma.moodTagCategory.findUniqueOrThrow({
|
||||
where: { key: "customcat:legacy" },
|
||||
});
|
||||
expect(category.labelEncrypted).toBeNull();
|
||||
const tag = await prisma.moodTag.findUniqueOrThrow({
|
||||
where: { key: "custom:legacy" },
|
||||
});
|
||||
expect(decrypt(tag.labelEncrypted!)).toBe(TAG_LABEL);
|
||||
|
||||
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: "moodLabelCiphertext",
|
||||
key: "customMoodTagCategories.customcat:legacy.labelEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user