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] 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() {