Files
webmail/lib/store/worker/host.ts
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

314 lines
12 KiB
TypeScript

/**
* What runs in the owner tab's worker: one database and engine per account.
* The list of areas is the only thing that decides what the store answers.
*/
import type { StoreArea, StoreEvent } from '../area';
import { AccountEngine } from '../engine/account-engine';
import { FetchTransport, type StoreSession } from '../engine/transport';
import type { StorageAdapter } from '../storage';
import type { OpenAccountParams, StoreRpc, StoreStatus } from './protocol';
/** Files in the pool that belong to no key. A name that isn't a store file's is left alone. */
export function storeFilesToPrune(files: readonly string[], keep: readonly string[]): string[] {
return files.filter((file) => /^\/a[0-9a-f]+\.db$/.test(file) && !keep.includes(file));
}
/** A copy of a database under way, page by page, into a new file under a new key. */
export interface StoreCopy {
/** Negative: everything that's left. Busy: the source is in the middle of something. */
step(pages: number): 'more' | 'done' | 'busy';
remaining(): number;
pageCount(): number;
/** Done: the file stays. */
finish(): void;
/** Not done: the file goes. */
abort(): void;
}
export interface HostStorage {
open(file: string, keyHex: string): Promise<StorageAdapter>;
remove(file: string): boolean;
startCopy?(source: StorageAdapter, file: string, keyHex: string): Promise<StoreCopy>;
}
const COPY_PAGES_PER_STEP = 256;
/** What's left for after the engine has stopped: a write after a copy's end isn't in it. */
const COPY_LAST_PAGES = 64;
const COPY_BUSY_WAIT_MS = 50;
interface OpenAccount {
engine: AccountEngine;
db: StorageAdapter;
file: string;
auth: string | null;
pendingAuth: Array<(header: string | null) => void>;
session: StoreSession;
/** A re-encryption under way: the database can't close under it. */
copying?: { stop: boolean; settled: Promise<void> };
}
export interface StoreHostOptions {
areas: readonly StoreArea[];
storage: HostStorage;
emit(event: StoreEvent): void;
needAuth(account: string): void;
}
/** The owner hasn't opened this account yet. The caller may try again shortly. */
function rekeyStopped(): Error {
const error = new Error('The re-encryption was stopped');
error.name = 'StoreRekeyStoppedError';
return error;
}
function accountClosed(): Error {
const error = new Error('The local store is not open for this account');
error.name = 'StoreAccountClosedError';
return error;
}
export class StoreHost {
private readonly accounts = new Map<string, OpenAccount>();
private online = true;
private readonly synced = new Map<string, number>();
constructor(private readonly options: StoreHostOptions) {}
async openAccount(params: OpenAccountParams): Promise<{ created: boolean }> {
const existing = this.accounts.get(params.account);
if (existing) {
if (params.session) this.setSession(params.account, params.session);
this.setAuth(params.account, params.auth);
if (params.scope) existing.engine.setScope(params.scope);
if (params.invertedKeywordSort !== null) existing.engine.setInvertedKeywordSort(params.invertedKeywordSort);
void existing.engine.requestSync();
return { created: false };
}
const db = await this.options.storage.open(params.file, params.keyHex);
const created = !db.get("SELECT 1 AS yes FROM sqlite_master WHERE name = 'schema_migration'");
// Nothing to sync with yet. No file stays behind, or the next open couldn't tell that this one was new.
if (created && !params.session) {
db.close();
this.options.storage.remove(params.file);
throw new Error('No session for this account yet');
}
const session = params.session ?? AccountEngine.storedSession(db);
if (!session) {
db.close();
throw new Error('No session for this account yet');
}
const account: OpenAccount = { db, file: params.file, auth: params.auth, pendingAuth: [], session, engine: undefined as unknown as AccountEngine };
const transport = new FetchTransport({
session: () => account.session,
auth: () => account.auth,
refreshAuth: () => new Promise((resolve) => {
account.pendingAuth.push(resolve);
this.options.needAuth(params.account);
}),
});
account.engine = new AccountEngine({
account: params.account,
db,
session,
transport,
areas: this.options.areas,
scope: params.scope,
invertedKeywordSort: params.invertedKeywordSort ?? undefined,
emit: (event) => {
if (event.type === 'synced') this.synced.set(event.account, event.at);
this.options.emit(event);
},
});
account.engine.setOnline(this.online);
this.accounts.set(params.account, account);
void account.engine.requestSync();
return { created };
}
setAuth(account: string, auth: string | null): void {
const open = this.accounts.get(account);
if (!open) return;
open.auth = auth;
for (const resolve of open.pendingAuth.splice(0)) resolve(auth);
}
setSession(account: string, session: StoreSession): void {
const open = this.accounts.get(account);
if (!open) return;
open.session = session;
open.engine.setSession(session);
}
setScope(account: string, scope: Parameters<AccountEngine['setScope']>[0]): void {
const open = this.accounts.get(account);
if (!open) return;
if (open.engine.setScope(scope)) void open.engine.requestSync();
}
setInvertedKeywordSort(account: string, inverted: boolean): void {
this.accounts.get(account)?.engine.setInvertedKeywordSort(inverted);
}
async closeAccount(account: string): Promise<void> {
const open = this.accounts.get(account);
if (!open) return;
if (open.copying) {
open.copying.stop = true;
await open.copying.settled;
}
this.accounts.delete(account);
await open.engine.close();
for (const resolve of open.pendingAuth.splice(0)) resolve(null);
open.db.close();
}
/**
* Copies an open account into a new file under a new key, while it goes on working: the source's
* own writes reach the copy too. Then stops the engine for the last pages, and ends with the
* account closed and nothing written since, so the old file and the new one are the same data.
* On a failure the new file goes and the account stays open, if it can.
*/
async rekey(account: string, file: string, keyHex: string): Promise<void> {
const open = this.accounts.get(account);
if (!open) throw accountClosed();
if (!this.options.storage.startCopy) throw new Error('This storage can\'t re-encrypt');
if (open.copying) throw new Error('A re-encryption is under way');
let settle = () => {};
const copying = { stop: false, settled: new Promise<void>((resolve) => { settle = resolve; }) };
open.copying = copying;
let copy: StoreCopy | null = null;
const emit = (percent: number) => this.options.emit({ type: 'rekey', account, percent });
try {
copy = await this.options.storage.startCopy(open.db, file, keyHex);
const yieldNow = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
// One page first: only then does SQLite know how much there is.
let answer = copy.step(1);
for (;;) {
if (copying.stop) throw rekeyStopped();
if (answer === 'done') break;
if (answer === 'busy') {
await yieldNow(COPY_BUSY_WAIT_MS);
} else {
const left = copy.remaining();
if (left <= COPY_LAST_PAGES) break;
const total = copy.pageCount();
emit(Math.min(99, Math.floor(((total - left) / Math.max(total, 1)) * 100)));
await yieldNow();
}
if (copying.stop) throw rekeyStopped();
answer = copy.step(Math.min(COPY_PAGES_PER_STEP, Math.max(1, copy.remaining() - COPY_LAST_PAGES)));
}
if (copying.stop) throw rekeyStopped();
// Calls wait for the account to open again from here. Other tabs try again by themselves.
this.accounts.delete(account);
await open.engine.close();
for (const resolve of open.pendingAuth.splice(0)) resolve(null);
// Nothing writes now. A copy that was short enough to end above is whole only if nothing
// wrote after, so it goes round again.
let last = answer === 'done' ? 'again' : copy.step(-1);
if (last === 'again') {
copy.abort();
copy = await this.options.storage.startCopy(open.db, file, keyHex);
last = copy.step(-1);
}
for (let tries = 0; last === 'busy' && tries < 20; tries += 1) {
await yieldNow(COPY_BUSY_WAIT_MS);
last = copy.step(-1);
}
if (last !== 'done') throw new Error('The copy did not finish');
copy.finish();
copy = null;
open.db.close();
emit(100);
} catch (error) {
copy?.abort();
if (!this.accounts.has(account)) {
try {
open.db.close();
} catch {
// The page opens the old file again.
}
}
throw error;
} finally {
open.copying = undefined;
settle();
}
}
async removeAccount(account: string, file: string): Promise<boolean> {
await this.closeAccount(account);
this.synced.delete(account);
const removed = this.options.storage.remove(file);
this.options.emit({ type: 'removed', account });
return removed;
}
async closeAll(): Promise<void> {
await Promise.all([...this.accounts.keys()].map((account) => this.closeAccount(account)));
}
/** The calls other tabs can make. */
async call(method: keyof StoreRpc, params: unknown): Promise<unknown> {
switch (method) {
case 'answer': {
const { account, calls } = params as Parameters<StoreRpc['answer']>[0];
const open = this.accounts.get(account);
if (!open) throw accountClosed();
return open.engine.answer(calls);
}
case 'sync': {
const { account } = params as Parameters<StoreRpc['sync']>[0];
await this.accounts.get(account)?.engine.requestSync();
return undefined;
}
case 'noteStateChange': {
const { account, changed } = params as Parameters<StoreRpc['noteStateChange']>[0];
this.accounts.get(account)?.engine.noteStateChange(changed);
return undefined;
}
case 'setOnline': {
const { online } = params as Parameters<StoreRpc['setOnline']>[0];
this.online = online;
for (const open of this.accounts.values()) open.engine.setOnline(online);
return undefined;
}
case 'status': {
const { account } = params as Parameters<StoreRpc['status']>[0];
return this.status(account);
}
case 'area': {
const { account, name, params: callParams } = params as Parameters<StoreRpc['area']>[0];
const open = this.accounts.get(account);
if (!open) throw accountClosed();
return open.engine.areaCall(name, callParams);
}
case 'setScope': {
const { account, scope } = params as Parameters<StoreRpc['setScope']>[0];
if (!this.accounts.has(account)) throw accountClosed();
this.setScope(account, scope);
return undefined;
}
case 'removeAccount': {
const { account, file } = params as Parameters<StoreRpc['removeAccount']>[0];
return this.removeAccount(account, file);
}
default:
throw new Error(`Unknown store call ${String(method)}`);
}
}
private status(account: string): StoreStatus | null {
const open = this.accounts.get(account);
if (!open) return null;
return {
account,
open: true,
emails: Number(open.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email')?.n ?? 0),
bodies: Number(open.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email_body')?.n ?? 0),
lastSyncedAt: this.synced.get(account) ?? null,
};
}
}