/** * App lock in one tab: when it locks, what locking does, what unlocking does. * * Locking saves what's being written, closes the store, drops the keys, then reloads * the page. The reload is how memory gets cleared: no list of stores to reset can * be as complete as a new document, and it takes the plugin frames, the client's * credentials and every module cache along. The new document stops at the lock screen * (components/app-lock/app-lock-gate.tsx), so nothing starts until someone unlocks. * * The lock key lives in the memory of the tab that unlocked, and in no other. It * never goes over a channel. That tab holds a Web Lock for as long as it lives. When it * goes, whichever tab inherits the store couldn't open it, so every tab locks. * * In the desktop app, main has the last word on whether the app is locked (see * the shell's src/main/app-lock-gate.ts). A page can tell it to lock and can prove * the real unlock, and has no way to just say "unlocked". */ import { flushComposers, composeSnapshots } from '@/lib/compose-flush-registry'; import { getDesktopBridge } from '@/lib/desktop/bridge'; import { setAppLockCheck } from '@/lib/desktop/app-lock-state'; import { restoreUrlFor } from '@/lib/desktop/window-restore'; import { IS_LITE } from '@/lib/lite'; import { keepRescuedDraft } from '@/lib/popout/rescued-drafts'; import { closePopoutWindow, isPopoutWindow } from '@/lib/popout/window-role'; import { getStore, type StoreBackend } from '@/lib/store/client/api'; import { listAccountRecords, rewrapAccountKey, StoreKeyLayerMissingError, type WrapLayer } from '@/lib/store/client/keys'; import { OWNER_LOCK } from '@/lib/store/worker/protocol'; import type { ComposerDraftData } from '@/components/email/email-composer'; import { importLockKey, LOCK_LAYER, lockLayer, mainProof } from './crypto'; import { watchIdle } from './idle'; import { createPasskey, passkeyPrf, PasskeyNoPrfError } from './passkey'; import { appLockRequired, effectiveTimeout, onLockPolicyChange } from './policy'; import { scrubBrowserStorage } from './scrub'; import { APP_LOCK_CHANNEL, APP_LOCK_HOLDER, APP_LOCK_KEY, isAppLockedNow, postAppLockMessage, readAppLockFile, setLockedHere, writeAppLockFile, type AppLockFile, type AppLockMessage, } from './state'; import { addVaultPrf, changeVaultPin, createVault, deleteVault, openSealedDrafts, openVaultWithPin, openVaultWithPrf, readVault, removeVaultPrf, sealDraft, } from './vault'; export type AppLockPhase = /** Off, or on and unlocked. */ | 'open' /** On, and this tab asks whether another tab holds the key. */ | 'checking' /** An organisation requires it and no PIN exists yet. */ | 'setup' /** On its way to the lock screen: saving, closing. */ | 'locking' | 'locked' /** Locked, in a page that couldn't reload: the app is gone from the screen. */ | 'locked-stay'; /** Long enough for a draft save, and for a send under way to end. The screen is covered by then. */ const LOCK_FLUSH_MS = 15_000; const STORE_CLOSE_MS = 5_000; const OWNER_WAIT_MS = 5_000; const HOLDER_WAIT_MS = 1_500; const RELOAD_PROBE_MS = 2_000; const DRAFTS_SAVED_KEY = 'bulwark-app-lock-drafts-saved'; let booted = false; let phase: AppLockPhase = 'open'; const listeners = new Set<() => void>(); let hold: { promise: Promise; release: () => void } | null = null; /** Only ever in this tab's memory. Can't be read back out. */ let heldKey: CryptoKey | null = null; let releaseHolder: (() => void) | null = null; let holderWatch: AbortController | null = null; let stopIdle: (() => void) | null = null; function setPhase(next: AppLockPhase): void { if (phase === next) return; phase = next; for (const listener of [...listeners]) listener(); } export function getAppLockPhase(): AppLockPhase { return phase; } export function subscribeAppLockPhase(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } function newHold(): { promise: Promise; release: () => void } { let release = () => {}; const promise = new Promise((resolve) => { release = resolve; }); return { promise, release }; } /** While this is pending, the app under the gate doesn't hydrate. */ export function appLockHold(): Promise | null { return hold?.promise ?? null; } function markDocument(locked: boolean): void { if (typeof document === 'undefined') return; if (locked) document.documentElement.setAttribute('data-app-locked', ''); else document.documentElement.removeAttribute('data-app-locked'); } function hasAccounts(): boolean { try { const parsed = JSON.parse(localStorage.getItem('account-registry') ?? 'null') as { state?: { accounts?: unknown[] } } | null; return (parsed?.state?.accounts?.length ?? 0) > 0; } catch { return false; } } function setupNeeded(): boolean { return !readAppLockFile().on && appLockRequired() && hasAccounts(); } /** * Decides this tab's start, synchronously, in the first client render. A tab that * finds the lock on doesn't start the app until it knows a tab holds the key. */ export function bootAppLock(): void { if (booted || typeof window === 'undefined' || IS_LITE) return; booted = true; setAppLockCheck(isAppLockedNow); const file = readAppLockFile(); if (file.on) { setLockedHere(true); hold = newHold(); if (file.locked) { phase = 'locked'; tellDesktopLocked(); } else { phase = 'checking'; void settleCheck(); } return; } markDocument(false); if (setupNeeded()) { hold = newHold(); phase = 'setup'; } } async function holderAlive(): Promise { if (!navigator.locks?.query) return false; try { const { held } = await navigator.locks.query(); return (held ?? []).some((lock) => lock.name === APP_LOCK_HOLDER); } catch { return false; } } async function desktopSaysLocked(): Promise { const member = getDesktopBridge()?.appLock; if (!member) return false; try { return (await member.status()).locked; } catch { return true; } } async function settleCheck(): Promise { if (phase !== 'checking') return; if (await holderAlive() && !(await desktopSaysLocked())) { openUp(); return; } // Nobody holds the key: a crash, or the browser was closed without saying so. writeAppLockFile({ ...readAppLockFile(), locked: true }); tellDesktopLocked(); tellServiceWorker('locked'); setPhase('locked'); } function tellDesktopLocked(): void { getDesktopBridge()?.appLock?.lock(); } /** The service worker shows push notifications with no page around. Kept across its restarts. */ function tellServiceWorker(state: 'locked' | 'unlocked' | 'off'): void { try { const message = { type: 'app-lock', state }; const worker = navigator.serviceWorker; if (!worker) return; if (worker.controller) worker.controller.postMessage(message); else void worker.ready.then((registration) => registration.active?.postMessage(message)); } catch { // No service worker, no push notifications. } } function startIdle(): void { stopIdle?.(); const file = readAppLockFile(); if (!file.on) { stopIdle = null; return; } stopIdle = watchIdle({ timeoutMs: () => effectiveTimeout(readAppLockFile().timeoutMinutes).minutes * 60_000, onIdle: () => lockEverywhere(), }); } /** * A tab with no key watches for the one that has it to go. The request waits as long * as the holder lives. Being granted means it's gone. */ function watchHolder(): void { holderWatch?.abort(); holderWatch = null; if (!navigator.locks?.request || releaseHolder) return; const watch = new AbortController(); holderWatch = watch; void navigator.locks.request(APP_LOCK_HOLDER, { signal: watch.signal }, () => { if (heldKey) return keepHolder(); if (readAppLockFile().on && !readAppLockFile().locked) lockEverywhere(); return undefined; }).catch(() => {}); } function keepHolder(): Promise { return new Promise((resolve) => { releaseHolder = resolve; }); } async function becomeHolder(): Promise { holderWatch?.abort(); holderWatch = null; if (releaseHolder || !navigator.locks?.request) return; await new Promise((resolve) => { const timer = setTimeout(resolve, HOLDER_WAIT_MS); void navigator.locks.request(APP_LOCK_HOLDER, () => { clearTimeout(timer); resolve(); return keepHolder(); }).catch(() => resolve()); }); } function dropKey(): void { heldKey = null; releaseHolder?.(); releaseHolder = null; } /** The app starts, or goes on, in this tab. */ function openUp(): void { setLockedHere(false); markDocument(false); setPhase('open'); startIdle(); if (readAppLockFile().on && !heldKey) watchHolder(); hold?.release(); hold = null; } // ---- Locking ---- /** Every window and tab. Safe to call at any time; it only ever makes things more locked. */ export function lockEverywhere(): void { const file = readAppLockFile(); if (!file.on) return; writeAppLockFile({ ...file, locked: true }); postAppLockMessage({ type: 'lock' }); void applyLockHere(); } async function saveComposers(): Promise<{ closing: boolean; saved: number }> { const snapshots = composeSnapshots(); const flushed = await flushComposers(LOCK_FLUSH_MS); const after = composeSnapshots(); let closing = false; let saved = 0; const trouble = flushed.failed > 0 || flushed.timedOut; for (const snapshot of after) { const state = snapshot.state(); const draft = snapshot.draft(); if (trouble && state) { // Not on the server. Sealed, it waits for the lock key. await sealDraft(state).catch(() => {}); if (snapshot.ownWindow) closing = true; continue; } if (draft) saved += 1; if (!snapshot.ownWindow) continue; if (draft) { // This window's address names no text of its own after a reload: it opens this draft. window.history.replaceState(window.history.state, '', restoreUrlFor(draft)); } else { closing = true; } } if (snapshots.length > 0 && saved > 0) { try { sessionStorage.setItem(DRAFTS_SAVED_KEY, '1'); } catch { // Only the notice goes. } } return { closing, saved }; } /** A flush under way stops at its next item (lib/offline/flush.ts). This waits for that item. */ async function outboxAtRest(): Promise { if (!navigator.locks?.query) return; const until = Date.now() + LOCK_FLUSH_MS; while (Date.now() < until) { const { held } = await navigator.locks.query(); if (!(held ?? []).some((lock) => lock.name?.startsWith('bulwark-offline-flush:'))) return; await new Promise((resolve) => setTimeout(resolve, 100)); } } let reloadSeam: (() => void) | null = null; function reloadPage(): void { if (reloadSeam) reloadSeam(); else window.location.reload(); } /** Tests only: jsdom can't reload. */ export function setReloadForTests(run: (() => void) | null): void { reloadSeam = run; } async function pageReachable(): Promise { try { const response = await fetch(window.location.href, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(RELOAD_PROBE_MS) }); return response.status < 500; } catch { return false; } } async function applyLockHere(): Promise { if (phase === 'locking' || phase === 'locked' || phase === 'locked-stay') return; const wasRunning = phase === 'open'; setLockedHere(true); markDocument(true); setPhase('locking'); tellDesktopLocked(); tellServiceWorker('locked'); stopIdle?.(); stopIdle = null; holderWatch?.abort(); holderWatch = null; let closing = false; if (wasRunning) { try { ({ closing } = await saveComposers()); await outboxAtRest(); } catch { // The lock goes on: what's on screen is covered either way. } await scrubBrowserStorage(); } await lockStore(); dropKey(); if (closing) closePopoutWindow(); if (await pageReachable()) { reloadPage(); return; } // The server is out of reach and there's no copy of the page: a reload would land on the // browser's error page, with no way to unlock. Off the screen now, out of memory when it's back. setPhase('locked-stay'); window.addEventListener('online', () => { if (isAppLockedNow()) reloadPage(); }); } function storeBackend(): StoreBackend { return getStore(); } async function lockStore(): Promise { try { await storeBackend().lock?.(STORE_CLOSE_MS); } catch { // Its worker is stopped either way. } } // ---- Unlocking ---- async function holdAndOpen(raw: Uint8Array): Promise { try { const proof = await mainProof(raw); const key = await importLockKey(raw); await getDesktopBridge()?.appLock?.prove(proof).catch(() => false); heldKey = key; } finally { raw.fill(0); } await becomeHolder(); const key = heldKey!; await addLayerWhereMissing(key); await storeBackend().unlock?.(OWNER_WAIT_MS); writeAppLockFile({ ...readAppLockFile(), on: true, locked: false }); postAppLockMessage({ type: 'unlocked' }); tellServiceWorker('unlocked'); openUp(); await deliverSealedDrafts(key); } async function deliverSealedDrafts(key: CryptoKey): Promise { try { for (const state of await openSealedDrafts(key)) { if (isPopoutWindow()) postAppLockMessage({ type: 'unsealed-draft', state }); else keepRescuedDraft(state as ComposerDraftData); } } catch { // They stay sealed for the next unlock. } } export async function unlockWithPin(pin: string): Promise { await holdAndOpen(await openVaultWithPin(pin)); } export async function unlockWithPasskey(): Promise { const vault = await readVault(); if (!vault?.prf) throw new PasskeyNoPrfError(); const output = await passkeyPrf(vault.prf.credentialId, vault.prf.salt); if (!output) throw new PasskeyNoPrfError(); await holdAndOpen(await openVaultWithPrf(output)); } /** Windows Hello or Touch ID. Main asks, and only hands over the key after a yes. */ export async function unlockWithOs(): Promise { const member = getDesktopBridge()?.appLock; if (!member) return false; const answer = await member.osUnlock(); if (!answer.ok) return false; await holdAndOpen(new Uint8Array(answer.key)); return true; } // ---- The account keys ---- /** A store record made when the lock was off, or before a crash: it gets the layer now. */ async function addLayerWhereMissing(key: CryptoKey): Promise { const layer = lockLayer(key); for (const record of await listAccountRecords().catch(() => [])) { if (!record.layers.includes(LOCK_LAYER)) await rewrapAccountKey(record.account, [layer], [layer]); } } async function removeLayerEverywhere(key: CryptoKey): Promise { const layer = lockLayer(key); for (const record of await listAccountRecords().catch(() => [])) { if (record.layers.includes(LOCK_LAYER)) await rewrapAccountKey(record.account, [layer], []); } } /** What the store's key handling uses. Throws while this tab has no key, so nothing opens or gets written unwrapped. */ export function appLockKeyLayers(): readonly WrapLayer[] { if (!readAppLockFile().on) return []; if (!heldKey) throw new StoreKeyLayerMissingError(LOCK_LAYER); return [lockLayer(heldKey)]; } /** This tab inherited the store and has no key for it. */ export function onStoreKeyMissing(): void { lockEverywhere(); } async function ownerIsElsewhere(): Promise { if (!navigator.locks?.query) return false; const { held } = await navigator.locks.query(); return (held ?? []).some((lock) => lock.name === OWNER_LOCK) && !storeBackend().ownsStoreHere?.(); } // ---- Settings ---- export async function enableAppLock(pin: string): Promise { const raw = await createVault(pin); const proof = await mainProof(raw); const member = getDesktopBridge()?.appLock; if (member && !(await member.enroll(proof))) { // Main still knows an earlier lock this profile lost. It asks the user natively. if (!(await member.forget()) || !(await member.enroll(proof))) { raw.fill(0); await deleteVault(); throw new Error('The desktop app refused the lock'); } } const key = await importLockKey(raw); raw.fill(0); heldKey = key; await becomeHolder(); await addLayerWhereMissing(key); const file: AppLockFile = { ...readAppLockFile(), on: true, locked: false }; writeAppLockFile(file); try { localStorage.removeItem('email-snapshot'); } catch { // Storage blocked: there's no snapshot either. } postAppLockMessage({ type: 'enabled' }); tellServiceWorker('unlocked'); openUp(); // Another tab has the store open and no key for what comes next. Everyone starts over. if (await ownerIsElsewhere()) lockEverywhere(); } /** Off again. The PIN proves it's the owner of the lock, in this tab or any other. */ export async function disableAppLock(pin: string): Promise { if (appLockRequired()) throw new Error('The organisation requires the app lock'); const raw = await openVaultWithPin(pin); let key: CryptoKey; try { const proof = await mainProof(raw); await getDesktopBridge()?.appLock?.disable(proof); key = await importLockKey(raw); } finally { raw.fill(0); } await removeLayerEverywhere(key); await deleteVault(); writeAppLockFile({ on: false, locked: false }); postAppLockMessage({ type: 'off' }); tellServiceWorker('off'); afterOff(); } function afterOff(): void { stopIdle?.(); stopIdle = null; holderWatch?.abort(); holderWatch = null; dropKey(); setLockedHere(false); markDocument(false); setPhase('open'); hold?.release(); hold = null; } export function changeAppLockPin(currentPin: string, newPin: string): Promise { return changeVaultPin(currentPin, newPin); } export function setAppLockTimeout(minutes: number): void { writeAppLockFile({ ...readAppLockFile(), timeoutMinutes: minutes }); } export async function addPasskey(pin: string, appName: string): Promise { const passkey = await createPasskey(appName); const raw = await openVaultWithPin(pin); try { await addVaultPrf(raw, passkey.credentialId, passkey.salt, passkey.prfOutput); } finally { raw.fill(0); } } export function removePasskey(): Promise { return removeVaultPrf(); } export async function hasPasskey(): Promise { return Boolean((await readVault())?.prf); } export async function addOsUnlock(pin: string): Promise { const member = getDesktopBridge()?.appLock; if (!member) return false; const raw = await openVaultWithPin(pin); try { return await member.osEnroll(await mainProof(raw), raw); } finally { raw.fill(0); } } /** * The way out when the PIN is gone: all local data, and every login on this device. The * server keeps everything except what waited in the outbox here. */ export async function forgetEverything(): Promise { const member = getDesktopBridge()?.appLock; // Native: a page's word alone can't lift the desktop's lock. if (member && !(await member.forget())) return false; const { useAuthStore } = await import('@/stores/auth-store'); await Promise.race([ useAuthStore.getState().logoutAll({ endProviderSession: false }), new Promise((resolve) => setTimeout(resolve, STORE_CLOSE_MS)), ]).catch(() => {}); await deleteVault(); writeAppLockFile({ on: false, locked: false }); postAppLockMessage({ type: 'off' }); tellServiceWorker('off'); afterOff(); window.location.replace(new URL(process.env.NEXT_PUBLIC_BASE_PATH || '/', window.location.origin).href); return true; } /** A saved draft's notice, once, after the lock that saved it. */ export function takeDraftsSavedNotice(): boolean { try { if (sessionStorage.getItem(DRAFTS_SAVED_KEY) === null) return false; sessionStorage.removeItem(DRAFTS_SAVED_KEY); return true; } catch { return false; } } // ---- The tab's life ---- function onMessage(message: AppLockMessage): void { switch (message.type) { case 'lock': void applyLockHere(); break; case 'unlocked': if (phase === 'locked' || phase === 'checking' || phase === 'locked-stay') { void desktopSaysLocked().then((locked) => { if (!locked) openUp(); }); } break; case 'enabled': if (phase === 'setup') { openUp(); break; } startIdle(); if (!heldKey) watchHolder(); break; case 'off': afterOff(); break; case 'main-ping': if (!isPopoutWindow()) postAppLockMessage({ type: 'main-pong' }); break; case 'focus-main': if (!isPopoutWindow()) window.focus(); break; case 'unsealed-draft': if (!isPopoutWindow()) keepRescuedDraft(message.state as ComposerDraftData); break; } } /** Runs for as long as the tab does, above the part of the page that the lock holds back. */ export function startAppLock(): () => void { if (IS_LITE || typeof window === 'undefined') return () => {}; bootAppLock(); let channel: BroadcastChannel | null = null; if (typeof BroadcastChannel !== 'undefined') { channel = new BroadcastChannel(APP_LOCK_CHANNEL); channel.onmessage = (event: MessageEvent) => onMessage(event.data); } const onStorage = (event: StorageEvent) => { if (event.key !== APP_LOCK_KEY) return; const file = readAppLockFile(); if (file.on && file.locked && phase === 'open') void applyLockHere(); }; window.addEventListener('storage', onStorage); const stopDesktop = getDesktopBridge()?.appLock?.onLockRequest(() => lockEverywhere()); const reevaluate = () => { if (phase === 'open' && setupNeeded()) setPhase('setup'); }; const stopPolicy = onLockPolicyChange(reevaluate); if (phase === 'open' && readAppLockFile().on) { startIdle(); if (!heldKey) watchHolder(); } if (readAppLockFile().on) tellServiceWorker(isAppLockedNow() ? 'locked' : 'unlocked'); return () => { channel?.close(); window.removeEventListener('storage', onStorage); stopDesktop?.(); stopPolicy(); stopIdle?.(); stopIdle = null; }; } /** An account appeared in a tab that started with none: a required lock is due now. */ export function recheckAppLockSetup(): void { if (phase === 'open' && setupNeeded()) setPhase('setup'); } /** Tests only. */ export function resetAppLockForTests(): void { booted = false; phase = 'open'; hold = null; heldKey = null; releaseHolder = null; holderWatch = null; stopIdle?.(); stopIdle = null; listeners.clear(); setLockedHere(false); }