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) =>