Files
webmail/lib/store/client/api.ts
T
Linus Rath fb3c82eed5 feat: lock the app with a PIN, a passkey or Windows Hello, with mail out of memory and the local store closed
The lock is off by default, and an OS policy can require it and set the
timeout. It locks after a time without use counted across all windows and
tabs, on OS screen lock in the desktop app, and from Lock now.

Locking saves open composers, stops the outbox at its next message, closes
the store and stops its worker with every key, then reloads the page, so
nothing of the old page stays in reach. The new page holds the app back at a
Suspense boundary until someone unlocks. Pop-outs get no session while
locked, push notifications name nobody, and subjects leave plain storage.

One random lock key wraps each account's key as a layer. The PIN wraps the
lock key with PBKDF2 at 600,000 iterations, with a wait after wrong tries
that a reload doesn't reset. A passkey with PRF and the desktop's OS storage
wrap it too. Details and limits are in the decisions file.
2026-11-03 13:08:37 +01:00

324 lines
12 KiB
TypeScript

/**
* The page's way to the local store: one StoreClient per tab, accounts opened
* while this tab is the owner, sessions and auth handed over. Knows nothing of
* the app's stores: lib/offline/offline-mode.ts wires those in with
* configureStore, so the worker bundle stays free of them.
*/
import { DEFAULT_SCOPE, type StoreEvent, type StoreScope } from '../area';
import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport';
import { forgetAccountKey, getAccountRecord, knownStoreFiles, listAccountRecords, openAccountKey, type StoreKeyLayerMissingError, type WrapLayer } from './keys';
import { StoreClient } from './store-client';
export interface StoreAccountInputs {
session: StoreSession | null;
auth: string | null;
scope?: StoreScope;
invertedKeywordSort: boolean | null;
}
export interface StorePageHooks {
/** What this tab knows about an account right now. */
inputs(account: string): StoreAccountInputs;
/** Runs the client's refresh path; the fresh Authorization header, or null. */
refreshAuth(account: string): Promise<string | null>;
onEvent(event: StoreEvent): void;
/** Wrap layers on top of the WebCrypto one (desktop keychain, app lock). */
keyLayers?(): readonly WrapLayer[];
/** An account this device kept mail for has an empty store now: its key or its file went. */
onStoreLost?(account: string): void;
/** This tab became the owner and has no lock key: the app lock has to ask again. */
onKeyLocked?(): void;
}
/** What lib/offline uses. A seam, so its tests run the real engine on node:sqlite. */
export interface StoreBackend {
supported(): boolean;
/** Asks for the account to be open. `session` is for a first open with no client yet. */
ensureOpen(account: string, session?: StoreSession | null): Promise<void>;
/** Hands the owner what this tab now knows: session, auth, sort polarity. */
refresh(account: string): void;
answer(account: string, calls: MethodCall[]): Promise<JmapResponse | null>;
area<T = unknown>(account: string, name: string, params?: unknown): Promise<T>;
sync(account: string): Promise<void>;
noteStateChange(account: string, changed: Record<string, Record<string, string>>): Promise<void>;
setOnline(online: boolean): Promise<void>;
/** Closes the account, forgets its key and deletes its file. */
remove(account: string): Promise<void>;
/** Stops this tab's worker at once (a sign-out; the files go separately). */
stop(): void;
/**
* App lock: closes the databases and stops this tab's worker, with every key it had.
* Waits for a clean close at most `closeMs`. Until `unlock`, every call is refused.
*/
lock?(closeMs: number): Promise<void>;
/** This tab has the key again: its worker starts first, and takes the store. */
unlock?(ownerWaitMs: number): Promise<void>;
ownsStoreHere?(): boolean;
}
/** The app is locked: the store is closed, and its key isn't in memory anywhere. */
export class StoreLockedError extends Error {
constructor() {
super('The local store is closed while the app is locked');
this.name = 'StoreLockedError';
}
}
/** How long a call waits for the owner to have opened an account. */
const OPEN_RETRIES = 25;
const OPEN_RETRY_MS = 200;
/**
* localStorage: accounts that have had a store here. Holds nothing but the
* account's id, and it outlives what a browser may clear of the rest.
*/
export const STORE_OPENED_KEY = 'bulwark-store-opened';
function readOpenedAccounts(): string[] {
try {
const parsed = JSON.parse(localStorage.getItem(STORE_OPENED_KEY) ?? '[]') as unknown;
return Array.isArray(parsed) ? parsed as string[] : [];
} catch {
return [];
}
}
function rememberOpened(account: string, opened: boolean): void {
try {
const others = readOpenedAccounts().filter((id) => id !== account);
localStorage.setItem(STORE_OPENED_KEY, JSON.stringify(opened ? [...others, account] : others));
} catch {
// Without it a lost store goes unnoticed, and the store still works.
}
}
class PageStore implements StoreBackend {
private client: StoreClient | null = null;
private readonly wanted = new Map<string, StoreSession | null>();
private readonly opening = new Map<string, Promise<void>>();
/** The engine can't start in this browser: don't keep every caller waiting for an owner. */
private broken = false;
/**
* Accounts whose key was just made, until their store has opened. A new owner
* can start the open again, and the second try finds the key the first one made.
*/
private readonly keyJustMade = new Set<string>();
/** What the owner's engine was last told to keep. */
private readonly sentScope = new Map<string, string>();
private locked = false;
private ownerReady: (() => void) | null = null;
constructor(private readonly hooks: () => StorePageHooks | null) {}
supported(): boolean {
return StoreClient.supported();
}
private started(): StoreClient {
if (this.locked) throw new StoreLockedError();
if (this.client) return this.client;
this.broken = false;
const client = new StoreClient({
basePath: (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''),
buildId: process.env.NEXT_PUBLIC_BUILD_ID,
onEvent: (event) => {
if (event.type === 'error' && event.area === 'store' && !event.account) this.broken = true;
this.hooks()?.onEvent(event);
},
onOwner: () => {
this.ownerReady?.();
this.opening.clear();
for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {});
},
onNeedAuth: (account) => {
void (this.hooks()?.refreshAuth(account) ?? Promise.resolve(null))
.catch(() => null)
.then((auth) => this.client?.setAuth(account, auth));
},
onHalted: () => this.reset(),
});
client.start();
this.client = client;
return client;
}
private reset(): void {
this.client = null;
this.sentScope.clear();
this.wanted.clear();
this.opening.clear();
this.broken = false;
}
private async readKey(account: string): ReturnType<typeof openAccountKey> {
try {
return await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []);
} catch (error) {
if ((error as Error)?.name === 'StoreKeyLayerMissingError' && (error as StoreKeyLayerMissingError).layer === 'applock') this.hooks()?.onKeyLocked?.();
throw error;
}
}
/** Only the owner may open: the key goes to this tab's own worker, never over the channel. */
private openHere(account: string): Promise<void> {
const running = this.opening.get(account);
if (running) return running;
const run = (async () => {
const client = this.started();
const { keyHex, record, fresh } = await this.readKey(account);
if (fresh) this.keyJustMade.add(account);
// The old file, if the key alone went, can never be read again.
if (this.keyJustMade.has(account)) await client.pruneFiles(await knownStoreFiles()).catch(() => {});
const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null };
if (inputs.scope) this.sentScope.set(account, JSON.stringify(inputs.scope));
const opened = await client.openAccount({
account,
file: record.file,
keyHex,
session: inputs.session ?? this.wanted.get(account) ?? null,
scope: inputs.scope ?? DEFAULT_SCOPE,
invertedKeywordSort: inputs.invertedKeywordSort,
auth: inputs.auth,
});
// Whichever try of them gets here first says so.
if (this.keyJustMade.delete(account) || opened?.created) {
if (readOpenedAccounts().includes(account)) this.hooks()?.onStoreLost?.(account);
}
rememberOpened(account, true);
})();
this.opening.set(account, run);
run.catch(() => this.opening.delete(account));
return run;
}
async lock(closeMs: number): Promise<void> {
this.locked = true;
const client = this.client;
this.client = null;
this.opening.clear();
this.sentScope.clear();
this.keyJustMade.clear();
if (!client) return;
let timer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
client.release().catch(() => {}),
new Promise((resolve) => { timer = setTimeout(resolve, closeMs); }),
]);
clearTimeout(timer);
// Past the wait a sync may still be running. Stopping the worker ends it, as a crash would.
client.halt();
}
async unlock(ownerWaitMs: number): Promise<void> {
this.locked = false;
if (this.client?.ownsStore || !StoreClient.supported()) return;
if ((await listAccountRecords().catch(() => [])).length === 0) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ownerWaitMs);
this.ownerReady = () => {
clearTimeout(timer);
this.ownerReady = null;
resolve();
};
this.started();
});
}
ownsStoreHere(): boolean {
return this.client?.ownsStore ?? false;
}
async ensureOpen(account: string, session?: StoreSession | null): Promise<void> {
if (this.locked) throw new StoreLockedError();
this.wanted.set(account, session ?? this.wanted.get(account) ?? null);
const client = this.started();
if (client.ownsStore) await this.openHere(account);
// Otherwise the owner opens it: calls below wait for that.
}
refresh(account: string): void {
const client = this.client;
if (!client?.ownsStore) return;
const inputs = this.hooks()?.inputs(account);
if (!inputs) return;
if (inputs.session) client.setSession(account, inputs.session);
client.setAuth(account, inputs.auth);
// Only a real change: setting it starts over what a full disk made the store give up on.
const scope = inputs.scope ? JSON.stringify(inputs.scope) : null;
if (inputs.scope && scope !== this.sentScope.get(account)) {
if (this.sentScope.has(account)) client.setScope(account, inputs.scope);
this.sentScope.set(account, scope!);
}
if (inputs.invertedKeywordSort !== null) client.setInvertedKeywordSort(account, inputs.invertedKeywordSort);
}
/** A call for an account: the owner may still be opening it, in this tab or another. */
private async whenOpen<T>(account: string, call: (client: StoreClient) => Promise<T>): Promise<T> {
if (this.locked) throw new StoreLockedError();
if (this.broken) throw new Error('The local store could not start');
await this.ensureOpen(account);
for (let attempt = 0; ; attempt += 1) {
try {
return await call(this.started());
} catch (error) {
if ((error as Error)?.name !== 'StoreAccountClosedError' || attempt >= OPEN_RETRIES) throw error;
await new Promise((resolve) => setTimeout(resolve, OPEN_RETRY_MS));
}
}
}
answer(account: string, calls: MethodCall[]): Promise<JmapResponse | null> {
return this.whenOpen(account, (client) => client.answer(account, calls));
}
area<T = unknown>(account: string, name: string, params?: unknown): Promise<T> {
return this.whenOpen(account, (client) => client.area<T>(account, name, params));
}
sync(account: string): Promise<void> {
return this.whenOpen(account, (client) => client.sync(account));
}
noteStateChange(account: string, changed: Record<string, Record<string, string>>): Promise<void> {
return this.whenOpen(account, (client) => client.noteStateChange(account, changed));
}
async setOnline(online: boolean): Promise<void> {
if (this.locked || !this.client || this.broken) return;
await this.client.setOnline(online);
}
async remove(account: string): Promise<void> {
const record = await getAccountRecord(account);
await forgetAccountKey(account);
rememberOpened(account, false);
this.wanted.delete(account);
this.opening.delete(account);
if (record && !this.broken) await this.started().removeAccount(account, record.file);
}
stop(): void {
this.client?.halt();
this.reset();
}
}
let hooks: StorePageHooks | null = null;
let backend: StoreBackend | null = null;
/** Registers what the store needs from the app. */
export function configureStore(next: StorePageHooks | null): void {
hooks = next;
}
export function getStore(): StoreBackend {
backend ??= new PageStore(() => hooks);
return backend;
}
/** Tests run the real engine in-process. */
export function setStoreBackend(next: StoreBackend | null): void {
backend = next;
}