Files
webmail/components/offline/offline-host.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

148 lines
6.0 KiB
TypeScript

'use client';
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { addSignOutGate } from '@/lib/sign-out-cleanup';
import { reloadToNewBuild } from '@/lib/app-update';
import { buildSettingsPath } from '@/lib/deep-links';
import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode';
import { toast } from '@/stores/toast-store';
/**
* Runs offline mode (#523) in this tab: the JMAP client's offline hooks,
* the outbox, the wipes. Its notices become toasts, and a sign-out the
* user asked for while messages wait in the outbox asks first. Not mounted
* in Lite (no offline mode there).
*/
export function OfflineHost() {
const t = useTranslations('offline');
const tRef = useRef(t);
tRef.current = t;
const tConflicts = useTranslations('offlineConflicts');
const tConflictsRef = useRef(tConflicts);
tConflictsRef.current = tConflicts;
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;
const tDrafts = useTranslations('offlineDrafts');
const tDraftsRef = useRef(tDrafts);
tDraftsRef.current = tDrafts;
const { dialogProps, confirm } = useConfirmDialog();
const confirmRef = useRef(confirm);
confirmRef.current = confirm;
useEffect(() => {
const show = (notice: OfflineNotice) => {
const tr = tRef.current;
const openStorage = { label: tStorageRef.current('open_settings'), onClick: () => routerRef.current.push(buildSettingsPath('backup', 'offline')) };
switch (notice.kind) {
case 'queued':
toast.info(tr('toast.queued'));
break;
case 'sent':
toast.success(tr('toast.sent', { subject: notice.subject || tr('outbox.no_subject') }));
break;
case 'send-failed':
toast.error(tr('toast.send_failed', { subject: notice.subject || tr('outbox.no_subject') }), {
message: notice.error.code ? tr(`errors.${notice.error.code}`) : notice.error.text,
});
break;
case 'held':
toast.warning(tr('toast.held', { subject: notice.subject || tr('outbox.no_subject') }));
break;
case 'lost':
toast.warning(tr('toast.lost', { subjects: notice.subjects.map((s) => s || tr('outbox.no_subject')).join(', ') }), { duration: 0 });
break;
case 'conflict':
toast.warning(tConflictsRef.current('toast', { count: notice.count }));
break;
case 'draft-refused':
toast.error(tDraftsRef.current('toast_refused', { subject: notice.subject || tr('outbox.no_subject') }), {
message: notice.error.code ? tDraftsRef.current(notice.error.code) : notice.error.text,
});
break;
case 'draft-conflict':
toast.warning(tDraftsRef.current('toast_conflict', { subject: notice.subject || tr('outbox.no_subject') }));
break;
case 'storage-failed':
toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage });
break;
case 'default-on':
toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage });
break;
case 'legacy-stranded':
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
break;
case 'store-reset':
toast.warning(tConflictsRef.current('store_reset'), {
message: notice.lost ? tStorageRef.current('evicted_lost', { count: notice.lost }) : undefined,
duration: 0,
action: openStorage,
});
break;
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;
case 'refused-other':
toast.warning(tr('toast.refused_other'));
break;
case 'forbidden':
toast.warning(tr('toast.forbidden'));
break;
case 'back-online-reload':
toast.info(tr('toast.back_online'), {
duration: 0,
action: { label: tr('toast.reload'), onClick: () => { void reloadToNewBuild(); } },
});
break;
}
};
setOfflineNotifier(show);
const stop = startOfflineMode();
// Messages that would be lost: ask before a sign-out the user started.
const removeGate = addSignOutGate(async (accountId) => {
const { messages, changes } = await unsentCount(accountId);
if (messages === 0 && changes === 0) return true;
const tr = tRef.current;
const trConflicts = tConflictsRef.current;
const ok = await confirmRef.current({
title: messages > 0 ? tr('signout.title') : trConflicts('signout_changes_title'),
message: [
...(messages > 0 ? [tr('signout.message', { count: messages })] : []),
...(changes > 0 ? [trConflicts('signout_changes', { count: changes })] : []),
].join(' '),
confirmText: tr('signout.confirm'),
variant: 'destructive',
});
// The user agreed: no word about these messages afterwards.
if (ok) confirmLoss(accountId);
return ok;
});
return () => {
removeGate();
stop();
setOfflineNotifier(null);
};
}, []);
return <ConfirmDialog {...dialogProps} />;
}