diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c7448be..787e93f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -572,7 +572,9 @@ and revoke metadata. the device. - Browser/WASM clients use WebCrypto with non-exportable storage where available. Loss of browser storage is acceptable and is recovered by - re-login. + re-login. The concrete browser baseline, IndexedDB schema, and + keystore lifecycle live in + [`ui/docs/storage.md`](../ui/docs/storage.md). ### Request envelope diff --git a/ui/PLAN.md b/ui/PLAN.md index 52e0576..b05e0a2 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -607,24 +607,55 @@ provide a generic local cache for game state. Defines the TypeScript-side `KeyStore` and `Cache` interfaces that desktop and mobile adapters will satisfy in later phases. +Decisions taken with the project owner before implementation: + +1. **Phase 6 stops at the storage boundary.** The PLAN previously + listed a Playwright check that the gateway accepts a signed + request. Public-key registration happens through the email-code + confirm endpoint, which Phase 7 wires; building a temporary + test-only registration path was rejected as throw-away + scaffolding. The live-gateway round-trip is therefore covered by + Phase 7's existing acceptance bullet "the first authenticated + Connect call after login … succeeds end-to-end" instead, which + cannot pass unless the Phase 6 keystore persists and signs + correctly. +2. **Modern-browser baseline, no JS Ed25519 fallback.** WebCrypto + Ed25519 lands in Chrome ≥137, Firefox ≥130, Safari ≥17.4. Phase 7 + surfaces a clear "browser not supported" message for older + engines instead of carrying a parallel `@noble/ed25519` code + path. The full matrix and rationale live in + `ui/docs/storage.md`. + Artifacts: -- `ui/frontend/src/platform/store/index.ts` defining `KeyStore` and - `Cache` interfaces -- `ui/frontend/src/platform/store/idb-cache.ts` IndexedDB-backed - `Cache` using the `idb` library -- `ui/frontend/src/platform/store/webcrypto-keystore.ts` WebCrypto - non-exportable Ed25519 key generation and IndexedDB handle - persistence -- `ui/frontend/src/api/session.ts` thin layer that loads or creates the - device session at app startup +- `ui/frontend/src/platform/store/index.ts` — public `KeyStore`, + `Cache`, `DeviceKeypair` interfaces and the `loadStore()` + resolver, with no web-specific imports in any public signature +- `ui/frontend/src/platform/store/idb.ts` — shared `galaxy-ui` + IndexedDB connection (typed via `idb`'s `DBSchema`) used by both + the keystore and the cache +- `ui/frontend/src/platform/store/idb-cache.ts` — IndexedDB-backed + `Cache` keyed by compound `[namespace, key]` +- `ui/frontend/src/platform/store/webcrypto-keystore.ts` — WebCrypto + non-exportable Ed25519 key generation, structured-cloned through + IDB +- `ui/frontend/src/platform/store/web.ts` — the `loadWebStore` + factory wired into `loadStore` +- `ui/frontend/src/api/session.ts` thin layer with + `loadDeviceSession`, `setDeviceSessionId`, `clearDeviceSession` +- `ui/frontend/src/routes/__debug/store/+page.svelte` (+ `+page.ts` + with `prerender = false; ssr = false;`) — dev-only debug surface + the Phase 6 Playwright spec drives through `window.__galaxyDebug` +- topic doc `ui/docs/storage.md` describing the browser baseline, + IDB schema, keystore lifecycle, and cache contract Dependencies: Phase 5. Acceptance criteria: -- a freshly generated keypair survives page reloads and produces - signatures that the gateway accepts; +- a freshly generated keypair survives page reloads (the loaded + handle still produces signatures verifiable under the persisted + public key); the live-gateway round-trip is covered by Phase 7; - clearing site data removes the keypair, and the next request triggers a re-login flow; - `KeyStore` and `Cache` interfaces have full TypeScript types and @@ -632,12 +663,19 @@ Acceptance criteria: Targeted tests: -- Vitest unit tests for `IDBCache` with `fake-indexeddb`; -- Vitest unit tests for `WebCryptoKeyStore` exercising generate, load, - sign, clear; -- Playwright integration test: generate keypair, sign a request - through `WasmCore`, send through Connect, verify gateway accepts, - reload the page, sign another request, verify accepted. +- Vitest unit tests for `IDBCache` with `fake-indexeddb` + (round-trip, namespace isolation, delete, clear-with-namespace, + full clear); +- Vitest unit tests for `WebCryptoKeyStore` (generate, load, + signature determinism under Node WebCrypto, signature + verifiability after a simulated reload, third-party verify of the + public key, clear, fresh-keypair-after-clear); +- Playwright e2e (`storage-keypair-persistence.spec.ts`, all four + projects): generate keypair, sign canonical bytes, capture the + signature, reload, assert the previous signature still verifies + under the public key (works on every engine in the baseline + including non-deterministic WebKit), and that + `clearDeviceSession` forces a fresh keypair on next load. ## Phase 7. Auth Flow UI @@ -670,7 +708,10 @@ Acceptance criteria: - the first authenticated Connect call after login (e.g. `user.account.read`) succeeds end-to-end through `WasmCore` → `GalaxyClient` → ConnectRPC → gateway, with the response signature - verified and the payload decoded back to JSON; + verified and the payload decoded back to JSON. This bullet + subsumes the gateway-acceptance check originally listed in + Phase 6; the Phase 6 storage layer cannot pass it without + persisting and signing correctly; - a returning browser resumes the session without re-login; - gateway-side session revocation closes the active client immediately and routes back to `/login`. diff --git a/ui/README.md b/ui/README.md index 6ae6de8..ce496ea 100644 --- a/ui/README.md +++ b/ui/README.md @@ -62,20 +62,24 @@ ui/ ├── Makefile wasm / ts-protos / web / mobile / desktop targets ├── README.md this file ├── buf.gen.yaml local-plugin TS Protobuf-ES generator -├── docs/ topic-based design notes -│ ├── testing.md per-PR / release test tiers -│ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget -├── core/ ui/core Go module (canonical bytes, keypair) -├── wasm/ TinyGo entry point exposing Core to JS -└── frontend/ SvelteKit / Vite source - ├── src/api/ GalaxyClient + typed Connect client - ├── src/platform/core/ Core interface + WasmCore adapter - ├── src/proto/ generated Protobuf-ES + Connect descriptors - └── static/ core.wasm + wasm_exec.js (committed artefacts) +├── docs/ topic-based design notes +│ ├── storage.md web KeyStore/Cache, IDB schema, baseline +│ ├── testing.md per-PR / release test tiers +│ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget +├── core/ ui/core Go module (canonical bytes, keypair) +├── wasm/ TinyGo entry point exposing Core to JS +└── frontend/ SvelteKit / Vite source + ├── src/api/ GalaxyClient + typed Connect client + session + ├── src/platform/core/ Core interface + WasmCore adapter + ├── src/platform/store/ KeyStore/Cache interfaces + web adapter + ├── src/proto/ generated Protobuf-ES + Connect descriptors + └── static/ core.wasm + wasm_exec.js (committed artefacts) ``` Linked topic docs: +- [`docs/storage.md`](docs/storage.md) — web KeyStore/Cache, + IndexedDB schema, browser baseline. - [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build, loading recipe, bundle size budget. - [`docs/testing.md`](docs/testing.md) — Tier 1 per-PR + Tier 2 diff --git a/ui/docs/storage.md b/ui/docs/storage.md new file mode 100644 index 0000000..d4a6de8 --- /dev/null +++ b/ui/docs/storage.md @@ -0,0 +1,201 @@ +# 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 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 + +```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+ | + +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 + +```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. diff --git a/ui/docs/wasm-toolchain.md b/ui/docs/wasm-toolchain.md index a6ea923..3ce0d35 100644 --- a/ui/docs/wasm-toolchain.md +++ b/ui/docs/wasm-toolchain.md @@ -5,6 +5,10 @@ bytes, signature verification, keypair helpers) to WebAssembly via **TinyGo**. The compiled artefact `core.wasm` and its companion runtime shim `wasm_exec.js` ship under `ui/frontend/static/`. +Real Ed25519 signing happens outside WASM in a platform-specific +keystore — see [`storage.md`](storage.md) for the web implementation +(WebCrypto non-extractable keys + IndexedDB). + ## Why TinyGo Two viable Go-to-WASM toolchains exist: diff --git a/ui/frontend/package.json b/ui/frontend/package.json index 0178d95..8911c0f 100644 --- a/ui/frontend/package.json +++ b/ui/frontend/package.json @@ -11,6 +11,9 @@ "test": "vitest run", "test:e2e": "playwright test" }, + "dependencies": { + "idb": "^8.0.3" + }, "devDependencies": { "@bufbuild/protobuf": "^2.12.0", "@bufbuild/protoc-gen-es": "^2.12.0", @@ -23,6 +26,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.2.0", "@types/node": "^22.0.0", + "fake-indexeddb": "^6.2.5", "jsdom": "^25.0.0", "playwright": "^1.59.1", "svelte": "^5.0.0", diff --git a/ui/frontend/src/api/session.ts b/ui/frontend/src/api/session.ts new file mode 100644 index 0000000..f27a701 --- /dev/null +++ b/ui/frontend/src/api/session.ts @@ -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 { + const existing = await keyStore.load(); + const keypair = existing ?? (await keyStore.generate()); + const stored = await cache.get(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 { + 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 { + await Promise.all([ + keyStore.clear(), + cache.delete(SESSION_NAMESPACE, SESSION_ID_KEY), + ]); +} diff --git a/ui/frontend/src/platform/store/idb-cache.ts b/ui/frontend/src/platform/store/idb-cache.ts new file mode 100644 index 0000000..1686177 --- /dev/null +++ b/ui/frontend/src/platform/store/idb-cache.ts @@ -0,0 +1,55 @@ +// `IDBCache` is the IndexedDB-backed [Cache] implementation. The +// underlying object store uses a compound key `[namespace, key]`, so +// `clear(namespace)` is a single bounded cursor walk rather than a +// full-store scan. + +import type { IDBPDatabase } from "idb"; +import type { Cache } from "./index"; +import type { GalaxyDB } from "./idb"; + +export class IDBCache implements Cache { + constructor(private readonly db: IDBPDatabase) {} + + async get( + namespace: string, + key: string, + ): Promise { + const row = await this.db.get("cache", [namespace, key]); + return row?.value as T | undefined; + } + + async put( + namespace: string, + key: string, + value: T, + ): Promise { + await this.db.put("cache", { namespace, key, value }); + } + + async delete(namespace: string, key: string): Promise { + await this.db.delete("cache", [namespace, key]); + } + + async clear(namespace?: string): Promise { + if (namespace === undefined) { + await this.db.clear("cache"); + return; + } + // IndexedDB orders compound keys lexicographically. The pair + // [namespace, ""] is the lower bound and [namespace, "￿"] + // is the upper bound for every key whose first component equals + // `namespace` — ￿ is the largest BMP code point and acts + // as a sentinel above any realistic application-level key. + const range = IDBKeyRange.bound( + [namespace, ""], + [namespace, "￿"], + ); + const tx = this.db.transaction("cache", "readwrite"); + let cursor = await tx.store.openCursor(range); + while (cursor) { + cursor.delete(); + cursor = await cursor.continue(); + } + await tx.done; + } +} diff --git a/ui/frontend/src/platform/store/idb.ts b/ui/frontend/src/platform/store/idb.ts new file mode 100644 index 0000000..c242534 --- /dev/null +++ b/ui/frontend/src/platform/store/idb.ts @@ -0,0 +1,90 @@ +// Shared IndexedDB connection used by both the WebCrypto keystore +// and the IDB cache. Opens one versioned database (`galaxy-ui`) +// containing two object stores: +// +// keypair: out-of-line keys; we always read/write the fixed slot +// `device`. The row carries the `CryptoKey` handles +// (private + public) and a cached raw 32-byte public-key +// export so consumers do not call exportKey on every load. +// cache: keyPath = ['namespace', 'key']; rows are +// { namespace, key, value } where `value` is whatever the +// caller stored. Compound keys give the cache cheap +// namespace-prefix scans without a secondary index. +// +// The database connection is cached per page; tests create their own +// per-test connection by calling [openGalaxyDB] directly with a +// distinct name so cases do not bleed state. + +import { openDB, type DBSchema, type IDBPDatabase } from "idb"; + +export const DB_NAME = "galaxy-ui"; +export const DB_VERSION = 1; + +/** Fixed key for the single device keypair slot in the `keypair` store. */ +export const DEVICE_KEYPAIR_KEY = "device"; + +export interface KeypairRow { + privateKey: CryptoKey; + publicKey: CryptoKey; + publicKeyRaw: ArrayBuffer; +} + +export interface CacheRow { + namespace: string; + key: string; + value: unknown; +} + +export interface GalaxyDB extends DBSchema { + keypair: { + key: string; + value: KeypairRow; + }; + cache: { + key: [string, string]; + value: CacheRow; + }; +} + +let cached: Promise> | undefined; + +/** + * dbConnection returns the cached `galaxy-ui` database connection, + * opening it on first call. Production callers always go through + * this; tests use [openGalaxyDB] with a per-test name instead. + */ +export function dbConnection(): Promise> { + if (!cached) { + cached = openGalaxyDB(DB_NAME); + } + return cached; +} + +/** + * resetDbConnection clears the cached connection. Tests call this + * between cases that share `DB_NAME`; production code never does. + */ +export function resetDbConnection(): void { + cached = undefined; +} + +/** + * openGalaxyDB opens a Galaxy UI database under the given name. The + * upgrade callback installs the two stores defined by [GalaxyDB] on + * the version-1 schema; later schema changes bump [DB_VERSION] and + * extend the callback in place. + */ +export function openGalaxyDB(name: string): Promise> { + return openDB(name, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains("keypair")) { + db.createObjectStore("keypair"); + } + if (!db.objectStoreNames.contains("cache")) { + db.createObjectStore("cache", { + keyPath: ["namespace", "key"], + }); + } + }, + }); +} diff --git a/ui/frontend/src/platform/store/index.ts b/ui/frontend/src/platform/store/index.ts new file mode 100644 index 0000000..0c06efc --- /dev/null +++ b/ui/frontend/src/platform/store/index.ts @@ -0,0 +1,69 @@ +// `KeyStore` and `Cache` are the platform-agnostic persistence +// boundaries every Galaxy UI build target (web, Wails desktop, +// Capacitor mobile) must satisfy. The web implementation in this +// directory backs them with WebCrypto non-exportable Ed25519 keys +// and IndexedDB; later phases plug Wails Keychain / Capacitor +// Preferences and SQLite behind the same interfaces without touching +// the orchestration layer. +// +// Public signatures intentionally use only `Uint8Array`, `string`, +// and `unknown` — no `CryptoKey`, no `IDBDatabase`, no `idb` symbols +// — so callers stay portable across platforms. The `Signer` type from +// `api/galaxy-client.ts` is structurally compatible with +// `DeviceKeypair.sign`: the constructor takes +// `signer: keypair.sign.bind(keypair)` (or an arrow wrapper). + +/** + * DeviceKeypair is the load-or-generate output of a [KeyStore]: the + * raw 32-byte Ed25519 public key in `publicKey`, and a `sign` method + * that produces the raw 64-byte Ed25519 signature for the given + * canonical bytes. The private key never leaves the platform's secure + * storage. + */ +export interface DeviceKeypair { + readonly publicKey: Uint8Array; + sign(canonicalBytes: Uint8Array): Promise; +} + +/** + * KeyStore persists exactly one device keypair at a time. `load` + * returns `null` when no keypair has been generated yet; `generate` + * always overwrites any prior keypair with a fresh one; `clear` + * deletes the stored keypair so the next `load` returns `null`. + */ +export interface KeyStore { + load(): Promise; + generate(): Promise; + clear(): Promise; +} + +/** + * Cache is a generic local key-value store partitioned by namespace. + * Values flow through the platform's structured-clone equivalent, so + * `Uint8Array`, plain objects, nested arrays, and primitives all + * round-trip. `clear()` without a namespace wipes every entry; with + * a namespace it wipes only that namespace's keys. + */ +export interface Cache { + get(namespace: string, key: string): Promise; + put(namespace: string, key: string, value: T): Promise; + delete(namespace: string, key: string): Promise; + clear(namespace?: string): Promise; +} + +export interface Store { + keyStore: KeyStore; + cache: Cache; +} + +export type StoreLoader = () => Promise; + +import { loadWebStore } from "./web"; + +/** + * loadStore resolves the [Store] implementation appropriate for the + * current build target. Phase 6 ships only the web adapter; later + * phases plug `WailsStore` and `CapacitorStore` here behind a + * build-time selector. + */ +export const loadStore: StoreLoader = loadWebStore; diff --git a/ui/frontend/src/platform/store/web.ts b/ui/frontend/src/platform/store/web.ts new file mode 100644 index 0000000..bdbe8e8 --- /dev/null +++ b/ui/frontend/src/platform/store/web.ts @@ -0,0 +1,18 @@ +// `loadWebStore` is the [StoreLoader] used by the browser build +// target. It opens the shared `galaxy-ui` IndexedDB connection and +// hands the same handle to both the keystore and the cache, so the +// page boots one DB connection regardless of which subsystem touches +// storage first. + +import type { Store, StoreLoader } from "./index"; +import { dbConnection } from "./idb"; +import { IDBCache } from "./idb-cache"; +import { WebCryptoKeyStore } from "./webcrypto-keystore"; + +export const loadWebStore: StoreLoader = async (): Promise => { + const db = await dbConnection(); + return { + keyStore: new WebCryptoKeyStore(db), + cache: new IDBCache(db), + }; +}; diff --git a/ui/frontend/src/platform/store/webcrypto-keystore.ts b/ui/frontend/src/platform/store/webcrypto-keystore.ts new file mode 100644 index 0000000..98dea79 --- /dev/null +++ b/ui/frontend/src/platform/store/webcrypto-keystore.ts @@ -0,0 +1,76 @@ +// `WebCryptoKeyStore` persists exactly one Ed25519 device keypair in +// IndexedDB using the WebCrypto subtle API. The private key is +// generated with `extractable: false`, which means even the page that +// owns the handle cannot read out the raw bytes — only the platform's +// signing primitive can use it. The matching public key is forced +// extractable by the WebCrypto spec for asymmetric pairs (you cannot +// publish a key you cannot read), and we cache its raw 32-byte +// export alongside the handle so consumers do not pay an exportKey +// round-trip on every page load. +// +// Both `CryptoKey` handles round-trip through IndexedDB's +// structured-clone path; the non-exportable property is preserved +// across page reloads. The browser baseline is documented in +// `ui/docs/storage.md` (Chrome ≥137, Firefox ≥130, Safari ≥17.4). + +import type { IDBPDatabase } from "idb"; +import type { DeviceKeypair, KeyStore } from "./index"; +import { + DEVICE_KEYPAIR_KEY, + type GalaxyDB, + type KeypairRow, +} from "./idb"; + +const ED25519_ALG = { name: "Ed25519" } as const; + +export class WebCryptoKeyStore implements KeyStore { + constructor(private readonly db: IDBPDatabase) {} + + async load(): Promise { + const row = await this.db.get("keypair", DEVICE_KEYPAIR_KEY); + if (!row) { + return null; + } + return makeDeviceKeypair(row); + } + + async generate(): Promise { + const pair = (await crypto.subtle.generateKey( + ED25519_ALG, + false, + ["sign", "verify"], + )) as CryptoKeyPair; + const publicKeyRaw = await crypto.subtle.exportKey("raw", pair.publicKey); + const row: KeypairRow = { + privateKey: pair.privateKey, + publicKey: pair.publicKey, + publicKeyRaw, + }; + await this.db.put("keypair", row, DEVICE_KEYPAIR_KEY); + return makeDeviceKeypair(row); + } + + async clear(): Promise { + await this.db.delete("keypair", DEVICE_KEYPAIR_KEY); + } +} + +function makeDeviceKeypair(row: KeypairRow): DeviceKeypair { + const publicKey = new Uint8Array(row.publicKeyRaw); + return { + publicKey, + async sign(canonicalBytes: Uint8Array): Promise { + // TS 5.7+ tightened BufferSource to require an ArrayBuffer- + // backed view; an arbitrary Uint8Array could in principle be + // SharedArrayBuffer-backed. The signature input is not, so + // the cast is safe and keeps the public Signer type + // (Uint8Array) un-narrowed. + const signature = await crypto.subtle.sign( + ED25519_ALG, + row.privateKey, + canonicalBytes as BufferSource, + ); + return new Uint8Array(signature); + }, + }; +} diff --git a/ui/frontend/src/routes/__debug/store/+page.svelte b/ui/frontend/src/routes/__debug/store/+page.svelte new file mode 100644 index 0000000..69ff1d9 --- /dev/null +++ b/ui/frontend/src/routes/__debug/store/+page.svelte @@ -0,0 +1,119 @@ + + +
+

store debug

+ {#if ready} +

debug store ready

+ {:else} +

booting…

+ {/if} +
+ + diff --git a/ui/frontend/src/routes/__debug/store/+page.ts b/ui/frontend/src/routes/__debug/store/+page.ts new file mode 100644 index 0000000..bd9c318 --- /dev/null +++ b/ui/frontend/src/routes/__debug/store/+page.ts @@ -0,0 +1,12 @@ +// Debug-only route used by Playwright e2e tests in Phase 6 to drive +// the [KeyStore]/[Cache] surface from the browser. SSR is disabled so +// the keystore code only runs in the browser, and prerender is +// disabled so the static-adapter build never freezes a debug page +// into the production bundle. +// +// The route itself is gated at runtime by `import.meta.env.DEV` +// inside `+page.svelte` — a production build still emits an empty +// shell here, but the debug entry point never attaches. + +export const prerender = false; +export const ssr = false; diff --git a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts new file mode 100644 index 0000000..5784888 --- /dev/null +++ b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts @@ -0,0 +1,129 @@ +// Verifies the Phase 6 storage layer end-to-end in real browser +// engines: a freshly generated device keypair persists across a page +// reload, signs deterministically with the same private key after the +// reload, and is wiped by `clearDeviceSession` so the next load +// generates a different keypair. The live-gateway round-trip is +// covered by Phase 7's e2e once the email-code login flow lands; +// this spec deliberately stops at the storage boundary. + +import { expect, test, type Page } from "@playwright/test"; + +interface DebugSnapshot { + publicKey: number[]; + deviceSessionId: string | null; +} + +interface DebugSurface { + ready: true; + loadSession(): Promise; + clearSession(): Promise; + signWithDevice(message: number[]): Promise; + setDeviceSessionId(id: string): Promise; + verifyWithStoredPublicKey( + message: number[], + signature: number[], + ): Promise; +} + +declare global { + interface Window { + __galaxyDebug?: DebugSurface; + } +} + +const CANONICAL = Array.from(new TextEncoder().encode("phase-6-canonical")); + +async function bootDebugPage(page: Page): Promise { + await page.goto("/__debug/store"); + await expect(page.getByTestId("debug-store-ready")).toBeVisible(); + await page.waitForFunction(() => window.__galaxyDebug?.ready === true); +} + +test("device keypair survives reload and produces verifiable signatures", async ({ + page, +}) => { + await bootDebugPage(page); + // Wipe any leftover state from a previous Playwright run that + // shared the same browser profile. + await page.evaluate(() => window.__galaxyDebug!.clearSession()); + + const first = await page.evaluate(async (canonical) => { + const sess = await window.__galaxyDebug!.loadSession(); + const signature = await window.__galaxyDebug!.signWithDevice(canonical); + return { publicKey: sess.publicKey, signature }; + }, CANONICAL); + expect(first.publicKey.length).toBe(32); + expect(first.signature.length).toBe(64); + + await page.reload(); + await expect(page.getByTestId("debug-store-ready")).toBeVisible(); + await page.waitForFunction(() => window.__galaxyDebug?.ready === true); + + const second = await page.evaluate( + async ({ canonical, firstSig }) => { + const sess = await window.__galaxyDebug!.loadSession(); + const fresh = await window.__galaxyDebug!.signWithDevice(canonical); + // Signatures produced before the reload must verify under the + // post-reload public key. A pre-reload signature only verifies + // when the persisted private key is identical to the original. + const prevVerifies = + await window.__galaxyDebug!.verifyWithStoredPublicKey( + canonical, + firstSig, + ); + const freshVerifies = + await window.__galaxyDebug!.verifyWithStoredPublicKey( + canonical, + fresh, + ); + return { + publicKey: sess.publicKey, + signature: fresh, + prevVerifies, + freshVerifies, + }; + }, + { canonical: CANONICAL, firstSig: first.signature }, + ); + expect(second.publicKey).toEqual(first.publicKey); + expect(second.prevVerifies).toBe(true); + expect(second.freshVerifies).toBe(true); +}); + +test("clearDeviceSession forces a fresh keypair on next load", async ({ + page, +}) => { + await bootDebugPage(page); + await page.evaluate(() => window.__galaxyDebug!.clearSession()); + const before = await page.evaluate(async () => { + const sess = await window.__galaxyDebug!.loadSession(); + return sess.publicKey; + }); + + await page.evaluate(() => window.__galaxyDebug!.clearSession()); + const after = await page.evaluate(async () => { + const sess = await window.__galaxyDebug!.loadSession(); + return sess.publicKey; + }); + + expect(after).not.toEqual(before); +}); + +test("setDeviceSessionId is observable through loadDeviceSession", async ({ + page, +}) => { + await bootDebugPage(page); + await page.evaluate(() => window.__galaxyDebug!.clearSession()); + const before = await page.evaluate(async () => { + const sess = await window.__galaxyDebug!.loadSession(); + return sess.deviceSessionId; + }); + expect(before).toBeNull(); + + await page.evaluate(() => window.__galaxyDebug!.setDeviceSessionId("dev-1")); + const after = await page.evaluate(async () => { + const sess = await window.__galaxyDebug!.loadSession(); + return sess.deviceSessionId; + }); + expect(after).toBe("dev-1"); +}); diff --git a/ui/frontend/tests/store-idb-cache.test.ts b/ui/frontend/tests/store-idb-cache.test.ts new file mode 100644 index 0000000..6e23c79 --- /dev/null +++ b/ui/frontend/tests/store-idb-cache.test.ts @@ -0,0 +1,80 @@ +// IDBCache unit tests under JSDOM with `fake-indexeddb` standing in +// for the browser's IndexedDB factory. Each case opens a freshly +// named database so state cannot leak across tests. + +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import type { IDBPDatabase } from "idb"; +import { IDBCache } from "../src/platform/store/idb-cache"; +import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb"; + +let db: IDBPDatabase; +let dbName: string; + +beforeEach(async () => { + dbName = `galaxy-ui-test-${crypto.randomUUID()}`; + db = await openGalaxyDB(dbName); +}); + +afterEach(async () => { + db.close(); + indexedDB.deleteDatabase(dbName); +}); + +describe("IDBCache", () => { + test("round-trips a typed object", async () => { + const cache = new IDBCache(db); + const value = { + name: "ping", + payload: new Uint8Array([1, 2, 3]), + nested: { count: 7 }, + }; + await cache.put("commands", "k1", value); + const out = await cache.get("commands", "k1"); + expect(out?.name).toBe("ping"); + expect(out?.nested.count).toBe(7); + expect(Array.from(out?.payload ?? [])).toEqual([1, 2, 3]); + }); + + test("namespaces are isolated", async () => { + const cache = new IDBCache(db); + await cache.put("a", "shared-key", "from-a"); + await cache.put("b", "shared-key", "from-b"); + expect(await cache.get("a", "shared-key")).toBe("from-a"); + expect(await cache.get("b", "shared-key")).toBe("from-b"); + }); + + test("delete removes a single row without touching neighbours", async () => { + const cache = new IDBCache(db); + await cache.put("ns", "k1", "v1"); + await cache.put("ns", "k2", "v2"); + await cache.delete("ns", "k1"); + expect(await cache.get("ns", "k1")).toBeUndefined(); + expect(await cache.get("ns", "k2")).toBe("v2"); + }); + + test("clear(namespace) wipes only that namespace", async () => { + const cache = new IDBCache(db); + await cache.put("a", "k1", "a1"); + await cache.put("a", "k2", "a2"); + await cache.put("b", "k1", "b1"); + await cache.clear("a"); + expect(await cache.get("a", "k1")).toBeUndefined(); + expect(await cache.get("a", "k2")).toBeUndefined(); + expect(await cache.get("b", "k1")).toBe("b1"); + }); + + test("clear() wipes every namespace", async () => { + const cache = new IDBCache(db); + await cache.put("a", "k1", "a1"); + await cache.put("b", "k1", "b1"); + await cache.clear(); + expect(await cache.get("a", "k1")).toBeUndefined(); + expect(await cache.get("b", "k1")).toBeUndefined(); + }); + + test("get on a missing key returns undefined", async () => { + const cache = new IDBCache(db); + expect(await cache.get("absent", "k")).toBeUndefined(); + }); +}); diff --git a/ui/frontend/tests/store-webcrypto-keystore.test.ts b/ui/frontend/tests/store-webcrypto-keystore.test.ts new file mode 100644 index 0000000..5c681dc --- /dev/null +++ b/ui/frontend/tests/store-webcrypto-keystore.test.ts @@ -0,0 +1,113 @@ +// WebCryptoKeyStore unit tests under JSDOM. Uses Node 22's WebCrypto +// (Ed25519 has been stable since Node 20) and `fake-indexeddb/auto` +// for storage. The "simulated reload" case closes the database and +// reopens it under the same name to prove the persisted keypair +// still signs after the connection round-trips. + +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import type { IDBPDatabase } from "idb"; +import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; +import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb"; + +let db: IDBPDatabase; +let dbName: string; + +beforeEach(async () => { + dbName = `galaxy-ui-test-${crypto.randomUUID()}`; + db = await openGalaxyDB(dbName); +}); + +afterEach(async () => { + db.close(); + indexedDB.deleteDatabase(dbName); +}); + +describe("WebCryptoKeyStore", () => { + test("generate produces a 32-byte raw Ed25519 public key", async () => { + const ks = new WebCryptoKeyStore(db); + const keypair = await ks.generate(); + expect(keypair.publicKey).toBeInstanceOf(Uint8Array); + expect(keypair.publicKey.length).toBe(32); + }); + + test("generate-then-load returns the same public key and a working signer", async () => { + const ks = new WebCryptoKeyStore(db); + const fresh = await ks.generate(); + const loaded = await ks.load(); + expect(loaded).not.toBeNull(); + expect(Array.from(loaded!.publicKey)).toEqual(Array.from(fresh.publicKey)); + + const canonical = new TextEncoder().encode("canonical-bytes"); + const sigA = await fresh.sign(canonical); + const sigB = await loaded!.sign(canonical); + // Ed25519 is deterministic: identical (key, message) ⇒ identical + // signature bytes. This proves the loaded handle is the same + // signing key as the freshly generated one without ever + // touching the private bytes. + expect(Array.from(sigA)).toEqual(Array.from(sigB)); + }); + + test("produced signature verifies under a third-party public key import", async () => { + const ks = new WebCryptoKeyStore(db); + const keypair = await ks.generate(); + const canonical = new TextEncoder().encode("verify-me"); + const signature = await keypair.sign(canonical); + expect(signature.length).toBe(64); + + const verifyKey = await crypto.subtle.importKey( + "raw", + keypair.publicKey as BufferSource, + { name: "Ed25519" }, + false, + ["verify"], + ); + const ok = await crypto.subtle.verify( + { name: "Ed25519" }, + verifyKey, + signature as BufferSource, + canonical as BufferSource, + ); + expect(ok).toBe(true); + }); + + test("survives a simulated page reload", async () => { + const ks1 = new WebCryptoKeyStore(db); + const generated = await ks1.generate(); + const canonical = new TextEncoder().encode("reload-canonical"); + const sigBefore = await generated.sign(canonical); + + db.close(); + db = await openGalaxyDB(dbName); + const ks2 = new WebCryptoKeyStore(db); + const reloaded = await ks2.load(); + expect(reloaded).not.toBeNull(); + expect(Array.from(reloaded!.publicKey)).toEqual( + Array.from(generated.publicKey), + ); + const sigAfter = await reloaded!.sign(canonical); + expect(Array.from(sigAfter)).toEqual(Array.from(sigBefore)); + }); + + test("clear empties the slot", async () => { + const ks = new WebCryptoKeyStore(db); + await ks.generate(); + await ks.clear(); + expect(await ks.load()).toBeNull(); + }); + + test("load on a fresh database returns null", async () => { + const ks = new WebCryptoKeyStore(db); + expect(await ks.load()).toBeNull(); + }); + + test("generate after clear yields a different keypair", async () => { + const ks = new WebCryptoKeyStore(db); + const first = await ks.generate(); + await ks.clear(); + const second = await ks.generate(); + expect(Array.from(second.publicKey)).not.toEqual( + Array.from(first.publicKey), + ); + }); +}); diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 90b72c2..fe2781b 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: frontend: + dependencies: + idb: + specifier: ^8.0.3 + version: 8.0.3 devDependencies: '@bufbuild/protobuf': specifier: ^2.12.0 @@ -41,6 +45,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.17 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jsdom: specifier: ^25.0.0 version: 25.0.1 @@ -557,6 +564,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -623,6 +634,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + idb@8.0.3: + resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -1495,6 +1509,8 @@ snapshots: expect-type@1.3.0: {} + fake-indexeddb@6.2.5: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1567,6 +1583,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@8.0.3: {} + indent-string@4.0.0: {} is-potential-custom-element-name@1.0.1: {}