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>
114 lines
3.7 KiB
TypeScript
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),
|
|
);
|
|
});
|
|
});
|