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:
@@ -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),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user