export interface StoredKey { scope: string; /** Non-extractable. Never leaves this object as bytes. */ privateKey: CryptoKey; publicKeyRaw: Uint8Array; /** * `Date.now()` when the key was minted. Absent on keys written by an earlier * version, which are by definition the oldest ones there. */ createdAt?: number; } export interface KeyStore { get(scope: string): Promise; put(key: StoredKey): Promise; remove(scope: string): Promise; /** Every scope currently holding a key, so a device can purge the rest. */ scopes(): Promise; } const DB_NAME = 'chil'; const STORE_NAME = 'recipient-keys'; /** * The recipient's keypair, in IndexedDB. * * IndexedDB rather than `localStorage`, and the reason is not capacity or * ergonomics: IndexedDB can structured-clone a `CryptoKey` object directly, so * the non-extractable private key is stored *as a key handle*. `localStorage` * holds strings only, which would force `extractable: true` and put the raw * private key within reach of any script on the page. * * ## How long this actually survives — read before shipping * * Longer than a session, but it is **not** permanent storage, and the failure * is silent. A device whose key is gone still works — a fresh keypair is * generated on demand and new uploads seal to it — but anything already in the * queue under the old key is undecryptable for good. * * Survives: page reload, tab close, browser restart, device reboot, and any * amount of server-side redeployment. None of this is on the server. * * Lost to: * * - **Clearing site data.** The realistic one, and the worst: it can happen * mid-shift with items still queued. * - **A replaced, reset or reimaged device**, and any other browser profile. * - **Private / incognito browsing**, where the store dies with the window. * - **Safari's ITP eviction.** All script-writable storage for an origin is * erased after *seven days without user interaction with that site*. It is a * tracking countermeasure, not a storage policy, and Chrome and Firefox do * not do it. Note what resets the clock: someone opening the page, not the * browser being used. A kiosk in daily use never approaches it, and a device * idle long enough to trigger it has an empty queue anyway — so this is the * least costly of the four, despite sounding like the most alarming. * - **Storage pressure**, where a browser evicts whole origins under disk * pressure. `navigator.storage.persist()` asks for exemption from this one * and is usually granted to a site with real interaction history — see * `requestPersistence`. It does **not** exempt anything from ITP above. * * The mitigation is not to prevent loss, which is not possible from here, but * to make it legible: `decrypt` reports `wrong-key` and `no-key` as distinct * reasons so a dashboard can say which happened instead of showing a broken * image. * * ## The opposite risk: keys outlive their scope * * Nothing here expires anything. A device that has served ten rooms holds ten * private keys, each still able to open whatever was queued under it, and the * one that matters is only ever the current scope's. On shared, rented or * resold hardware the other nine are keys to someone else's uploads. * * Purging is the app's job, because only the app knows which scopes are stale — * a device legitimately serving two rooms at once must not lose one. `scopes()` * is the mechanism: read them, `remove` the ones that no longer apply. Do it * when the scope changes, when it drops, and on sign-out. Where the current * scope is unknown — an unassigned device sitting in stock — `createdAt` is the * only handle left: drop everything past some age. * * Deletion is final in the same way loss is. Purge a scope with items still * queued under it and those items are unreadable for good. */ export function indexedDbKeyStore( dbName = DB_NAME, storeName = STORE_NAME, ): KeyStore { function open(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(dbName, 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(storeName)) { db.createObjectStore(storeName, { keyPath: 'scope' }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async function transact( mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest, ): Promise { const db = await open(); try { return await new Promise((resolve, reject) => { const tx = db.transaction(storeName, mode); const request = run(tx.objectStore(storeName)); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); tx.onabort = () => reject(tx.error); }); } finally { db.close(); } } return { async get(scope) { const found = await transact('readonly', (store) => store.get(scope), ); return found ?? null; }, async put(key) { await transact('readwrite', (store) => store.put(key)); }, async remove(scope) { await transact('readwrite', (store) => store.delete(scope)); }, async scopes() { // The store's keyPath is `scope`, so its keys are the scopes themselves — // no key material is read to answer this. const keys = await transact('readonly', (store) => store.getAllKeys()); return keys.map(String); }, }; } /** * A key store in a `Map`. For tests, and for Node, which has WebCrypto but no * IndexedDB. Everything in it dies with the process. */ export function memoryKeyStore(): KeyStore { const keys = new Map(); return { get: async (scope) => keys.get(scope) ?? null, put: async (key) => void keys.set(key.scope, key), remove: async (scope) => void keys.delete(scope), scopes: async () => [...keys.keys()], }; } /** * Asks the browser not to evict this origin under storage pressure. * * Worth calling once on the requester's side. Returns whether it was granted — * browsers decide by interaction history and installed-app status, so a first * visit is usually refused and a daily-use kiosk usually is not. * * This says nothing about Safari's seven-day ITP eviction, which no API opts * out of. */ export async function requestPersistence(): Promise { try { return (await navigator.storage?.persist?.()) ?? false; } catch { return false; } }