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:
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user