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