From 78cde56542451a81610e06aedb594624b84ccf3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:49:24 +0200 Subject: [PATCH] fix(environment): fail closed on a travel period that does not open A location period whose sealed location this host cannot open was dropped from the resolver, so its days fell through to the home and the next refresh overwrote the stored travel days with the weather at home. Those days are now skipped and the run logs an error signal. A restore now opens every sealed location once. One that does not open is kept sealed, so the refresh keeps skipping its days, and is named in the restore report under a new catalogue. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- src/components/admin/backups-section.tsx | 5 ++ .../__tests__/service-air-quality.test.ts | 35 +++++++++ src/lib/environment/service.ts | 69 +++++++++++++++--- src/lib/export/environment-backup.ts | 73 +++++++++++++++---- src/lib/export/restore-backup.ts | 1 + src/lib/export/restore-skips.ts | 3 +- .../environment-location-encryption.test.ts | 48 ++++++++++++ 14 files changed, 223 insertions(+), 32 deletions(-) diff --git a/messages/de.json b/messages/de.json index 445c875ee..95e994717 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7811,7 +7811,8 @@ "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)", "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)" + "restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)", + "restoreSkippedEnvironmentLocationCiphertext": "Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index c474dd409..d85f09645 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7811,7 +7811,8 @@ "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)", "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)" + "restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)", + "restoreSkippedEnvironmentLocationCiphertext": "Location of an environment period or day sealed with a key this server does not hold (kept sealed, those days are not refreshed)" }, "danger-zone": { "title": "Danger zone", diff --git a/messages/es.json b/messages/es.json index 45dd83155..dd6e662a1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7811,7 +7811,8 @@ "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)", "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)" + "restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)", + "restoreSkippedEnvironmentLocationCiphertext": "Ubicación de un periodo o día ambiental cifrada con una clave que este servidor no tiene (se conserva cifrada, esos días no se actualizan)" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 49cbf7748..adb6dca04 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7811,7 +7811,8 @@ "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)", "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)" + "restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)", + "restoreSkippedEnvironmentLocationCiphertext": "Lieu d'une période ou d'un jour environnemental chiffré avec une clé que ce serveur ne possède pas (conservé chiffré, ces jours ne sont pas actualisés)" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 7e31189e0..07746fed7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7811,7 +7811,8 @@ "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)", "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)" + "restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)", + "restoreSkippedEnvironmentLocationCiphertext": "Luogo di un periodo o giorno ambientale cifrato con una chiave che questo server non possiede (mantenuto cifrato, quei giorni non vengono aggiornati)" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index 32c245fac..04de60d80 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7787,7 +7787,8 @@ "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)", "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)", "restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)", - "restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)" + "restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)", + "restoreSkippedEnvironmentLocationCiphertext": "이 서버에 없는 키로 암호화된 환경 기간 또는 날짜의 위치(암호화된 채 유지되며 해당 날짜는 새로 가져오지 않음)" }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index 286d25498..1a6857b27 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7811,7 +7811,8 @@ "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)", "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)" + "restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)", + "restoreSkippedEnvironmentLocationCiphertext": "Lokalizacja okresu lub dnia środowiskowego zaszyfrowana kluczem, którego ten serwer nie ma (zachowana w postaci zaszyfrowanej, te dni nie są odświeżane)" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index e890109ec..9a15c499a 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -471,6 +471,11 @@ function catalogueLabel( if (catalogue === "vaccinationCiphertext") { return t("admin.section.backups.restoreSkippedVaccinationCiphertext"); } + if (catalogue === "environmentLocationCiphertext") { + return t( + "admin.section.backups.restoreSkippedEnvironmentLocationCiphertext", + ); + } if (catalogue === "moodLabelCiphertext") { return t("admin.section.backups.restoreSkippedMoodLabelCiphertext"); } diff --git a/src/lib/environment/__tests__/service-air-quality.test.ts b/src/lib/environment/__tests__/service-air-quality.test.ts index ce9203c55..3d3915d8d 100644 --- a/src/lib/environment/__tests__/service-air-quality.test.ts +++ b/src/lib/environment/__tests__/service-air-quality.test.ts @@ -139,6 +139,41 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()); describe("fetchAndStoreEnvironment", () => { + it("skips the days of a travel period whose sealed location does not open, never resolving them to home", async () => { + air.mockResolvedValue({ days: [], stopped: null }); + const damaged = new Uint8Array([2, 2, 118, 49, ...new Array(40).fill(7)]); + db.travel = [ + { + startDate: "2026-05-02", + endDate: "2026-05-03", + lat: null, + lon: null, + label: null, + locationEncrypted: damaged, + }, + ]; + weather.mockImplementation( + async (args: { startDate: string; endDate: string }) => { + const out = []; + for (let d = 1; d <= 4; d++) { + const day = `2026-05-0${d}`; + if (day >= args.startDate && day <= args.endDate) out.push(obs(day)); + } + return out; + }, + ); + const result = await fetchAndStoreEnvironment({ + userId: "u1", + startDate: "2026-05-01", + endDate: "2026-05-04", + }); + const writtenDays = db.upserts.map((u) => u.create.date); + expect(writtenDays).not.toContain("2026-05-02"); + expect(writtenDays).not.toContain("2026-05-03"); + expect(writtenDays.sort()).toEqual(["2026-05-01", "2026-05-04"]); + expect(result.skipped).toBe(2); + }); + it("asks both feeds with the coarse home only and writes the day sealed", async () => { air.mockResolvedValue({ days: [airDay("2026-05-01")], stopped: null }); const result = await fetchAndStoreEnvironment({ diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts index da0b8e0a3..b0e5b9277 100644 --- a/src/lib/environment/service.ts +++ b/src/lib/environment/service.ts @@ -42,6 +42,7 @@ import { type DailyAirQualityObservation, } from "@/lib/environment/open-meteo-air-quality"; import { readLocation, sealLocation } from "@/lib/environment/location-cipher"; +import { emitSignal } from "@/lib/logging/signal"; export { isAirQualityActive }; import { OpenMeteoBudgetExhaustedError } from "@/lib/environment/request-budget"; @@ -148,7 +149,10 @@ function groupKey(loc: ResolvedLocation): string { export interface FetchAndStoreResult { /** Days that resolved to a location and were upserted. */ stored: number; - /** Days skipped because no location resolved (no home, no override). */ + /** + * Days skipped because no location resolved (no home, no override), or + * because they fall in a period whose sealed location does not open. + */ skipped: number; /** Distinct upstream fetches made. */ fetches: number; @@ -203,8 +207,23 @@ export function resolveHome(user: { }; } -/** Every dated location period of an account, opened. */ -async function readTravelOverrides(userId: string): Promise { +/** A location period whose sealed location this host cannot open. */ +interface UnreadablePeriod { + startDate: string; + endDate: string; +} + +/** + * Every dated location period of an account, opened, plus the periods whose + * sealed location did not open (a key missing from `ENCRYPTION_KEYS`, a + * damaged value). The second list exists so the caller can fail closed: a + * period it cannot read still says "this account was not at home on these + * days", so those days must not fall through to the home location. + */ +async function readTravelOverrides(userId: string): Promise<{ + overrides: TravelOverride[]; + unreadable: UnreadablePeriod[]; +}> { const records = await prisma.environmentTravelLocation.findMany({ where: { userId }, select: { @@ -216,17 +235,33 @@ async function readTravelOverrides(userId: string): Promise { locationEncrypted: true, }, }); - return records.flatMap((row) => { + const overrides: TravelOverride[] = []; + const unreadable: UnreadablePeriod[] = []; + for (const row of records) { const location = readLocation({ sealed: row.locationEncrypted, lat: row.lat, lon: row.lon, label: row.label, }); - return location - ? [{ startDate: row.startDate, endDate: row.endDate, ...location }] - : []; - }); + if (location) { + overrides.push({ + startDate: row.startDate, + endDate: row.endDate, + ...location, + }); + } else if (row.locationEncrypted && row.locationEncrypted.byteLength > 0) { + // `readLocation` returns null for a present sealed value only when it + // does not open. + unreadable.push({ startDate: row.startDate, endDate: row.endDate }); + } + } + return { overrides, unreadable }; +} + +/** True when `day` falls inside one of `periods`. */ +function inAnyPeriod(day: string, periods: readonly UnreadablePeriod[]) { + return periods.some((p) => day >= p.startDate && day <= p.endDate); } /** @@ -251,7 +286,17 @@ export async function fetchAndStoreEnvironment(args: { } const home = resolveHome(user); - const travelRows = await readTravelOverrides(userId); + const travel = await readTravelOverrides(userId); + if (travel.unreadable.length > 0) { + // Fail closed: the days of a period this host cannot open are skipped, + // never resolved to the home location, so a stored travel day is not + // overwritten with the weather at home. + emitSignal({ + action: "environment.travel_location.undecryptable", + level: "error", + meta: { periods: travel.unreadable.length }, + }); + } const airQuality = isAirQualityActive(user.environmentAirQualityEnabled); // The timezone used to enumerate AND fetch days — one tz keeps the stored @@ -262,7 +307,11 @@ export async function fetchAndStoreEnvironment(args: { const groups = new Map(); let skipped = 0; for (const day of days) { - const loc = resolveLocationForDay(day, home, travelRows); + if (inAnyPeriod(day, travel.unreadable)) { + skipped += 1; + continue; + } + const loc = resolveLocationForDay(day, home, travel.overrides); if (!loc) { skipped += 1; continue; diff --git a/src/lib/export/environment-backup.ts b/src/lib/export/environment-backup.ts index 658aeb832..dc344e535 100644 --- a/src/lib/export/environment-backup.ts +++ b/src/lib/export/environment-backup.ts @@ -79,7 +79,15 @@ import { Buffer } from "node:buffer"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { EnvironmentLocationSource } from "@/generated/prisma/client"; import type { AirQualityDay } from "@/lib/environment/air-quality-contract"; -import { readLocation, sealLocation } from "@/lib/environment/location-cipher"; +import { + openLocation, + readLocation, + sealLocation, +} from "@/lib/environment/location-cipher"; +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; export interface EnvironmentBackupOptions { purpose?: "portable-export" | "disaster-recovery"; @@ -450,18 +458,35 @@ export type RestoredEnvironmentTravelLocation = Pick< * value as it came, or the file's readable location sealed under this host's * key (rounded to the privacy floor first, so a file written before v1.39.4 * with two decimals comes back coarse). Null when the file carries neither. + * + * A sealed value is opened once to check it. One this host cannot open is + * still written as it came, and its path is added to `unopened` so the + * restore report names it. Writing it without a location would be worse: a + * period without a location reads as no period at all, and the next refresh + * would rewrite those days with the weather at home. Kept sealed, the + * refresh skips them (`fetchAndStoreEnvironment` fails closed), and the + * location comes back once the missing key is added. */ -function restoredLocation(entry: { - locationEncrypted?: string | null; - lat: number | null; - lon: number | null; - label: string | null; -}): Uint8Array | null { +function restoredLocation( + entry: { + locationEncrypted?: string | null; + lat: number | null; + lon: number | null; + label: string | null; + }, + path: string, + unopened: string[], +): Uint8Array | null { if (typeof entry.locationEncrypted === "string") { const buffer = Buffer.from(entry.locationEncrypted, "base64"); if (buffer.byteLength > 0) { const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength)); bytes.set(buffer); + try { + openLocation(bytes); + } catch { + unopened.push(path); + } return bytes; } } @@ -495,7 +520,10 @@ export async function restoreEnvironmentData( tx: Prisma.TransactionClient, ownerId: string, payload: EnvironmentRestoreInput, + skips?: RestoreSkipLog, ): Promise { + // Sealed locations this host's keys do not open, by file path. + const unopened: string[] = []; const [clearedTravel, clearedContexts] = await Promise.all([ tx.environmentTravelLocation.deleteMany({ where: { userId: ownerId } }), tx.environmentContext.deleteMany({ where: { userId: ownerId } }), @@ -510,7 +538,11 @@ export async function restoreEnvironmentData( startDate: entry.startDate, endDate: entry.endDate, // Sealed, never readable: see `restoredLocation`. - locationEncrypted: restoredLocation(entry), + locationEncrypted: restoredLocation( + entry, + `environmentTravelLocations.${entry.startDate}..${entry.endDate}`, + unopened, + ), lat: null, lon: null, label: null, @@ -525,12 +557,16 @@ export async function restoreEnvironmentData( data: payload.environmentContexts.map((entry) => ({ userId: ownerId, date: entry.date, - locationEncrypted: restoredLocation({ - locationEncrypted: entry.locationEncrypted, - lat: entry.lat, - lon: entry.lon, - label: entry.locationLabel, - }), + locationEncrypted: restoredLocation( + { + locationEncrypted: entry.locationEncrypted, + lat: entry.lat, + lon: entry.lon, + label: entry.locationLabel, + }, + `environmentContexts.${entry.date}`, + unopened, + ), lat: null, lon: null, locationLabel: null, @@ -562,6 +598,15 @@ export async function restoreEnvironmentData( }); } + if (skips) { + recordUnknownKeys( + skips, + "environmentLocationCiphertext", + unopened, + unopened, + ); + } + return { environmentContexts: clearedContexts.count, environmentTravelLocations: clearedTravel.count, diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 55275dbd0..06eeeabf9 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -2660,6 +2660,7 @@ export async function restoreBackup( tx, ownerId, payload, + skips, ); const cleared = { diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index c8bb1fd92..e73dcb712 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -195,7 +195,8 @@ export type SkippedCatalogue = | "cycleCiphertext" | "moodLabelCiphertext" | "visitCiphertext" - | "vaccinationCiphertext"; + | "vaccinationCiphertext" + | "environmentLocationCiphertext"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/tests/integration/environment-location-encryption.test.ts b/tests/integration/environment-location-encryption.test.ts index c2e18188c..ff5e25456 100644 --- a/tests/integration/environment-location-encryption.test.ts +++ b/tests/integration/environment-location-encryption.test.ts @@ -38,6 +38,7 @@ function throughFile(section: unknown): EnvironmentRestoreInput { return JSON.parse(JSON.stringify(section)) as EnvironmentRestoreInput; } +import type { RestoreSkipLog } from "@/lib/export/restore-skips"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -426,6 +427,53 @@ describe("the environment backup with sealed locations", () => { ).toBe(true); }); + it("keeps a sealed location this host cannot open sealed, and reports it", async () => { + const prisma = getPrismaClient(); + const damaged = Buffer.from([2, 2, 118, 49, ...new Array(40).fill(7)]); + const skips: RestoreSkipLog = []; + await prisma.$transaction((tx) => + restoreEnvironmentData( + tx, + OWNER, + { + environmentTravelLocations: [ + { + startDate: "2026-08-01", + endDate: "2026-08-10", + lat: null, + lon: null, + label: null, + locationEncrypted: damaged.toString("base64"), + }, + ], + environmentContexts: [ + { + date: "2026-08-02", + lat: null, + lon: null, + locationLabel: null, + source: "TRAVEL", + locationEncrypted: damaged.toString("base64"), + tempMin: 20, + }, + ], + }, + skips, + ), + ); + expect(skips.map((s) => [s.catalogue, s.key]).sort()).toEqual([ + ["environmentLocationCiphertext", "environmentContexts.2026-08-02"], + [ + "environmentLocationCiphertext", + "environmentTravelLocations.2026-08-01..2026-08-10", + ], + ]); + const period = await prisma.environmentTravelLocation.findFirstOrThrow({ + where: { userId: OWNER }, + }); + expect(Buffer.from(period.locationEncrypted!).equals(damaged)).toBe(true); + }); + it("restores a file from before air quality as a day never fetched", async () => { const prisma = getPrismaClient(); await prisma.$transaction((tx) =>