mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 16:16:49 +00:00
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.
330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
/**
|
|
* The per-account database keys, as the page sees them.
|
|
*
|
|
* Each account's database has a random 32-byte key. It is only ever stored
|
|
* wrapped: AES-GCM with a non-extractable key that lives in IndexedDB, then
|
|
* any extra layers on top (the desktop app's OS keychain, app lock).
|
|
*
|
|
* That first key is non-extractable for script only: the browser keeps its bytes
|
|
* in plain form in its IndexedDB files, next to the record. So in a browser with no
|
|
* app lock, someone with a copy of those files can open the database. And a record
|
|
* deleted or overwritten here stays in those files until the browser compacts them.
|
|
* So forgetting a record stops the app, and script, from using the key. It doesn't
|
|
* make a file on disk safe from a copy of the disk: deleting the file does that. And
|
|
* more layers on a record only count for a key that never sat there with fewer,
|
|
* which is why those ask for a new key and file (`rekey`).
|
|
*/
|
|
|
|
/**
|
|
* 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';
|
|
|
|
const DB_NAME = 'bulwark-store';
|
|
const DB_VERSION = 1;
|
|
const KEYS = 'keys';
|
|
const ACCOUNTS = 'accounts';
|
|
const WRAP_ID = 'wrap';
|
|
|
|
export interface WrapLayer {
|
|
/** A name for the layer: 'safeStorage', 'pin', 'prf'. */
|
|
name: string;
|
|
wrap(data: Uint8Array): Promise<Uint8Array>;
|
|
unwrap(data: Uint8Array): Promise<Uint8Array>;
|
|
}
|
|
|
|
export interface AccountRecord {
|
|
account: string;
|
|
/** `/a<hex>.db`. */
|
|
file: string;
|
|
iv: Uint8Array;
|
|
wrapped: Uint8Array;
|
|
/** Layers applied after the WebCrypto wrap, innermost first. */
|
|
layers: string[];
|
|
createdAt: number;
|
|
/** An older copy of this record, with fewer layers, may still be on disk: the key has to change. */
|
|
rekey?: true;
|
|
/** The file from before a new key, until it's known to be deleted. */
|
|
leftover?: string;
|
|
}
|
|
|
|
function promisify<T>(request: IDBRequest<T>): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
function openDb(): Promise<IDBDatabase> {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
request.onupgradeneeded = () => {
|
|
const db = request.result;
|
|
if (!db.objectStoreNames.contains(KEYS)) db.createObjectStore(KEYS, { keyPath: 'id' });
|
|
if (!db.objectStoreNames.contains(ACCOUNTS)) db.createObjectStore(ACCOUNTS, { keyPath: 'account' });
|
|
};
|
|
return promisify(request);
|
|
}
|
|
|
|
async function withDb<T>(fn: (db: IDBDatabase) => Promise<T>): Promise<T> {
|
|
const db = await openDb();
|
|
try {
|
|
return await fn(db);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
async function wrapKey(db: IDBDatabase): Promise<CryptoKey> {
|
|
const read = db.transaction(KEYS, 'readonly').objectStore(KEYS).get(WRAP_ID);
|
|
const existing = await promisify(read) as { key: CryptoKey } | undefined;
|
|
if (existing) return existing.key;
|
|
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
const tx = db.transaction(KEYS, 'readwrite');
|
|
const store = tx.objectStore(KEYS);
|
|
// Another tab may have got there first: keep theirs.
|
|
const raced = await promisify(store.get(WRAP_ID)) as { key: CryptoKey } | undefined;
|
|
if (raced) return raced.key;
|
|
await promisify(store.put({ id: WRAP_ID, key }));
|
|
return key;
|
|
}
|
|
|
|
function toHex(bytes: Uint8Array): string {
|
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function asBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
}
|
|
|
|
export function newStoreFileName(): string {
|
|
return `/a${toHex(crypto.getRandomValues(new Uint8Array(8)))}.db`;
|
|
}
|
|
|
|
/** The file of every account with a key. Any other file in the pool can't be read by anyone. */
|
|
export async function knownStoreFiles(): Promise<string[]> {
|
|
return withDb(async (db) => {
|
|
const records = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).getAll()) as AccountRecord[];
|
|
return records.map((record) => record.file);
|
|
});
|
|
}
|
|
|
|
export async function getAccountRecord(account: string): Promise<AccountRecord | null> {
|
|
return withDb(async (db) => {
|
|
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
|
|
return found ?? null;
|
|
});
|
|
}
|
|
|
|
export async function listAccountRecords(onlyIfThere = false): Promise<AccountRecord[]> {
|
|
if (onlyIfThere && !(await keyDatabaseExists())) return [];
|
|
return withDb((db) => promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).getAll()) as Promise<AccountRecord[]>);
|
|
}
|
|
|
|
function sameLayers(names: readonly string[], layers: readonly WrapLayer[]): boolean {
|
|
return names.length === layers.length && layers.every((layer, index) => layer.name === names[index]);
|
|
}
|
|
|
|
function hasMore(from: readonly string[], to: readonly string[]): boolean {
|
|
return to.some((name) => !from.includes(name));
|
|
}
|
|
|
|
/**
|
|
* The account's key in hex, created on first use. Returns the record too, for the file name.
|
|
* A record with other layers than these gets these. `wrapFailed`: it couldn't, the OS refused,
|
|
* and the key comes anyway since what's stored is no worse than it was. The caller says so.
|
|
*/
|
|
export async function openAccountKey(account: string, layers: readonly WrapLayer[] = []): Promise<{ keyHex: string; record: AccountRecord; fresh: boolean; wrapFailed?: boolean }> {
|
|
return withDb(async (db) => {
|
|
const wrapping = await wrapKey(db);
|
|
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
|
|
if (found) {
|
|
let data = found.wrapped;
|
|
for (const name of [...found.layers].reverse()) {
|
|
const layer = layers.find((candidate) => candidate.name === name);
|
|
if (!layer) throw new StoreKeyLayerMissingError(name);
|
|
data = await layer.unwrap(data);
|
|
}
|
|
const raw = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBuffer(found.iv) }, wrapping, asBuffer(data)));
|
|
const keyHex = toHex(raw);
|
|
raw.fill(0);
|
|
if (sameLayers(found.layers, layers)) return { keyHex, record: found, fresh: false };
|
|
let rewrapped = data;
|
|
try {
|
|
for (const layer of layers) rewrapped = new Uint8Array(await layer.wrap(rewrapped));
|
|
} catch {
|
|
return { keyHex, record: found, fresh: false, wrapFailed: true };
|
|
}
|
|
const names = layers.map((layer) => layer.name);
|
|
const record: AccountRecord = { ...found, wrapped: rewrapped, layers: names, ...(found.rekey || hasMore(found.layers, names) ? { rekey: true as const } : {}) };
|
|
await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record));
|
|
return { keyHex, record, fresh: false };
|
|
}
|
|
const raw = crypto.getRandomValues(new Uint8Array(32));
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
let wrapped = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapping, raw));
|
|
for (const layer of layers) wrapped = new Uint8Array(await layer.wrap(wrapped));
|
|
const record: AccountRecord = {
|
|
account,
|
|
file: newStoreFileName(),
|
|
iv,
|
|
wrapped,
|
|
layers: layers.map((layer) => layer.name),
|
|
createdAt: Date.now(),
|
|
};
|
|
await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record));
|
|
const keyHex = toHex(raw);
|
|
raw.fill(0);
|
|
return { keyHex, record, fresh: true };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adds or removes wrap layers without touching the database key. More layers than before ask for
|
|
* a new key (`rekey`), and so does the caller who knows better, for a lock key that changed.
|
|
*/
|
|
export async function rewrapAccountKey(
|
|
account: string,
|
|
from: readonly WrapLayer[],
|
|
to: readonly WrapLayer[],
|
|
options: { rekey?: boolean } = {},
|
|
): Promise<void> {
|
|
await withDb(async (db) => {
|
|
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
|
|
if (!found) return;
|
|
let data = found.wrapped;
|
|
for (const name of [...found.layers].reverse()) {
|
|
const layer = from.find((candidate) => candidate.name === name);
|
|
if (!layer) throw new StoreKeyLayerMissingError(name);
|
|
data = await layer.unwrap(data);
|
|
}
|
|
for (const layer of to) data = new Uint8Array(await layer.wrap(data));
|
|
const names = to.map((layer) => layer.name);
|
|
const { rekey: _before, ...rest } = found;
|
|
const record: AccountRecord = {
|
|
...rest,
|
|
wrapped: data,
|
|
layers: names,
|
|
...(options.rekey || found.rekey || hasMore(found.layers, names) ? { rekey: true as const } : {}),
|
|
};
|
|
await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record));
|
|
});
|
|
}
|
|
|
|
/** A new random key for a re-encryption, in hex, and a file for it. */
|
|
export function newStoreKey(): { keyHex: string; file: string } {
|
|
const raw = crypto.getRandomValues(new Uint8Array(32));
|
|
const keyHex = toHex(raw);
|
|
raw.fill(0);
|
|
return { keyHex, file: newStoreFileName() };
|
|
}
|
|
|
|
/** The account's data is whole under this key in this file now. One write, so a crash leaves one or the other. */
|
|
export async function replaceAccountKey(account: string, keyHex: string, file: string, layers: readonly WrapLayer[] = []): Promise<void> {
|
|
await withDb(async (db) => {
|
|
const wrapping = await wrapKey(db);
|
|
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
|
|
if (!found) return;
|
|
const raw = Uint8Array.from(keyHex.match(/../g)!.map((pair) => parseInt(pair, 16)));
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
let wrapped = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapping, raw));
|
|
raw.fill(0);
|
|
for (const layer of layers) wrapped = new Uint8Array(await layer.wrap(wrapped));
|
|
const { rekey: _done, ...rest } = found;
|
|
const record: AccountRecord = { ...rest, file, iv, wrapped, layers: layers.map((layer) => layer.name), leftover: found.file };
|
|
await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record));
|
|
});
|
|
}
|
|
|
|
/** The old file is gone. */
|
|
export async function clearLeftover(account: string): Promise<void> {
|
|
await withDb(async (db) => {
|
|
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
|
|
if (!found?.leftover) return;
|
|
const { leftover: _gone, ...rest } = found;
|
|
await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(rest));
|
|
});
|
|
}
|
|
|
|
/** Forgets the account's key. The app can't read the database file after this. */
|
|
export async function forgetAccountKey(account: string): Promise<void> {
|
|
await withDb((db) => promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).delete(account)).then(() => undefined));
|
|
}
|
|
|
|
/**
|
|
* This browser profile's row at the instance, for one account: its id, and the
|
|
* secret that lets a device answer for that row without a session. In this
|
|
* database and nowhere else, because a start has to read it before any store
|
|
* opens, and a copy in the store could never outlive the key that opens it. A
|
|
* cleared database means a new device at the next registration.
|
|
*
|
|
* Not sealed. Sealing it with the wrap key would change nothing, since that sits beside
|
|
* it, and a device at the lock screen still has to hear a wipe. So someone with a copy of
|
|
* these files can act as this row at the instance: read whether a wipe waits, and report one
|
|
* as done. It reaches no mail. An admin's "wiped" means the device said so.
|
|
*/
|
|
export interface DeviceRecord {
|
|
account: string;
|
|
id: string;
|
|
secret: string;
|
|
createdAt: number;
|
|
}
|
|
|
|
const DEVICE_RECORD_PREFIX = 'device:';
|
|
// ':' + 1, so the range holds exactly the prefixed keys.
|
|
const DEVICE_RECORD_END = 'device;';
|
|
|
|
export async function getDeviceRecord(account: string): Promise<DeviceRecord | null> {
|
|
return withDb(async (db) => {
|
|
const found = await promisify(db.transaction(KEYS, 'readonly').objectStore(KEYS).get(DEVICE_RECORD_PREFIX + account)) as { record: DeviceRecord } | undefined;
|
|
return found?.record ?? null;
|
|
});
|
|
}
|
|
|
|
export async function putDeviceRecord(record: DeviceRecord): Promise<void> {
|
|
await withDb((db) => promisify(db.transaction(KEYS, 'readwrite').objectStore(KEYS).put({ id: DEVICE_RECORD_PREFIX + record.account, record })).then(() => undefined));
|
|
}
|
|
|
|
export async function deleteDeviceRecord(account: string): Promise<void> {
|
|
if (!(await keyDatabaseExists())) return;
|
|
await withDb((db) => promisify(db.transaction(KEYS, 'readwrite').objectStore(KEYS).delete(DEVICE_RECORD_PREFIX + account)).then(() => undefined));
|
|
}
|
|
|
|
/** Opening the database makes it, so a browser that can say it isn't there doesn't open it. */
|
|
async function keyDatabaseExists(): Promise<boolean> {
|
|
if (typeof indexedDB.databases !== 'function') return true;
|
|
return (await indexedDB.databases()).some((database) => database.name === DB_NAME);
|
|
}
|
|
|
|
export async function listDeviceRecords(onlyIfThere = true): Promise<DeviceRecord[]> {
|
|
if (onlyIfThere && !(await keyDatabaseExists())) return [];
|
|
return withDb(async (db) => {
|
|
const range = IDBKeyRange.bound(DEVICE_RECORD_PREFIX, DEVICE_RECORD_END, false, true);
|
|
const rows = await promisify(db.transaction(KEYS, 'readonly').objectStore(KEYS).getAll(range)) as Array<{ record: DeviceRecord }>;
|
|
return rows.map((row) => row.record);
|
|
});
|
|
}
|
|
|
|
export class StoreKeyLayerMissingError extends Error {
|
|
constructor(readonly layer: string) {
|
|
super(`The key needs the ${layer} layer`);
|
|
this.name = 'StoreKeyLayerMissingError';
|
|
}
|
|
}
|
|
|
|
/** Deletes every key at sign-out. Works with no worker running, on any page. */
|
|
export function deleteKeyDatabase(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const request = indexedDB.deleteDatabase(DB_NAME);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => resolve();
|
|
// A connection still open goes on its own: withDb closes after each use.
|
|
request.onblocked = () => resolve();
|
|
} catch {
|
|
// Private mode or similar: nothing was stored either.
|
|
resolve();
|
|
}
|
|
});
|
|
}
|