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:
Ilia Denisov
2026-05-07 14:08:09 +02:00
parent 87a6694e2d
commit ecd2bc9348
18 changed files with 1133 additions and 29 deletions
@@ -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;