Files
galaxy-game/ui/docs/storage.md
T
Ilia Denisov ecd2bc9348 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>
2026-05-07 14:08:09 +02:00

8.4 KiB

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"; 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 login UI surfaces a clear "browser not supported" message instead.

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

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+

Phase 8 onwards will add per-feature namespaces (lobby snapshot, game state, fixtures, etc.). The contract is namespace-strings stay scoped to one feature; cross-feature reads through the cache are by convention disallowed.

Keystore lifecycle

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.