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