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,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");
});
+80
View File
@@ -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),
);
});
});