# Storage Layer (Web) The Galaxy UI client persists exactly two things on the web target: 1. the device session keypair (one Ed25519 pair per browser profile), and 2. a generic local cache for game state and small bookkeeping. Both live in the same IndexedDB database `galaxy-ui`. The keypair private key is generated as a non-extractable WebCrypto handle and never appears as plain bytes in JavaScript memory or in IDB. The cache stores arbitrary structured-clone values and is partitioned by namespace. This topic doc covers the web implementation only. The platform- agnostic `KeyStore` and `Cache` interfaces in `src/platform/store/index.ts` are what the rest of the client codes against; later phases bring `WailsStore` and `CapacitorStore` adapters that satisfy the same contracts. Source-of-truth for the cross-service contract is [`../../docs/ARCHITECTURE.md` §15 "Transport security model"](../../docs/ARCHITECTURE.md); this doc only covers the web-side primitives. ## Browser baseline The web keystore depends on WebCrypto Ed25519 (`{ name: 'Ed25519' }` in `subtle.generateKey` / `subtle.sign` / `subtle.verify` / `subtle.importKey('raw')`). Native support shipped relatively recently: | Engine | Minimum version | Released | |-----------|---------------------------|--------------| | Chrome | 137 | May 2025 | | Edge | 137 | May 2025 | | Firefox | 130 | Sept 2024 | | Safari | 17.4 (iOS 17.4 / macOS) | March 2024 | Browsers older than the baseline above will fail at the first `generateKey({ name: 'Ed25519' }, ...)` call with a `NotSupportedError`. Phase 6 deliberately does not ship a JavaScript fallback (e.g. `@noble/ed25519`) — keeping the keystore on WebCrypto is what gives us non-extractable storage on every supported engine. The Phase 7 root layout runs a one-time probe on boot and switches to a "browser not supported" page (described in [`auth-flow.md`](auth-flow.md)) when the probe rejects, instead of attempting the keystore generate. ### WebKit non-determinism note Pure Ed25519 (RFC 8032) signatures are deterministic: same private key + same message ⇒ same 64-byte signature. Most engines (Chrome, Firefox, Node 22's WebCrypto) follow this strictly. WebKit's `crypto.subtle.sign({ name: 'Ed25519' }, ...)` returns **non-deterministic** signatures — calls with identical inputs produce different signature bytes each time. The signatures still verify correctly under the matching public key (the protocol-level guarantee Galaxy depends on); only the byte-for-byte determinism is missing. Tests that assert "the same key signs the same message identically" must either pin to the Vitest path (Node WebCrypto) or be replaced with verify-after-sign assertions. The Phase 6 Playwright spec uses the verify path, which works on every engine in the baseline. ## IndexedDB schema ```text database: galaxy-ui (version 1) ├── object store: keypair │ key: string (the constant "device") │ value: { privateKey: CryptoKey, publicKey: CryptoKey, │ publicKeyRaw: ArrayBuffer } └── object store: cache keyPath: ["namespace", "key"] value: { namespace: string, key: string, value: unknown } ``` ### `keypair` store Holds at most one row, keyed by the literal string `"device"`. The row carries: - `privateKey` — non-extractable Ed25519 `CryptoKey`. Persisted via IndexedDB's structured-clone path; the cloned handle on load signs with the same private bytes as the original (asserted by the verify-after-reload Playwright test and by signature-byte equality under Node). - `publicKey` — extractable Ed25519 `CryptoKey`. The WebCrypto spec forces `extractable=true` on the public half of generated asymmetric pairs regardless of the `extractable` flag passed to `generateKey`. - `publicKeyRaw` — cached 32-byte raw export of `publicKey`. Lets every `KeyStore.load()` skip an `exportKey` round-trip; the `Uint8Array` view returned to callers is allocated from this buffer. ### `cache` store Compound-key (`[namespace, key]`) object store. Rows are `{ namespace, key, value }`. The `value` field is whatever the caller stored, round-tripped through structured-clone semantics — plain objects, arrays, `Uint8Array`, `Date`, etc. all survive. `Cache.clear(namespace)` walks a bounded cursor over `[ns, ""]..[ns, "￿"]`. The lexicographic upper bound `"￿"` is the largest BMP code point and acts as a sentinel above any realistic application-level key string. `Cache.clear()` (no argument) calls `IDBObjectStore.clear()` and wipes every namespace. Namespaces in current use: | Namespace | Key | Value type | Owner | |-----------------|---------------------|------------------|-----------------------------| | `session` | `device-session-id` | `string` | Phase 7+ | | `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) | | `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) | Later phases will add more per-feature namespaces (fixtures, lobby snapshot, etc.). The contract is namespace-strings stay scoped to one feature; cross-feature reads through the cache are by convention disallowed. ## Keystore lifecycle ```text KeyStore.generate() — generates a non-extractable Ed25519 keypair via subtle.generateKey, exports the public half to raw bytes, writes the row, and returns a DeviceKeypair view. KeyStore.load() — reads the row; returns null when missing, otherwise wraps the persisted privateKey in a DeviceKeypair whose .sign(canonical) calls subtle.sign({name: 'Ed25519'}, ...). KeyStore.clear() — deletes the row. The next load() returns null; the next generate() creates a fresh keypair. ``` `DeviceKeypair.sign` matches the `Signer` type from `api/galaxy-client.ts` so a session can wire the signer with `signer: keypair.sign.bind(keypair)`. ### What counts as "site data cleared" Browser-driven clearing — the user wipes site data, runs in private/incognito mode that ends, or storage is evicted under pressure — removes the `keypair` row. The next `loadDeviceSession` call generates a fresh keypair; the stored `device_session_id` (if any) becomes orphaned and the user is forced through the email-code login flow again. This is documented as acceptable in `docs/ARCHITECTURE.md` §15 "Key storage": loss of browser storage is recoverable through re-login, not through key recovery. ## `api/session.ts` Thin orchestration layer over `KeyStore` + `Cache`: - `loadDeviceSession(keyStore, cache)` returns `{ keypair, deviceSessionId }`. The `keypair` field is always populated (loaded if present, freshly generated if not). The `deviceSessionId` field is `null` until Phase 7's `confirm-email-code` handler stores the gateway-issued id via `setDeviceSessionId`. - `setDeviceSessionId(cache, id)` writes the id to the `session` namespace. - `clearDeviceSession(keyStore, cache)` wipes both the keypair and the stored id. Used by the user-driven logout path and by the push-event-driven revocation path. A `null` `deviceSessionId` is the signal that the session is unauthenticated — Phase 7 routes such users to `/login`. ## Test layout - `tests/store-idb-cache.test.ts` — Vitest unit tests over the cache, using `fake-indexeddb/auto` and unique per-test database names so cases never share state. - `tests/store-webcrypto-keystore.test.ts` — Vitest unit tests over the keystore, using Node 22's deterministic WebCrypto plus `fake-indexeddb/auto`. Asserts byte-for-byte signature determinism (which is fine under Node) and verify-after-reload. - `tests/e2e/storage-keypair-persistence.spec.ts` — Playwright e2e test that drives the storage surface through a debug route at `/__debug/store`. Asserts persistence across reload via signature verification (works on every engine in the baseline, including non-deterministic WebKit) and that `clearDeviceSession` forces a fresh keypair on next load. The debug route is gated by `import.meta.env.DEV` and `+page.ts` sets `prerender = false; ssr = false;`, so the production static-adapter build still emits an empty shell at that path but the debug entry point never attaches `window.__galaxyDebug`. ## Future: native targets Phase 31 (Wails) and Phase 32 (Capacitor) bring native keystores — Keychain on macOS / iOS, DPAPI/Credential Locker on Windows, libsecret on Linux, Android Keystore on Android — behind the same `KeyStore` interface, plus SQLite-backed `Cache` adapters. The web implementation here is the reference shape those adapters satisfy.