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,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