phase 6: web storage layer (KeyStore, Cache, session)

KeyStore + Cache TS interfaces with WebCrypto non-extractable Ed25519
keys persisted via IndexedDB (idb), plus thin api/session.ts that
loads or creates the device session at app startup. Vitest unit
tests under fake-indexeddb cover both adapters; Playwright e2e
verifies the keypair survives reload and produces signatures still
verifiable under the persisted public key (gateway round-trip moves
to Phase 7's existing acceptance bullet).

Browser baseline: WebCrypto Ed25519 — Chrome >=137, Firefox >=130,
Safari >=17.4. No JS fallback; ui/docs/storage.md documents the
matrix and the WebKit non-determinism quirk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-07 14:08:09 +02:00
parent 87a6694e2d
commit ecd2bc9348
18 changed files with 1133 additions and 29 deletions
+69
View File
@@ -0,0 +1,69 @@
// `session.ts` is the thin layer that loads or creates the device
// session at app startup. The keypair half is always materialised
// (loaded from the [KeyStore], or freshly generated when the slot is
// empty); the device-session-id half is read out of the [Cache] and
// is `null` until Phase 7's confirm-email-code handler stores it via
// [setDeviceSessionId]. A `null` deviceSessionId is the marker that
// the user must run the email-code login flow.
//
// Phase 7 wires the production caller (a layout-level loader that
// runs `loadDeviceSession` on first render and forwards the result
// into the Svelte session store). Phase 6 ships the persistence
// primitives this loader needs.
import type { Cache, DeviceKeypair, KeyStore } from "../platform/store/index";
export const SESSION_NAMESPACE = "session";
export const SESSION_ID_KEY = "device-session-id";
export interface DeviceSession {
keypair: DeviceKeypair;
deviceSessionId: string | null;
}
/**
* loadDeviceSession returns the device session for the current
* device. The keypair is loaded from `keyStore`; if the slot is
* empty, a fresh non-exportable Ed25519 keypair is generated and
* persisted. The returned `deviceSessionId` is `null` until Phase 7
* registers the public key with the gateway and persists the
* resulting id via [setDeviceSessionId].
*/
export async function loadDeviceSession(
keyStore: KeyStore,
cache: Cache,
): Promise<DeviceSession> {
const existing = await keyStore.load();
const keypair = existing ?? (await keyStore.generate());
const stored = await cache.get<string>(SESSION_NAMESPACE, SESSION_ID_KEY);
return { keypair, deviceSessionId: stored ?? null };
}
/**
* setDeviceSessionId persists the device-session id returned by the
* gateway's confirm-email-code response so subsequent app starts can
* resume the session without re-login.
*/
export async function setDeviceSessionId(
cache: Cache,
deviceSessionId: string,
): Promise<void> {
await cache.put(SESSION_NAMESPACE, SESSION_ID_KEY, deviceSessionId);
}
/**
* clearDeviceSession wipes both the device keypair and the stored
* device-session id. The next [loadDeviceSession] call will generate
* a fresh keypair and report `deviceSessionId: null`, forcing
* re-login. Used by user-driven logout and by gateway-driven
* revocation paths.
*/
export async function clearDeviceSession(
keyStore: KeyStore,
cache: Cache,
): Promise<void> {
await Promise.all([
keyStore.clear(),
cache.delete(SESSION_NAMESPACE, SESSION_ID_KEY),
]);
}