From e64c6c7bc30f2d37ab5a8a1f7fdf8c5724301629 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:08:30 +0100 Subject: [PATCH 01/51] fix: keep data on a mistyped offline policy and use the shortest lock time for an unreadable one Forced OfflineEnabled values that make no sense read as forced off, which wipes kept mail. They now stop new mail being kept and delete nothing. An unreadable AppLockTimeoutMinutes now means the shortest time, so the existing out-of-range test changes from no value to 1: no value let the user's own choice win. - recommended AutoUpdate, AppLockRequired, TelemetryAllowed and CrashReportsAllowed are gone: nothing read them, or one acted as forced - auth lists and certificate rules can't name a whole top-level domain - ALL_WIRED is gone, as every key has a consumer --- lib/app-lock/__tests__/controller.test.ts | 3 + lib/app-lock/policy.ts | 8 +- lib/desktop/__tests__/policy-client.test.tsx | 7 ++ lib/desktop/__tests__/policy-schema.test.ts | 29 +++++-- lib/desktop/policy-schema.ts | 87 +++++++++++++++++-- lib/desktop/policy.ts | 5 +- lib/device-policy.ts | 6 +- .../__tests__/effective-scope-wiring.test.ts | 31 +++++++ lib/offline/offline-mode.ts | 3 +- 9 files changed, 161 insertions(+), 18 deletions(-) diff --git a/lib/app-lock/__tests__/controller.test.ts b/lib/app-lock/__tests__/controller.test.ts index ad3bd3121..1c2d41e91 100644 --- a/lib/app-lock/__tests__/controller.test.ts +++ b/lib/app-lock/__tests__/controller.test.ts @@ -770,6 +770,9 @@ describe('policy', () => { it('a forced timeout wins over the user\'s choice, a recommended one only over no choice', () => { withPolicy({ value: false, locked: false }, { value: 3, locked: true }); expect(effectiveTimeout(60)).toEqual({ minutes: 3, locked: true }); + // A value nobody could read: the user's choice was never what the admin meant. + (window.bulwarkDesktop as unknown as { policy: { get(): unknown } }).policy.get = () => ({ AppLockTimeoutMinutes: { value: null, locked: true, source: 'forced-machine', invalid: 'out_of_range' } }); + expect(effectiveTimeout(60)).toEqual({ minutes: 1, locked: true }); withPolicy({ value: false, locked: false }, { value: 3, locked: false }); expect(effectiveTimeout(60)).toEqual({ minutes: 60, locked: false }); expect(effectiveTimeout(undefined)).toEqual({ minutes: 3, locked: false }); diff --git a/lib/app-lock/policy.ts b/lib/app-lock/policy.ts index 718e2b27a..a12207dfa 100644 --- a/lib/app-lock/policy.ts +++ b/lib/app-lock/policy.ts @@ -5,8 +5,12 @@ */ import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { POLICY_SCHEMA } from '@/lib/desktop/policy-schema'; import { DEFAULT_TIMEOUT_MINUTES } from './state'; +const timeoutDefinition = POLICY_SCHEMA.AppLockTimeoutMinutes; +const SHORTEST_MINUTES = timeoutDefinition.type === 'integer' ? timeoutDefinition.min : 1; + interface PolicyEntry { value: T; locked: boolean; @@ -51,7 +55,9 @@ export interface EffectiveTimeout { export function effectiveTimeout(choice: number | undefined): EffectiveTimeout { const entry = read().AppLockTimeoutMinutes; const value = typeof entry?.value === 'number' && entry.value > 0 ? entry.value : null; - if (entry?.locked && value !== null) return { minutes: value, locked: true }; + // Forced to something nobody can read, by a shell that hands that on as nothing: the + // tightest there is, since the user's choice was never meant to apply. + if (entry?.locked) return { minutes: value ?? SHORTEST_MINUTES, locked: true }; return { minutes: choice ?? value ?? DEFAULT_TIMEOUT_MINUTES, locked: false }; } diff --git a/lib/desktop/__tests__/policy-client.test.tsx b/lib/desktop/__tests__/policy-client.test.tsx index 37670f0ac..dcc1ce17d 100644 --- a/lib/desktop/__tests__/policy-client.test.tsx +++ b/lib/desktop/__tests__/policy-client.test.tsx @@ -116,6 +116,13 @@ describe('in the app', () => { expect(devicesForAccount('some-account').noOffline).toBe(false); }, 60_000); + it('a forced value that makes no sense keeps offline off without forbidding it, which would wipe', async () => { + setBridgePolicy(snapshotOf({ OfflineEnabled: 'yes' })); + expect(desktopOfflinePolicy()).toEqual({ forced: false, fallback: null, invalid: true }); + const { devicesForAccount } = await import('@/lib/device-policy'); + expect(devicesForAccount('some-account').noOffline).toBe(false); + }, 60_000); + it('the hook settles on a bridge that copies what it returns, as contextBridge does', async () => { setBridgePolicy(snapshotOf({ PluginsMode: 'off' })); window.bulwarkDesktop!.policy!.get = () => JSON.parse(JSON.stringify(current)); diff --git a/lib/desktop/__tests__/policy-schema.test.ts b/lib/desktop/__tests__/policy-schema.test.ts index 3411ad9ea..3ad319aed 100644 --- a/lib/desktop/__tests__/policy-schema.test.ts +++ b/lib/desktop/__tests__/policy-schema.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_POLICY_SNAPSHOT, EMPTY_POLICY_LAYERS, POLICY_KEYS, + policyProblems, POLICY_CONSUMERS, POLICY_MAX_LIST_ITEMS, POLICY_SCHEMA, @@ -133,9 +134,9 @@ describe('hostile values fail closed', () => { expect(entry.raw!.length).toBeLessThanOrEqual(201); }); - it('an out of range timeout falls to no value, not to a huge one', () => { + it('an out of range timeout falls to the shortest, not to a huge one or the user\'s own', () => { const entry = resolvePolicies(layers({ forcedMachine: { AppLockTimeoutMinutes: 999999 } })).AppLockTimeoutMinutes; - expect(entry).toMatchObject({ value: null, invalid: 'out_of_range' }); + expect(entry).toMatchObject({ value: 1, invalid: 'out_of_range' }); }); }); @@ -322,6 +323,26 @@ describe('KeystoreAllowedOrigins', () => { }); }); +describe('what a recommendation can do', () => { + it.each(['AppLockRequired', 'TelemetryAllowed', 'CrashReportsAllowed', 'AutoUpdate'] as const)('%s: nothing, and the templates do not offer one', (key) => { + expect(POLICY_SCHEMA[key].forcedOnly).toBe(true); + expect(resolvePolicies({ ...EMPTY_POLICY_LAYERS, recommendedMachine: { [key]: 0 } })[key]).toMatchObject({ source: 'default', locked: false }); + expect(resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { [key]: 0 } })[key]).toMatchObject({ value: false, locked: true }); + }); +}); + +describe('values that fail open', () => { + it('a lock timeout nobody can read is the shortest there is', () => { + expect(resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { AppLockTimeoutMinutes: '99999' } }).AppLockTimeoutMinutes).toMatchObject({ value: 1, locked: true, invalid: 'out_of_range' }); + }); + + it('a wipe code too long to use is named as invalid', () => { + const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { WipeOfflineData: 'w'.repeat(200) } }); + expect(snapshot.WipeOfflineData).toMatchObject({ value: null, invalid: 'too_long' }); + expect(policyProblems(snapshot).map((problem) => problem.key)).toEqual(['WipeOfflineData']); + }); +}); + describe('every key has someone acting on it', () => { it('names a consumer or who owes one', () => { for (const key of POLICY_KEYS) { @@ -338,9 +359,7 @@ describe('every key has someone acting on it', () => { } }); - // The coordinator flips this when step 7 is done: then no key may be waiting. - const ALL_WIRED = false; - it.runIf(ALL_WIRED)('nobody is waiting any more', () => { + it('nobody is waiting any more', () => { expect(POLICY_KEYS.filter((key) => 'pendingConsumer' in POLICY_CONSUMERS[key])).toEqual([]); }); }); diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index 749a8361d..781726d39 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -86,9 +86,10 @@ interface PolicyBase { title: PolicyText; description: PolicyText; /** - * Only a forced value counts. Anything that decides what's allowed: it has no user - * choice to sit under, so a "recommended" one would act as forced without being - * shown as such, and only a forced value may loosen a forced one. + * Only a forced value counts. Anything that decides what's allowed, or a yes or no with + * nothing for a recommendation to set: it has no user choice to sit under, so a + * "recommended" one would act as forced without being shown as such, or do nothing, and + * only a forced value may loosen a forced one. */ forcedOnly?: boolean; } @@ -208,6 +209,7 @@ export const POLICY_SCHEMA: Record = { }, AutoUpdate: { category: 'updates', + forcedOnly: true, type: 'boolean', default: true, onInvalid: false, @@ -264,6 +266,7 @@ export const POLICY_SCHEMA: Record = { }, AppLockRequired: { category: 'security', + forcedOnly: true, type: 'boolean', default: false, onInvalid: true, @@ -290,6 +293,7 @@ export const POLICY_SCHEMA: Record = { }, TelemetryAllowed: { category: 'privacy', + forcedOnly: true, type: 'boolean', default: true, onInvalid: false, @@ -302,6 +306,7 @@ export const POLICY_SCHEMA: Record = { }, CrashReportsAllowed: { category: 'privacy', + forcedOnly: true, type: 'boolean', default: true, onInvalid: false, @@ -395,8 +400,8 @@ export const POLICY_SCHEMA: Record = { label: { en: 'Wipe code', de: 'Löschcode' }, title: { en: 'Delete mail kept on the device', de: 'Auf dem Gerät behaltene E-Mails löschen' }, description: { - en: 'Set this to a new value, such as today\'s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.', - de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.', + en: 'Set this to a new value, such as today\'s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. That memory is in the user\'s own profile, so someone who edits it can skip a wipe they know is coming: this cleans up a device, it doesn\'t take data from the person using it.', + de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. Diese Liste liegt im Profil des Benutzers. Wer sie bearbeitet, kann also eine Löschung umgehen, von der er weiß: Dies räumt ein Gerät auf, es nimmt der Person am Gerät keine Daten weg.', }, }, }; @@ -560,6 +565,19 @@ function parseShortcut(raw: unknown): Parsed { const HOST_LIST = /^[A-Za-z0-9*.,:_[\]-]+$/; +const COMMON_TOP_LEVEL = /^(?:[a-z]{2}|com|net|org|edu|gov|mil|int|info|biz|name|pro|mobi|app|dev|cloud|online|site|xyz|top|shop|store|tech|club|live|blog|page|arpa)$/; + +/** + * Chromium reads a plain name in these lists as "ends with", so `com` means every .com + * address, and `.com` is `*.com`. `intranet` or `sso` stay fine: that's how a server with + * no domain is named. + */ +function coversTopLevelDomain(host: string): boolean { + const name = host.replace(/:\d+$/, '').replace(/\.+$/, '').toLowerCase(); + if (name.startsWith('.')) return !/^\.+[^.]+\.[^.]/.test(name); + return COMMON_TOP_LEVEL.test(name); +} + /** What Chromium takes for its auth lists: names and wildcards, commas between them. */ function parseHostList(raw: unknown): Parsed { if (typeof raw !== 'string') return fail('not_text'); @@ -572,6 +590,7 @@ function parseHostList(raw: unknown): Parsed { // A wildcard must sit in front of a real domain of two labels or more: `*` or `*.com` // would hand Windows credentials to any site that asks. if (hosts.some((host) => host.includes('*') && !/^\*\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+(?::\d+)?$/.test(host))) return fail('invalid_host_list'); + if (hosts.some(coversTopLevelDomain)) return fail('invalid_host_list'); return { ok: true, value: hosts.join(',') }; } @@ -616,12 +635,15 @@ export function parseCertificateRule(raw: unknown): CertificateRule | null { const match = /^(?:https:\/\/)?(\[\*\.\]|\*\.)?([^/*[\]]+(?::\d+)?)\/?$/i.exec(pattern); if (!match) return null; let host: string; + let hostname: string; try { - host = new URL(`https://${match[2]}`).host; + ({ host, hostname } = new URL(`https://${match[2]}`)); } catch { return null; } if (!host) return null; + // A wildcard over a bare name is the whole of a top-level domain. + if (match[1] && !hostname.includes('.')) return null; if (filter !== undefined && (typeof filter !== 'object' || filter === null || Array.isArray(filter))) return null; const { ISSUER, SUBJECT } = (filter ?? {}) as { ISSUER?: unknown; SUBJECT?: unknown }; const issuer = certificateName(ISSUER); @@ -737,6 +759,8 @@ function failClosedValue(key: PolicyKey): PolicyValues[PolicyKey] { if (definition.type === 'list') return [] as PolicyValues[PolicyKey]; // A shortcut nobody can read doesn't get the built-in one instead. if (definition.type === 'string' && definition.format === 'shortcut') return 'off' as PolicyValues[PolicyKey]; + // A limit nobody can read is the tightest one, not the user's own. + if (definition.type === 'integer') return definition.min as PolicyValues[PolicyKey]; return definition.default as PolicyValues[PolicyKey]; } @@ -802,6 +826,53 @@ export function effectivePolicyValue(entry: { value: T; locked: boolean }, ch export const DEFAULT_POLICY_SNAPSHOT: PolicySnapshot = resolvePolicies(EMPTY_POLICY_LAYERS); +export interface PolicyProblem { + key: PolicyKey; + reason: string; + raw?: string; +} + +/** Forced values the app can't use, and lists with entries it had to leave out. */ +export function policyProblems(snapshot: PolicySnapshot): PolicyProblem[] { + const problems: PolicyProblem[] = []; + for (const key of POLICY_KEYS) { + const { invalid, dropped, raw } = snapshot[key]; + if (invalid) problems.push({ key, reason: invalid, ...(raw ? { raw } : {}) }); + else if (dropped) problems.push({ key, reason: 'entries_left_out' }); + } + return problems; +} + +/** What the webmail reads, in `lib/` and `components/`. A page gets nothing else. */ +const PAGE_POLICY_KEYS: readonly PolicyKey[] = [ + 'AllowStandalone', + 'AllowedJmapServers', + 'InstanceUrl', + 'PluginsMode', + 'PluginAllowlist', + 'OfflineEnabled', + 'AppLockRequired', + 'AppLockTimeoutMinutes', + 'CrashReportsAllowed', +]; + +/** + * The policies as a page gets them. A page can be any server someone chose, and the auth + * lists and certificate rules name an organisation's internal hosts, so those stay at their + * defaults. Nor does it get a bad value's text, or the address of a server: only whether + * one is forced. + */ +export function pagePolicySnapshot(snapshot: PolicySnapshot): PolicySnapshot { + const page = { ...DEFAULT_POLICY_SNAPSHOT } as Record; + for (const key of PAGE_POLICY_KEYS) { + const entry: PolicyEntry = { ...snapshot[key] }; + delete entry.raw; + if (key === 'InstanceUrl') (entry as { value: unknown }).value = null; + page[key] = entry; + } + return page as PolicySnapshot; +} + /** * Who acts on each key, so none can sit idle: the desktop repo's test and the webmail's * read the named file for the key. `pendingConsumer` names who still has to wire one. @@ -819,8 +890,8 @@ export const POLICY_CONSUMERS: Record = { WipeOfflineData: { repo: 'shell', file: 'src/main/features/policies.ts' }, AuthServerAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' }, AuthNegotiateDelegateAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' }, - AutoUpdate: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' }, - UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' }, + AutoUpdate: { repo: 'shell', file: 'src/main/update-rules.ts' }, + UpdateChannel: { repo: 'shell', file: 'src/main/update-rules.ts' }, AppLockRequired: { repo: 'webmail', file: 'lib/app-lock/policy.ts' }, AppLockTimeoutMinutes: { repo: 'webmail', file: 'lib/app-lock/policy.ts' }, TelemetryAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' }, diff --git a/lib/desktop/policy.ts b/lib/desktop/policy.ts index 5e9ec2f2f..d541d2229 100644 --- a/lib/desktop/policy.ts +++ b/lib/desktop/policy.ts @@ -102,9 +102,12 @@ export function customServerAllowedByDesktopPolicy(snapshot: PolicySnapshot = ge * Whether accounts may keep mail on this device: `forced` when the policy says so * either way, `fallback` for the default where the user hasn't chosen. Forced off * feeds `noOffline` (lib/device-policy.ts), which also clears what's there already. + * `invalid`: forced to something that makes no sense. Nothing new is kept, and a typo + * deletes nothing. */ -export function desktopOfflinePolicy(snapshot: PolicySnapshot = getDesktopPolicy()): { forced: boolean | null; fallback: boolean | null } { +export function desktopOfflinePolicy(snapshot: PolicySnapshot = getDesktopPolicy()): { forced: boolean | null; fallback: boolean | null; invalid?: true } { const { OfflineEnabled } = snapshot; + if (OfflineEnabled.locked && OfflineEnabled.invalid) return { forced: false, fallback: null, invalid: true }; if (OfflineEnabled.locked) return { forced: OfflineEnabled.value, fallback: null }; return { forced: null, fallback: OfflineEnabled.source === 'default' ? null : OfflineEnabled.value }; } diff --git a/lib/device-policy.ts b/lib/device-policy.ts index f5cf9dc3e..93486080a 100644 --- a/lib/device-policy.ts +++ b/lib/device-policy.ts @@ -83,8 +83,10 @@ export function devicesForAccount(subject: DeviceSubject): DevicePolicy { const accountSubject = subjectForAccount(accountId); const policy = accountSubject === store.subject ? store.policy : store.resolveFor(accountSubject); const devices = devicesOf(policy); - // The desktop app's OS policy says the same thing as the organisation's switch. - return desktopOfflinePolicy().forced === false ? { ...devices, noOffline: true } : devices; + // The desktop app's OS policy says the same thing as the organisation's switch. Not a + // value that makes no sense: noOffline wipes, and offlineAllowed asks about that one itself. + const offline = desktopOfflinePolicy(); + return offline.forced === false && !offline.invalid ? { ...devices, noOffline: true } : devices; } /** What applies to anyone, with no account's groups: for data of an account nobody here is signed in to. */ diff --git a/lib/offline/__tests__/effective-scope-wiring.test.ts b/lib/offline/__tests__/effective-scope-wiring.test.ts index 4bf221c12..d9590bc76 100644 --- a/lib/offline/__tests__/effective-scope-wiring.test.ts +++ b/lib/offline/__tests__/effective-scope-wiring.test.ts @@ -1,6 +1,7 @@ import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { EMPTY_POLICY_LAYERS, resolvePolicies } from '@/lib/desktop/policy-schema'; import { getOfflineHooks, setOfflineHooks } from '@/lib/jmap/offline-hooks'; import { setStoreBackend } from '@/lib/store/client/api'; import { FakeJmap } from '@/lib/store/__tests__/helpers/fake-jmap'; @@ -14,6 +15,8 @@ import { disableOffline, enableOffline, enforcePolicy, + offlineActive, + offlineAllowed, refreshQueueState, refreshUsage, registerOfflineHooks, @@ -62,6 +65,34 @@ afterEach(async () => { setStoreBackend(null); }); +describe('OfflineEnabled from the desktop app\'s OS policy', () => { + function forced(value: unknown): void { + const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { OfflineEnabled: value } }); + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'standalone', policy: { get: () => snapshot, onChange: () => () => {} } }; + } + + it('a value that makes no sense stops keeping mail and deletes nothing', async () => { + await enableOffline('acc'); + expect(useOfflineStore.getState().enabled).toEqual(['acc']); + + forced('yes'); + expect(offlineAllowed('acc')).toBe(false); + enforcePolicy(); + await vi.waitFor(() => expect(offlineActive('acc')).toBe(false)); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(useOfflineStore.getState().enabled).toEqual(['acc']); + expect(notices.map((notice) => notice.kind)).not.toContain('forbidden'); + }); + + it('forced off does delete', async () => { + await enableOffline('acc'); + forced(0); + enforcePolicy(); + await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([])); + expect(notices.map((notice) => notice.kind)).toContain('forbidden'); + }); +}); + describe('at page load, before the subject is known', () => { const forbiddenExceptGroup = { ...usePolicyStore.getState().rawPolicy, diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 2de80e0f6..0fc20a875 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -86,6 +86,7 @@ import { type OutboxRow, type UserChoice, } from './state'; +import { desktopOfflinePolicy } from '@/lib/desktop/policy'; import { effectiveScope, offlineDefaultOn, olderThanCap, platformDefault, userChoiceFor, type EffectiveScope } from './scope'; import { isStandaloneDisplayMode } from '@/lib/standalone-display'; import { readPersistence, requestPersistence, safariMayDelete } from './storage-health'; @@ -163,7 +164,7 @@ export function offlineSupported(): boolean { /** The organisation allows this account to keep mail on the device. */ export function offlineAllowed(account: string | null | undefined): boolean { - return offlineSupported() && !devicesForAccount(account ?? null).noOffline; + return offlineSupported() && !devicesForAccount(account ?? null).noOffline && !desktopOfflinePolicy().invalid; } /** A dropped account's data isn't judged by the groups of whoever is signed in now. */ From c844f2a5f3e6cb9fc98a0231832df21ee09cdc90 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:12:43 +0100 Subject: [PATCH 02/51] fix: finish a wipe in the desktop app, which never answers about shown notifications --- .../__tests__/system-notification.test.ts | 34 ++++++++++++++++++ lib/calendar-reminders/system-notification.ts | 6 +++- lib/notifications/shown.ts | 24 +++++++++++++ lib/offline/__tests__/device-wipe.test.ts | 35 +++++++++++++++++++ lib/offline/device-wipe.ts | 12 +++++-- 5 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 lib/calendar-reminders/__tests__/system-notification.test.ts create mode 100644 lib/notifications/shown.ts diff --git a/lib/calendar-reminders/__tests__/system-notification.test.ts b/lib/calendar-reminders/__tests__/system-notification.test.ts new file mode 100644 index 000000000..761d401bb --- /dev/null +++ b/lib/calendar-reminders/__tests__/system-notification.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { closeSystemReminders } from '../system-notification'; + +describe('closing reminders', () => { + afterEach(() => { + vi.unstubAllGlobals(); + delete (navigator as { serviceWorker?: unknown }).serviceWorker; + }); + + function withRegistration(getNotifications: (filter: { tag: string }) => Promise): void { + vi.stubGlobal('Notification', { permission: 'granted' }); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ getNotifications }) }, + }); + } + + it('closes what is shown', async () => { + const close = vi.fn(); + withRegistration(async () => [{ close } as unknown as Notification]); + await closeSystemReminders(['a']); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("doesn't wait for good on a shell that never answers", async () => { + // Electron leaves getNotifications() pending for ever. + withRegistration(() => new Promise(() => {})); + const answer = await Promise.race([ + closeSystemReminders(['a', 'b']).then(() => 'finished'), + new Promise((resolve) => setTimeout(() => resolve('hung'), 6_000)), + ]); + expect(answer).toBe('finished'); + }, 10_000); +}); diff --git a/lib/calendar-reminders/system-notification.ts b/lib/calendar-reminders/system-notification.ts index 2427f7c19..26dd022d1 100644 --- a/lib/calendar-reminders/system-notification.ts +++ b/lib/calendar-reminders/system-notification.ts @@ -5,6 +5,8 @@ * tab asks. */ +import { shownNotifications } from '@/lib/notifications/shown'; + export interface SystemReminder { key: string; title: string; @@ -65,7 +67,9 @@ export async function closeSystemReminders(keys: readonly string[]): Promise, + filter?: GetNotificationOptions, + waitMs = SHOWN_WAIT_MS, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + registration.getNotifications(filter), + new Promise((resolve) => { timer = setTimeout(() => resolve(null), waitMs); }), + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts index d0b44dd22..bf460e7cf 100644 --- a/lib/offline/__tests__/device-wipe.test.ts +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -1261,6 +1261,41 @@ describe('what else goes with a wipe', () => { }); }); +describe('a shell that never answers about shown notifications', () => { + // Electron has no answer to getNotifications(): the promise stays pending for good. + beforeEach(() => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + const registration = { + getNotifications: () => new Promise(() => {}), + pushManager: { getSubscription: async () => null }, + }; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { getRegistration: async () => registration, controller: null } }); + }); + + afterEach(() => { + delete (navigator as { serviceWorker?: unknown }).serviceWorker; + }); + + const ended = (work: Promise) => Promise.race([ + work.then(() => 'finished'), + new Promise((resolve) => setTimeout(() => resolve('hung'), 20_000)), + ]); + + it('a wipe of the whole device still gets to the keys and the files', async () => { + const before = purge.files; + expect(await ended(wipe.wipeWholeDevice('user'))).toBe('finished'); + expect(purge.files).toBeGreaterThan(before); + expect(wipeState.readPendingWipes()).toEqual({}); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + + it('a wipe of one account gets past them too, to its sign-out', async () => { + expect(await ended(wipe.wipeAccountFromDevice(ALICE, 'admin'))).toBe('finished'); + expect(wipeState.readPendingWipes()).toEqual({}); + expect(useAccountStore.getState().accounts.some((account) => account.id === ALICE)).toBe(false); + }); +}); + describe('the session timers', () => { const timerSignOut = () => useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts index faface568..74b2777bf 100644 --- a/lib/offline/device-wipe.ts +++ b/lib/offline/device-wipe.ts @@ -52,6 +52,7 @@ import { isAppLockedNow } from '@/lib/app-lock/state'; import { clearSealed, VAULT_DB } from '@/lib/app-lock/vault'; import { clearRescuedDrafts } from '@/lib/popout/rescued-drafts'; import { isPopoutWindow } from '@/lib/popout/window-role'; +import { shownNotifications } from '@/lib/notifications/shown'; import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; import { ACCOUNT_STORAGE_KEYS, onBeforeSignOut } from '@/lib/sign-out-cleanup'; import { @@ -101,6 +102,8 @@ export const CHECK_IN_EVERY_MS = 60 * 60 * 1000; const TICK_MS = 60 * 1000; /** How long a wipe waits to tell the instance before it goes on: it tries again at the next contact. */ const REPORT_BUDGET_MS = 3_000; +/** How long a wipe waits for the desktop app's main process: the keys come after this. */ +const SHELL_FILES_BUDGET_MS = 10_000; /** How long a wipe waits for one database a running plugin frame holds open. */ const DATABASE_BUDGET_MS = 2_000; @@ -208,7 +211,11 @@ async function clearShellFiles(): Promise { clipboard?: { clearFiles?: () => void | Promise }; } | null; bridge?.os?.clearSurfaces?.(); - await bridge?.clipboard?.clearFiles?.(); + let timer: ReturnType | undefined; + await Promise.race([ + Promise.resolve(bridge?.clipboard?.clearFiles?.()), + new Promise((resolve) => { timer = setTimeout(resolve, SHELL_FILES_BUDGET_MS); }), + ]).finally(() => clearTimeout(timer)); } /** A running frame is gone at the next load. Before any plugin starts again. */ @@ -256,7 +263,8 @@ async function pushRegistration(): Promise { /** A sender and a subject can sit on screen long after the mail is gone. */ async function closeShownNotifications(): Promise { const registration = await pushRegistration(); - for (const notification of await registration?.getNotifications?.() ?? []) notification.close(); + if (!registration?.getNotifications) return; + for (const notification of await shownNotifications(registration) ?? []) notification.close(); } /** The subscription is the browser's, shared by every account on this origin. */ From 66c6b61853da4add15ebf27adc911e8d890b4488 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:31:16 +0100 Subject: [PATCH 03/51] fix: keep downloaded mail out of the browser's HTTP cache --- lib/desktop/types.ts | 2 ++ lib/jmap/__tests__/no-http-cache.test.ts | 38 ++++++++++++++++++++++++ lib/jmap/client.ts | 5 +++- lib/sign-out-cleanup.ts | 5 +++- lib/store/__tests__/transport.test.ts | 15 ++++++++++ lib/store/engine/transport.ts | 3 +- 6 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 lib/jmap/__tests__/no-http-cache.test.ts diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 2e80e6479..217902572 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -655,6 +655,8 @@ export interface BulwarkDesktopBridge { /** Absent on older shells. */ certificates?: DesktopCertificatesBridge; appLock?: DesktopAppLockBridge; + /** Absent on an older shell. */ + httpCache?: { clear: () => Promise }; localImport?: DesktopLocalImportBridge; privacy?: DesktopPrivacyBridge; } diff --git a/lib/jmap/__tests__/no-http-cache.test.ts b/lib/jmap/__tests__/no-http-cache.test.ts new file mode 100644 index 000000000..a12f5a7d9 --- /dev/null +++ b/lib/jmap/__tests__/no-http-cache.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../client'; + +/** The browser's cache isn't encrypted, and Stalwart lets it keep a download for a year. */ +describe('what the client fetches stays out of the HTTP cache', () => { + afterEach(() => vi.unstubAllGlobals()); + + function clientWithFetch() { + const seen: Array<{ url: string; cache?: RequestCache }> = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + seen.push({ url: String(url), cache: init?.cache }); + return new Response('raw message', { status: 200 }); + })); + const client = JMAPClient.withBearer('https://mail.example.test', 'token', 'alice'); + const fields = client as unknown as { downloadUrl: string; accountId: string }; + fields.downloadUrl = 'https://mail.example.test/d/{accountId}/{blobId}/{name}?accept={type}'; + fields.accountId = 'a'; + return { client, seen }; + } + + it('a blob, as attachments and inline images use it', async () => { + const { client, seen } = clientWithFetch(); + await client.fetchBlob('b1', 'photo.png', 'image/png'); + expect(seen).toEqual([expect.objectContaining({ cache: 'no-store' })]); + }); + + it('a raw message for an export or a move', async () => { + const { client, seen } = clientWithFetch(); + await client.fetchBlobForTransfer('b2', 'a'); + expect(seen).toEqual([expect.objectContaining({ cache: 'no-store' })]); + }); + + it('bytes for a preview', async () => { + const { client, seen } = clientWithFetch(); + await client.fetchBlobArrayBuffer('b3', 'report.pdf', 'application/pdf'); + expect(seen).toEqual([expect.objectContaining({ cache: 'no-store' })]); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 2ac9dd25f..6b445018b 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1868,7 +1868,8 @@ export class JMAPClient implements IJMAPClient { const stopWatching = onConnectionsReset(giveUp); try { - const response = await fetch(url, { ...withJmapCredentials(init), headers, signal: controller.signal }); + // The browser's cache isn't encrypted, and Stalwart lets it keep a download for a year. + const response = await fetch(url, { ...withJmapCredentials(init), cache: 'no-store', headers, signal: controller.signal }); if (stopWatching) setTimeout(stopWatching, timeoutMs); return response; } catch (error) { @@ -2022,6 +2023,7 @@ export class JMAPClient implements IJMAPClient { if (response.redirected && response.status === 401) { return fetch(response.url, withJmapCredentials({ method: 'GET', + cache: 'no-store', headers: { 'Authorization': this.authHeader }, })); } @@ -2034,6 +2036,7 @@ export class JMAPClient implements IJMAPClient { return fetch(response.url, withJmapCredentials({ method: 'GET', + cache: 'no-store', headers: { 'Authorization': this.authHeader }, })); } diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index f364514a3..4c8227e9d 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -2,7 +2,7 @@ import { forgetAppLockAtSignOut } from '@/lib/app-lock/sign-out'; import { fileStorage } from '@/lib/plugin-storage'; import { clearHandoffs } from '@/lib/launch-handoff'; import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; -import { clearDesktopSurfaces } from '@/lib/desktop/bridge'; +import { clearDesktopSurfaces, getDesktopBridge } from '@/lib/desktop/bridge'; import { sweepPrivateExports } from '@/lib/mail-export/sinks'; import { purgeStoreFiles } from '@/lib/store/client/purge'; @@ -153,6 +153,9 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void { // Files and links on their way into a draft or an import. clearHandoffs(); clearDesktopSurfaces(); + // Nothing new goes in the HTTP cache, but a profile from an older version has mail in it. + // A browser gives a page no way to empty it. + void getDesktopBridge()?.httpCache?.clear().catch(() => {}); for (const cleanup of signedOutCleanups) { try { cleanup(options); diff --git a/lib/store/__tests__/transport.test.ts b/lib/store/__tests__/transport.test.ts index 780837583..76e6c5ed9 100644 --- a/lib/store/__tests__/transport.test.ts +++ b/lib/store/__tests__/transport.test.ts @@ -35,6 +35,21 @@ function transportWith(respond: (init: RequestInit) => Response | Promise vi.useRealTimers()); +describe('the HTTP cache', () => { + // Stalwart answers a download with `private, immutable, max-age=31536000`, and the browser's + // cache isn't encrypted: without this, a whole message sits in it in the clear for a year. + it('never gets a download or an answer', async () => { + const seen: Array = []; + const transport = transportWith((init) => { + seen.push(init); + return new Response('{"methodResponses":[]}', { status: 200 }); + }); + await transport.download('a', 'b', 'message/rfc822', 'message.eml'); + await transport.request([], []); + expect(seen.map((init) => init.cache)).toEqual(['no-store', 'no-store']); + }); +}); + describe('downloads', () => { it('gives a body piece by piece and adds them up for a plain download', async () => { const parts = [new Uint8Array([1, 2]), new Uint8Array([3])]; diff --git a/lib/store/engine/transport.ts b/lib/store/engine/transport.ts index ec2a6288c..ac17e7548 100644 --- a/lib/store/engine/transport.ts +++ b/lib/store/engine/transport.ts @@ -128,7 +128,8 @@ export class FetchTransport implements JmapTransport { if (!header) throw new TransportError('auth', 'No credentials yet'); if (this.aborted) throw new TransportError('network', 'The store is closing'); try { - return await doFetch(url, { ...init, signal: transfer.signal, headers: { ...init.headers, Authorization: header } }); + // The browser's cache isn't encrypted, and a download may stay in it for a year. + return await doFetch(url, { ...init, cache: 'no-store', signal: transfer.signal, headers: { ...init.headers, Authorization: header } }); } catch (error) { throw new TransportError('network', messageOf(error, 'Network failure'), undefined, undefined, undefined, transfer.timedOut()); } From 88d64b7f95a75dfed0d817ddbfe44653b5a4d5fd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:15:31 +0100 Subject: [PATCH 04/51] fix: protect the keys to the mail on this device for real The review found the store key readable from a copy of the disk. The desktop app only added its OS layer with the lock on, the record from before a lock stayed in LevelDB and opened the mail with no PIN, any frame could unwrap the PIN record and guess at it, a lock left the key in the renderer's memory, and a store waiting after a refused login went to whoever signed in next under the same username@host. - The desktop app wraps the store key with safeStorage, lock or no lock. A record without the layer gets it at its next open. A refusal is said out loud, and only a computer with no key store goes without, which settings say. So do they for a browser, which has no such layer. - A record that gained layers, and every record after a changed PIN, asks for a new key: `sqlite3_backup` into a new file under a new key while the store keeps working, one write to the record, then the old file goes. A changed PIN makes a new lock key, since the old PIN's record stays on disk too. The passkey and OS unlock go with the old key. - `wrap` and `unwrap` come through `claim()`, and the key under the PIN takes a secret from main, one counted trip per guess. - A lock on the desktop ends the renderer rather than reload in it. - A sign-in compares the waiting store's JMAP account and server first, and wipes on a mismatch. The account id keeps its form: every store, setting and server record of an account is keyed by it. - The 30 days also run in an app that stays open. Changes an existing assertion: turning the lock off now keeps the safeStorage layer, by decision, so that test expects it to stay. --- .../admin/org/sections/offline-section.tsx | 6 +- .../__tests__/key-protection-notes.test.tsx | 50 ++++++ components/offline/key-protection-notes.tsx | 49 ++++++ components/offline/offline-host.tsx | 9 ++ components/offline/offline-settings.tsx | 2 + components/settings/app-lock-settings.tsx | 10 +- lib/app-lock/__tests__/controller.test.ts | 104 ++++++++++++- lib/app-lock/__tests__/vault.test.ts | 105 ++++++++++++- lib/app-lock/controller.ts | 145 ++++++++++++++---- lib/app-lock/crypto.ts | 19 +++ lib/app-lock/shell.ts | 51 ++++++ lib/app-lock/vault.ts | 121 +++++++++++---- lib/desktop/types.ts | 20 ++- lib/offline/__tests__/device-wipe.test.ts | 38 +++++ lib/offline/__tests__/store-owner.test.ts | 60 ++++++++ lib/offline/device-wipe.ts | 3 +- lib/offline/offline-mode.ts | 30 +++- lib/offline/state.ts | 63 ++++++++ lib/sign-out-cleanup.ts | 1 + lib/store/__tests__/host.test.ts | 85 +++++++++- lib/store/__tests__/key-layers.test.ts | 108 +++++++++++++ lib/store/__tests__/page-side.test.ts | 88 +++++++++++ lib/store/adapters/wasm.ts | 51 +++++- lib/store/area.ts | 4 +- lib/store/client/api.ts | 73 ++++++++- lib/store/client/keys.ts | 101 +++++++++++- lib/store/client/purge.ts | 12 +- lib/store/client/store-client.ts | 9 +- lib/store/worker/host.ts | 104 +++++++++++++ lib/store/worker/protocol.ts | 2 + lib/store/worker/worker.ts | 8 + locales/ar/common.json | 12 +- locales/ca/common.json | 12 +- locales/cs/common.json | 12 +- locales/da/common.json | 12 +- locales/de/common.json | 12 +- locales/en/common.json | 12 +- locales/es/common.json | 12 +- locales/et/common.json | 12 +- locales/fa/common.json | 12 +- locales/fr/common.json | 12 +- locales/he/common.json | 12 +- locales/hu/common.json | 12 +- locales/it/common.json | 12 +- locales/ja/common.json | 12 +- locales/ko/common.json | 12 +- locales/lv/common.json | 12 +- locales/mn/common.json | 12 +- locales/nb/common.json | 12 +- locales/nl/common.json | 12 +- locales/pl/common.json | 12 +- locales/pt/common.json | 12 +- locales/ro/common.json | 12 +- locales/ru/common.json | 12 +- locales/sk/common.json | 12 +- locales/th/common.json | 12 +- locales/tr/common.json | 12 +- locales/uk/common.json | 12 +- locales/vi/common.json | 12 +- locales/zh-TW/common.json | 12 +- locales/zh/common.json | 12 +- stores/auth-store.ts | 6 +- 62 files changed, 1734 insertions(+), 163 deletions(-) create mode 100644 components/offline/__tests__/key-protection-notes.test.tsx create mode 100644 components/offline/key-protection-notes.tsx create mode 100644 lib/app-lock/shell.ts create mode 100644 lib/offline/__tests__/store-owner.test.ts create mode 100644 lib/store/__tests__/key-layers.test.ts diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 9a806ccbc..18f63f935 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -117,8 +117,10 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {

What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything), - with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not - against someone using the unlocked browser or app. The limits above can only lower what people pick: where several + with calendar, contacts and files, encrypted on the device. In a browser the key sits in the same profile, so this doesn't stop someone + who copies its files: use full-disk encryption. In the desktop app the key also takes that person's login on + that computer. Neither stops someone using the unlocked browser or app. An app lock adds a PIN, which someone with + a copy can guess at their own speed, so ask for a long one. The limits above can only lower what people pick: where several scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to them when it fetches the policy; the server can't check what is on a device. A sign-out someone asks for deletes everything. One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings. diff --git a/components/offline/__tests__/key-protection-notes.test.tsx b/components/offline/__tests__/key-protection-notes.test.tsx new file mode 100644 index 000000000..53ac50a25 --- /dev/null +++ b/components/offline/__tests__/key-protection-notes.test.tsx @@ -0,0 +1,50 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOfflineStore } from '@/lib/offline/state'; +import { KeyProtectionNotes } from '../key-protection-notes'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), +})); + +function withShell(wrap: boolean): void { + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { + version: 1, + platform: 'linux', + appLock: { status: async () => ({ wrap }) }, + }; +} + +describe('what settings say about the key', () => { + beforeEach(() => useOfflineStore.setState({ keyUnprotected: false, rekey: {} })); + afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + }); + + it('a browser says it has no layer of the OS', async () => { + await act(async () => { render(); }); + expect(screen.getByText('browser_note')).toBeTruthy(); + }); + + it('the desktop app with a real key store says nothing', async () => { + withShell(true); + await act(async () => { render(); }); + expect(screen.queryByTestId('key-protection-notes')).toBeNull(); + }); + + it('Linux with no secret service says secrets are only obfuscated', async () => { + withShell(false); + await act(async () => { render(); }); + expect(screen.getByText('no_secure_storage')).toBeTruthy(); + expect(screen.queryByText('browser_note')).toBeNull(); + }); + + it('says out loud when the key store refused, and how a re-encryption goes', async () => { + withShell(true); + useOfflineStore.setState({ keyUnprotected: true, rekey: { a: 40, b: 'failed' } }); + await act(async () => { render(); }); + expect(screen.getByText('wrap_failed')).toBeTruthy(); + expect(screen.getByText('rekey_failed')).toBeTruthy(); + expect(screen.getByText('rekey_progress:{"percent":40}')).toBeTruthy(); + }); +}); diff --git a/components/offline/key-protection-notes.tsx b/components/offline/key-protection-notes.tsx new file mode 100644 index 000000000..ee5a1a36c --- /dev/null +++ b/components/offline/key-protection-notes.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { useOfflineStore } from '@/lib/offline/state'; + +/** + * What protects the key to the mail on this device, where that's less than one would think, and + * how a re-encryption is getting on. `browser`: also says what a browser can't do. The app lock's + * own settings have said that already. + */ +export function KeyProtectionNotes({ browser = false }: { browser?: boolean }) { + const t = useTranslations('keyProtection'); + const keyUnprotected = useOfflineStore((state) => state.keyUnprotected); + const rekey = useOfflineStore((state) => state.rekey); + const [bridge, setBridge] = useState<'none' | 'wraps' | 'obfuscates'>('wraps'); + + useEffect(() => { + const member = getDesktopBridge()?.appLock; + if (!member) { + setBridge('none'); + return; + } + let live = true; + void member.status().then((status) => { + if (live) setBridge(status.wrap ? 'wraps' : 'obfuscates'); + }).catch(() => {}); + return () => { + live = false; + }; + }, []); + + const states = Object.values(rekey); + const running = states.filter((state): state is number => typeof state === 'number'); + const lines = [ + bridge === 'obfuscates' ? t('no_secure_storage') : null, + bridge === 'none' && browser ? t('browser_note') : null, + keyUnprotected ? t('wrap_failed') : null, + states.includes('failed') ? t('rekey_failed') : null, + running.length > 0 ? t('rekey_progress', { percent: Math.min(...running) }) : null, + ].filter((line): line is string => line !== null); + if (lines.length === 0) return null; + return ( +

+ {lines.map((line) =>

{line}

)} +
+ ); +} diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index f86d066a3..244db009c 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -27,6 +27,9 @@ export function OfflineHost() { const tStorage = useTranslations('offlineStorage'); const tStorageRef = useRef(tStorage); tStorageRef.current = tStorage; + const tKey = useTranslations('keyProtection'); + const tKeyRef = useRef(tKey); + tKeyRef.current = tKey; const router = useRouter(); const routerRef = useRef(router); routerRef.current = router; @@ -89,6 +92,12 @@ export function OfflineHost() { case 'store-unavailable': toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 }); break; + case 'key-unprotected': + toast.warning(tKeyRef.current('wrap_failed'), { duration: 0 }); + break; + case 'rekey-failed': + toast.warning(tKeyRef.current('rekey_failed'), { duration: 0 }); + break; case 'refused-delete': toast.warning(tr('toast.refused_delete')); break; diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 1bfd3706a..0ae50fbc3 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -15,6 +15,7 @@ import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { AccountStorage } from './account-storage'; import { OutboxList } from './outbox-list'; +import { KeyProtectionNotes } from './key-protection-notes'; import { WipeDeviceSection } from './wipe-device-section'; /** @@ -65,6 +66,7 @@ export function OfflineSettings() {