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:
@@ -572,7 +572,9 @@ and revoke metadata.
|
|||||||
the device.
|
the device.
|
||||||
- Browser/WASM clients use WebCrypto with non-exportable storage where
|
- Browser/WASM clients use WebCrypto with non-exportable storage where
|
||||||
available. Loss of browser storage is acceptable and is recovered by
|
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
|
### Request envelope
|
||||||
|
|
||||||
|
|||||||
+59
-18
@@ -607,24 +607,55 @@ provide a generic local cache for game state. Defines the
|
|||||||
TypeScript-side `KeyStore` and `Cache` interfaces that desktop and
|
TypeScript-side `KeyStore` and `Cache` interfaces that desktop and
|
||||||
mobile adapters will satisfy in later phases.
|
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:
|
Artifacts:
|
||||||
|
|
||||||
- `ui/frontend/src/platform/store/index.ts` defining `KeyStore` and
|
- `ui/frontend/src/platform/store/index.ts` — public `KeyStore`,
|
||||||
`Cache` interfaces
|
`Cache`, `DeviceKeypair` interfaces and the `loadStore()`
|
||||||
- `ui/frontend/src/platform/store/idb-cache.ts` IndexedDB-backed
|
resolver, with no web-specific imports in any public signature
|
||||||
`Cache` using the `idb` library
|
- `ui/frontend/src/platform/store/idb.ts` — shared `galaxy-ui`
|
||||||
- `ui/frontend/src/platform/store/webcrypto-keystore.ts` WebCrypto
|
IndexedDB connection (typed via `idb`'s `DBSchema`) used by both
|
||||||
non-exportable Ed25519 key generation and IndexedDB handle
|
the keystore and the cache
|
||||||
persistence
|
- `ui/frontend/src/platform/store/idb-cache.ts` — IndexedDB-backed
|
||||||
- `ui/frontend/src/api/session.ts` thin layer that loads or creates the
|
`Cache` keyed by compound `[namespace, key]`
|
||||||
device session at app startup
|
- `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.
|
Dependencies: Phase 5.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
- a freshly generated keypair survives page reloads and produces
|
- a freshly generated keypair survives page reloads (the loaded
|
||||||
signatures that the gateway accepts;
|
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
|
- clearing site data removes the keypair, and the next request
|
||||||
triggers a re-login flow;
|
triggers a re-login flow;
|
||||||
- `KeyStore` and `Cache` interfaces have full TypeScript types and
|
- `KeyStore` and `Cache` interfaces have full TypeScript types and
|
||||||
@@ -632,12 +663,19 @@ Acceptance criteria:
|
|||||||
|
|
||||||
Targeted tests:
|
Targeted tests:
|
||||||
|
|
||||||
- Vitest unit tests for `IDBCache` with `fake-indexeddb`;
|
- Vitest unit tests for `IDBCache` with `fake-indexeddb`
|
||||||
- Vitest unit tests for `WebCryptoKeyStore` exercising generate, load,
|
(round-trip, namespace isolation, delete, clear-with-namespace,
|
||||||
sign, clear;
|
full clear);
|
||||||
- Playwright integration test: generate keypair, sign a request
|
- Vitest unit tests for `WebCryptoKeyStore` (generate, load,
|
||||||
through `WasmCore`, send through Connect, verify gateway accepts,
|
signature determinism under Node WebCrypto, signature
|
||||||
reload the page, sign another request, verify accepted.
|
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
|
## Phase 7. Auth Flow UI
|
||||||
|
|
||||||
@@ -670,7 +708,10 @@ Acceptance criteria:
|
|||||||
- the first authenticated Connect call after login (e.g.
|
- the first authenticated Connect call after login (e.g.
|
||||||
`user.account.read`) succeeds end-to-end through `WasmCore` →
|
`user.account.read`) succeeds end-to-end through `WasmCore` →
|
||||||
`GalaxyClient` → ConnectRPC → gateway, with the response signature
|
`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;
|
- a returning browser resumes the session without re-login;
|
||||||
- gateway-side session revocation closes the active client immediately
|
- gateway-side session revocation closes the active client immediately
|
||||||
and routes back to `/login`.
|
and routes back to `/login`.
|
||||||
|
|||||||
+5
-1
@@ -63,19 +63,23 @@ ui/
|
|||||||
├── README.md this file
|
├── README.md this file
|
||||||
├── buf.gen.yaml local-plugin TS Protobuf-ES generator
|
├── buf.gen.yaml local-plugin TS Protobuf-ES generator
|
||||||
├── docs/ topic-based design notes
|
├── docs/ topic-based design notes
|
||||||
|
│ ├── storage.md web KeyStore/Cache, IDB schema, baseline
|
||||||
│ ├── testing.md per-PR / release test tiers
|
│ ├── testing.md per-PR / release test tiers
|
||||||
│ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget
|
│ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget
|
||||||
├── core/ ui/core Go module (canonical bytes, keypair)
|
├── core/ ui/core Go module (canonical bytes, keypair)
|
||||||
├── wasm/ TinyGo entry point exposing Core to JS
|
├── wasm/ TinyGo entry point exposing Core to JS
|
||||||
└── frontend/ SvelteKit / Vite source
|
└── frontend/ SvelteKit / Vite source
|
||||||
├── src/api/ GalaxyClient + typed Connect client
|
├── src/api/ GalaxyClient + typed Connect client + session
|
||||||
├── src/platform/core/ Core interface + WasmCore adapter
|
├── src/platform/core/ Core interface + WasmCore adapter
|
||||||
|
├── src/platform/store/ KeyStore/Cache interfaces + web adapter
|
||||||
├── src/proto/ generated Protobuf-ES + Connect descriptors
|
├── src/proto/ generated Protobuf-ES + Connect descriptors
|
||||||
└── static/ core.wasm + wasm_exec.js (committed artefacts)
|
└── static/ core.wasm + wasm_exec.js (committed artefacts)
|
||||||
```
|
```
|
||||||
|
|
||||||
Linked topic docs:
|
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,
|
- [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build,
|
||||||
loading recipe, bundle size budget.
|
loading recipe, bundle size budget.
|
||||||
- [`docs/testing.md`](docs/testing.md) — Tier 1 per-PR + Tier 2
|
- [`docs/testing.md`](docs/testing.md) — Tier 1 per-PR + Tier 2
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -5,6 +5,10 @@ bytes, signature verification, keypair helpers) to WebAssembly via
|
|||||||
**TinyGo**. The compiled artefact `core.wasm` and its companion
|
**TinyGo**. The compiled artefact `core.wasm` and its companion
|
||||||
runtime shim `wasm_exec.js` ship under `ui/frontend/static/`.
|
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
|
## Why TinyGo
|
||||||
|
|
||||||
Two viable Go-to-WASM toolchains exist:
|
Two viable Go-to-WASM toolchains exist:
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"idb": "^8.0.3"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bufbuild/protobuf": "^2.12.0",
|
"@bufbuild/protobuf": "^2.12.0",
|
||||||
"@bufbuild/protoc-gen-es": "^2.12.0",
|
"@bufbuild/protoc-gen-es": "^2.12.0",
|
||||||
@@ -23,6 +26,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.2.0",
|
"@testing-library/svelte": "^5.2.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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<GalaxyDB>) {}
|
||||||
|
|
||||||
|
async get<T = unknown>(
|
||||||
|
namespace: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
const row = await this.db.get("cache", [namespace, key]);
|
||||||
|
return row?.value as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T = unknown>(
|
||||||
|
namespace: string,
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.db.put("cache", { namespace, key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(namespace: string, key: string): Promise<void> {
|
||||||
|
await this.db.delete("cache", [namespace, key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(namespace?: string): Promise<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IDBPDatabase<GalaxyDB>> | 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<IDBPDatabase<GalaxyDB>> {
|
||||||
|
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<IDBPDatabase<GalaxyDB>> {
|
||||||
|
return openDB<GalaxyDB>(name, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
if (!db.objectStoreNames.contains("keypair")) {
|
||||||
|
db.createObjectStore("keypair");
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains("cache")) {
|
||||||
|
db.createObjectStore("cache", {
|
||||||
|
keyPath: ["namespace", "key"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<DeviceKeypair | null>;
|
||||||
|
generate(): Promise<DeviceKeypair>;
|
||||||
|
clear(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T = unknown>(namespace: string, key: string): Promise<T | undefined>;
|
||||||
|
put<T = unknown>(namespace: string, key: string, value: T): Promise<void>;
|
||||||
|
delete(namespace: string, key: string): Promise<void>;
|
||||||
|
clear(namespace?: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Store {
|
||||||
|
keyStore: KeyStore;
|
||||||
|
cache: Cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoreLoader = () => Promise<Store>;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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<Store> => {
|
||||||
|
const db = await dbConnection();
|
||||||
|
return {
|
||||||
|
keyStore: new WebCryptoKeyStore(db),
|
||||||
|
cache: new IDBCache(db),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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<GalaxyDB>) {}
|
||||||
|
|
||||||
|
async load(): Promise<DeviceKeypair | null> {
|
||||||
|
const row = await this.db.get("keypair", DEVICE_KEYPAIR_KEY);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return makeDeviceKeypair(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generate(): Promise<DeviceKeypair> {
|
||||||
|
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<void> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import {
|
||||||
|
clearDeviceSession,
|
||||||
|
loadDeviceSession,
|
||||||
|
setDeviceSessionId,
|
||||||
|
} from "../../../api/session";
|
||||||
|
import { loadStore } from "../../../platform/store/index";
|
||||||
|
|
||||||
|
interface DebugSnapshot {
|
||||||
|
publicKey: number[];
|
||||||
|
deviceSessionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DebugSurface {
|
||||||
|
ready: true;
|
||||||
|
loadSession(): Promise<DebugSnapshot>;
|
||||||
|
clearSession(): Promise<void>;
|
||||||
|
signWithDevice(message: number[]): Promise<number[]>;
|
||||||
|
setDeviceSessionId(id: string): Promise<void>;
|
||||||
|
verifyWithStoredPublicKey(
|
||||||
|
message: number[],
|
||||||
|
signature: number[],
|
||||||
|
): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
|
||||||
|
|
||||||
|
let ready = $state(false);
|
||||||
|
|
||||||
|
function describe(err: unknown): string {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return `${err.name}: ${err.message}`;
|
||||||
|
}
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { keyStore, cache } = await loadStore();
|
||||||
|
const surface: DebugSurface = {
|
||||||
|
ready: true,
|
||||||
|
async loadSession() {
|
||||||
|
try {
|
||||||
|
const session = await loadDeviceSession(keyStore, cache);
|
||||||
|
return {
|
||||||
|
publicKey: Array.from(session.keypair.publicKey),
|
||||||
|
deviceSessionId: session.deviceSessionId,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`loadSession: ${describe(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async clearSession() {
|
||||||
|
try {
|
||||||
|
await clearDeviceSession(keyStore, cache);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`clearSession: ${describe(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async signWithDevice(message: number[]) {
|
||||||
|
try {
|
||||||
|
const session = await loadDeviceSession(keyStore, cache);
|
||||||
|
const sig = await session.keypair.sign(new Uint8Array(message));
|
||||||
|
return Array.from(sig);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`signWithDevice: ${describe(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setDeviceSessionId(id: string) {
|
||||||
|
try {
|
||||||
|
await setDeviceSessionId(cache, id);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`setDeviceSessionId: ${describe(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async verifyWithStoredPublicKey(message, signature) {
|
||||||
|
try {
|
||||||
|
const session = await loadDeviceSession(keyStore, cache);
|
||||||
|
const importedPublic = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
session.keypair.publicKey as BufferSource,
|
||||||
|
{ name: "Ed25519" },
|
||||||
|
false,
|
||||||
|
["verify"],
|
||||||
|
);
|
||||||
|
return await crypto.subtle.verify(
|
||||||
|
{ name: "Ed25519" },
|
||||||
|
importedPublic,
|
||||||
|
new Uint8Array(signature) as BufferSource,
|
||||||
|
new Uint8Array(message) as BufferSource,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`verifyWithStoredPublicKey: ${describe(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(window as DebugWindow).__galaxyDebug = surface;
|
||||||
|
ready = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>store debug</h1>
|
||||||
|
{#if ready}
|
||||||
|
<p data-testid="debug-store-ready">debug store ready</p>
|
||||||
|
{:else}
|
||||||
|
<p>booting…</p>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
padding: 2rem;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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;
|
||||||
@@ -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<DebugSnapshot>;
|
||||||
|
clearSession(): Promise<void>;
|
||||||
|
signWithDevice(message: number[]): Promise<number[]>;
|
||||||
|
setDeviceSessionId(id: string): Promise<void>;
|
||||||
|
verifyWithStoredPublicKey(
|
||||||
|
message: number[],
|
||||||
|
signature: number[],
|
||||||
|
): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__galaxyDebug?: DebugSurface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANONICAL = Array.from(new TextEncoder().encode("phase-6-canonical"));
|
||||||
|
|
||||||
|
async function bootDebugPage(page: Page): Promise<void> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
@@ -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<GalaxyDB>;
|
||||||
|
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<typeof value>("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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<GalaxyDB>;
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+18
@@ -7,6 +7,10 @@ settings:
|
|||||||
importers:
|
importers:
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
dependencies:
|
||||||
|
idb:
|
||||||
|
specifier: ^8.0.3
|
||||||
|
version: 8.0.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@bufbuild/protobuf':
|
'@bufbuild/protobuf':
|
||||||
specifier: ^2.12.0
|
specifier: ^2.12.0
|
||||||
@@ -41,6 +45,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.17
|
version: 22.19.17
|
||||||
|
fake-indexeddb:
|
||||||
|
specifier: ^6.2.5
|
||||||
|
version: 6.2.5
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^25.0.0
|
specifier: ^25.0.0
|
||||||
version: 25.0.1
|
version: 25.0.1
|
||||||
@@ -557,6 +564,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
fake-indexeddb@6.2.5:
|
||||||
|
resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -623,6 +634,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
idb@8.0.3:
|
||||||
|
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
|
||||||
|
|
||||||
indent-string@4.0.0:
|
indent-string@4.0.0:
|
||||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1495,6 +1509,8 @@ snapshots:
|
|||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
|
fake-indexeddb@6.2.5: {}
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.4):
|
fdir@6.5.0(picomatch@4.0.4):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
@@ -1567,6 +1583,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
idb@8.0.3: {}
|
||||||
|
|
||||||
indent-string@4.0.0: {}
|
indent-string@4.0.0: {}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1: {}
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user