ecd2bc9348
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>
130 lines
4.1 KiB
TypeScript
130 lines
4.1 KiB
TypeScript
// 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");
|
|
});
|