//, slice 4 - where this browser's DPoP key lives between page loads. // // WHY INDEXEDDB AND NOT localStorage: a non-extractable `CryptoKey` cannot be serialised to a string, // which is the whole point of it. IndexedDB stores it by STRUCTURED CLONE, so the browser keeps the key // material itself and hands back a handle that can sign and can never be read out. localStorage could only // ever hold an exported key, and an exportable key makes DPoP obfuscation rather than a boundary. // // CREATION IS A SIGN-IN-ONLY POWER, and this module enforces it by having two different doors: // `getOrCreateForSignIn` may generate; `getExisting` may not. That mirrors the server exactly - a // thumbprint may only be REGISTERED by an act that required credentials `auth/src/utils/dpopBinding.ts`. // A "helpful" regeneration on the refresh path is a lockout: the server refuses an unregistered key on a // refresh, that refusal throws inside token generation, and the user is signed out with nothing legible. // // A BINDING WE CANNOT KEEP IS WORSE THAN NO BINDING. If the key cannot be PERSISTED - private browsing, // a blocked store, a quota refusal - `getOrCreateForSignIn` answers `null` and the sign-in proceeds // UNBOUND. The alternative is a session that works until the first reload and then ends itself, which is // the same outcome dressed as a fault. // SLICE 5 INHERITS A PRODUCT CONSEQUENCE FROM THAT: once the API refuses unbound requests, a browser // that cannot keep a key cannot use the product at all. Private browsing is the obvious case. That is a // decision to take deliberately at the flag day, not a detail to discover then. // // THE STORED KEY DELIBERATELY SURVIVES A SIGN-OUT, and there is no clear-on-logout. It identifies the // DEVICE, not the person, and the server records it per user (`USER#` / `JKT#`), so one // front-desk machine keeps a single key across everyone who signs in on it. Clearing it at sign-out would // add a fresh key to every user's set on every visit and grow that set without bound. import { generateDpopKeyPair, thumbprintForKeyPair, type DpopKeyPair } from './dpop'; import { demoTabAtLoad } from '../services/authStorage'; import { demoDpopRecord } from '../services/demoTab'; const DB_NAME = 'akashic-dpop'; const DB_VERSION = 1; const STORE = 'device-key'; const RECORD_KEY = 'current'; /** * The persistence seam. * * IT EXISTS BECAUSE jsdom HAS `crypto.subtle` AND NO IndexedDB - measured, not assumed. Without a seam * the rules above would be untestable outside a real browser, and the rule that matters (creation is * sign-in-only) is exactly the one worth pinning. The adapter below is the part no unit test can reach; * it is deliberately the thinnest thing in this file. */ export interface DpopKeyStorage { read(): Promise; /** Resolves only when the pair is genuinely persisted; rejects otherwise. */ write(pair: DpopKeyPair): Promise; } const openDatabase = (): Promise => new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; } const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(STORE)) { request.result.createObjectStore(STORE); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed')); // A second tab holding an old version blocks the upgrade indefinitely; treat it as unavailable // rather than hanging the sign-in on it. request.onblocked = () => reject(new Error('IndexedDB open blocked')); }); export const indexedDbStorage = (record: string = RECORD_KEY): DpopKeyStorage => ({ async read() { const db = await openDatabase(); try { return await new Promise((resolve, reject) => { const request = db.transaction(STORE, 'readonly').objectStore(STORE).get(record); request.onsuccess = () => resolve((request.result as DpopKeyPair | undefined) ?? null); request.onerror = () => reject(request.error ?? new Error('IndexedDB read failed')); }); } finally { db.close(); } }, async write(pair) { const db = await openDatabase(); try { await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).put(pair, record); // Resolve on the TRANSACTION completing, never on the put request succeeding: a quota // refusal aborts the transaction after the request has already reported success, and // resolving there would report a key as persisted that was thrown away. tx.oncomplete = () => resolve(); tx.onabort = () => reject(tx.error ?? new Error('IndexedDB write aborted')); tx.onerror = () => reject(tx.error ?? new Error('IndexedDB write failed')); }); } finally { db.close(); } }, }); // A demo visit's key goes when the visit does. Only demo records can be named here, never the device key. export const discardDemoKey = async (tabId: string): Promise => { const db = await openDatabase(); try { await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).delete(demoDpopRecord(tabId)); tx.oncomplete = () => resolve(); tx.onabort = () => reject(tx.error ?? new Error('IndexedDB delete aborted')); tx.onerror = () => reject(tx.error ?? new Error('IndexedDB delete failed')); }); } finally { db.close(); } }; /** * The key for a sign-in - read the existing one, or make and PERSIST a new one. * * Answers `null` when this browser cannot keep a key, so the caller signs in unbound rather than bound to * something that will not survive the next page load. */ export async function getOrCreateForSignIn( storage: DpopKeyStorage, generate: () => Promise = generateDpopKeyPair, ): Promise { try { const existing = await storage.read(); if (existing) return existing; } catch { // A store we cannot read is a store we cannot rely on. Fall through and try to establish one; // if that also fails we answer null and the session is unbound. } try { const pair = await generate(); // WRITE FIRST, RETURN SECOND. Handing the key back before it is persisted would let the sign-in // register a thumbprint the next page load cannot reproduce - the precise state `key-lost` exists // to detect, manufactured by us. await storage.write(pair); return pair; } catch { return null; } } /** * The key for a refresh or a request. NEVER generates one - see the header. */ export async function getExisting(storage: DpopKeyStorage): Promise { try { return await storage.read(); } catch { return null; } } // The app-facing singleton // The memo is a correctness aid as much as a performance one: `fetchWithAuth` runs on every request, and // an IndexedDB round trip per request would put the key store in the latency path of the whole product. let storage: DpopKeyStorage | null = null; let cached: Promise | null = null; let cachedThumbprint: string | null = null; // A demo tab keeps its own key record, so a demo sign-in never shares or replaces this browser's ordinary key. const activeStorage = (): DpopKeyStorage => (storage ??= indexedDbStorage(demoTabAtLoad ? demoDpopRecord(demoTabAtLoad.tabId) : RECORD_KEY)); export const dpopKeyStore = { /** * Sign-in path - may create. Answers this browser's THUMBPRINT, or null when it cannot keep a key. * * Returns the thumbprint rather than the pair because that is all a sign-in needs, and handing back * a pair invites a caller to derive the thumbprint a second way. The two answers must never differ. */ async establishForSignIn(): Promise { const pair = await getOrCreateForSignIn(activeStorage()); cached = Promise.resolve(pair); cachedThumbprint = pair ? await thumbprintForKeyPair(pair) : null; return cachedThumbprint; }, /** Refresh and request paths - read only. */ async current(): Promise { cached ??= getExisting(activeStorage()); return cached; }, /** This browser's thumbprint, or null when it holds no key. */ async currentThumbprint(): Promise { if (cachedThumbprint) return cachedThumbprint; const pair = await this.current(); if (!pair) return null; cachedThumbprint = await thumbprintForKeyPair(pair); return cachedThumbprint; }, /** Test seam - swap the persistence adapter. */ __setStorage(next: DpopKeyStorage | null): void { storage = next; cached = null; cachedThumbprint = null; }, };