Files
webmail/components/offline/key-protection-notes.tsx
T
Linus Rath 88d64b7f95 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.
2026-11-04 04:15:31 +01:00

50 lines
1.9 KiB
TypeScript

'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 (
<div className="space-y-1 rounded-md border border-border bg-muted px-3 py-2 text-xs text-foreground" data-testid="key-protection-notes">
{lines.map((line) => <p key={line}>{line}</p>)}
</div>
);
}