Files
galaxy-game/ui/frontend/tests/store-webcrypto-keystore.test.ts
T
Ilia Denisov ecd2bc9348 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>
2026-05-07 14:08:09 +02:00

114 lines
3.7 KiB
TypeScript

// 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),
);
});
});