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:
Ilia Denisov
2026-05-07 14:08:09 +02:00
parent 87a6694e2d
commit ecd2bc9348
18 changed files with 1133 additions and 29 deletions
+4
View File
@@ -11,6 +11,9 @@
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"idb": "^8.0.3"
},
"devDependencies": {
"@bufbuild/protobuf": "^2.12.0",
"@bufbuild/protoc-gen-es": "^2.12.0",
@@ -23,6 +26,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.2.0",
"@types/node": "^22.0.0",
"fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.0",
"playwright": "^1.59.1",
"svelte": "^5.0.0",
+69
View File
@@ -0,0 +1,69 @@
// `session.ts` is the thin layer that loads or creates the device
// session at app startup. The keypair half is always materialised
// (loaded from the [KeyStore], or freshly generated when the slot is
// empty); the device-session-id half is read out of the [Cache] and
// is `null` until Phase 7's confirm-email-code handler stores it via
// [setDeviceSessionId]. A `null` deviceSessionId is the marker that
// the user must run the email-code login flow.
//
// Phase 7 wires the production caller (a layout-level loader that
// runs `loadDeviceSession` on first render and forwards the result
// into the Svelte session store). Phase 6 ships the persistence
// primitives this loader needs.
import type { Cache, DeviceKeypair, KeyStore } from "../platform/store/index";
export const SESSION_NAMESPACE = "session";
export const SESSION_ID_KEY = "device-session-id";
export interface DeviceSession {
keypair: DeviceKeypair;
deviceSessionId: string | null;
}
/**
* loadDeviceSession returns the device session for the current
* device. The keypair is loaded from `keyStore`; if the slot is
* empty, a fresh non-exportable Ed25519 keypair is generated and
* persisted. The returned `deviceSessionId` is `null` until Phase 7
* registers the public key with the gateway and persists the
* resulting id via [setDeviceSessionId].
*/
export async function loadDeviceSession(
keyStore: KeyStore,
cache: Cache,
): Promise<DeviceSession> {
const existing = await keyStore.load();
const keypair = existing ?? (await keyStore.generate());
const stored = await cache.get<string>(SESSION_NAMESPACE, SESSION_ID_KEY);
return { keypair, deviceSessionId: stored ?? null };
}
/**
* setDeviceSessionId persists the device-session id returned by the
* gateway's confirm-email-code response so subsequent app starts can
* resume the session without re-login.
*/
export async function setDeviceSessionId(
cache: Cache,
deviceSessionId: string,
): Promise<void> {
await cache.put(SESSION_NAMESPACE, SESSION_ID_KEY, deviceSessionId);
}
/**
* clearDeviceSession wipes both the device keypair and the stored
* device-session id. The next [loadDeviceSession] call will generate
* a fresh keypair and report `deviceSessionId: null`, forcing
* re-login. Used by user-driven logout and by gateway-driven
* revocation paths.
*/
export async function clearDeviceSession(
keyStore: KeyStore,
cache: Cache,
): Promise<void> {
await Promise.all([
keyStore.clear(),
cache.delete(SESSION_NAMESPACE, SESSION_ID_KEY),
]);
}
@@ -0,0 +1,55 @@
// `IDBCache` is the IndexedDB-backed [Cache] implementation. The
// underlying object store uses a compound key `[namespace, key]`, so
// `clear(namespace)` is a single bounded cursor walk rather than a
// full-store scan.
import type { IDBPDatabase } from "idb";
import type { Cache } from "./index";
import type { GalaxyDB } from "./idb";
export class IDBCache implements Cache {
constructor(private readonly db: IDBPDatabase<GalaxyDB>) {}
async get<T = unknown>(
namespace: string,
key: string,
): Promise<T | undefined> {
const row = await this.db.get("cache", [namespace, key]);
return row?.value as T | undefined;
}
async put<T = unknown>(
namespace: string,
key: string,
value: T,
): Promise<void> {
await this.db.put("cache", { namespace, key, value });
}
async delete(namespace: string, key: string): Promise<void> {
await this.db.delete("cache", [namespace, key]);
}
async clear(namespace?: string): Promise<void> {
if (namespace === undefined) {
await this.db.clear("cache");
return;
}
// IndexedDB orders compound keys lexicographically. The pair
// [namespace, ""] is the lower bound and [namespace, "￿"]
// is the upper bound for every key whose first component equals
// `namespace` — ￿ is the largest BMP code point and acts
// as a sentinel above any realistic application-level key.
const range = IDBKeyRange.bound(
[namespace, ""],
[namespace, "￿"],
);
const tx = this.db.transaction("cache", "readwrite");
let cursor = await tx.store.openCursor(range);
while (cursor) {
cursor.delete();
cursor = await cursor.continue();
}
await tx.done;
}
}
+90
View File
@@ -0,0 +1,90 @@
// Shared IndexedDB connection used by both the WebCrypto keystore
// and the IDB cache. Opens one versioned database (`galaxy-ui`)
// containing two object stores:
//
// keypair: out-of-line keys; we always read/write the fixed slot
// `device`. The row carries the `CryptoKey` handles
// (private + public) and a cached raw 32-byte public-key
// export so consumers do not call exportKey on every load.
// cache: keyPath = ['namespace', 'key']; rows are
// { namespace, key, value } where `value` is whatever the
// caller stored. Compound keys give the cache cheap
// namespace-prefix scans without a secondary index.
//
// The database connection is cached per page; tests create their own
// per-test connection by calling [openGalaxyDB] directly with a
// distinct name so cases do not bleed state.
import { openDB, type DBSchema, type IDBPDatabase } from "idb";
export const DB_NAME = "galaxy-ui";
export const DB_VERSION = 1;
/** Fixed key for the single device keypair slot in the `keypair` store. */
export const DEVICE_KEYPAIR_KEY = "device";
export interface KeypairRow {
privateKey: CryptoKey;
publicKey: CryptoKey;
publicKeyRaw: ArrayBuffer;
}
export interface CacheRow {
namespace: string;
key: string;
value: unknown;
}
export interface GalaxyDB extends DBSchema {
keypair: {
key: string;
value: KeypairRow;
};
cache: {
key: [string, string];
value: CacheRow;
};
}
let cached: Promise<IDBPDatabase<GalaxyDB>> | undefined;
/**
* dbConnection returns the cached `galaxy-ui` database connection,
* opening it on first call. Production callers always go through
* this; tests use [openGalaxyDB] with a per-test name instead.
*/
export function dbConnection(): Promise<IDBPDatabase<GalaxyDB>> {
if (!cached) {
cached = openGalaxyDB(DB_NAME);
}
return cached;
}
/**
* resetDbConnection clears the cached connection. Tests call this
* between cases that share `DB_NAME`; production code never does.
*/
export function resetDbConnection(): void {
cached = undefined;
}
/**
* openGalaxyDB opens a Galaxy UI database under the given name. The
* upgrade callback installs the two stores defined by [GalaxyDB] on
* the version-1 schema; later schema changes bump [DB_VERSION] and
* extend the callback in place.
*/
export function openGalaxyDB(name: string): Promise<IDBPDatabase<GalaxyDB>> {
return openDB<GalaxyDB>(name, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains("keypair")) {
db.createObjectStore("keypair");
}
if (!db.objectStoreNames.contains("cache")) {
db.createObjectStore("cache", {
keyPath: ["namespace", "key"],
});
}
},
});
}
+69
View File
@@ -0,0 +1,69 @@
// `KeyStore` and `Cache` are the platform-agnostic persistence
// boundaries every Galaxy UI build target (web, Wails desktop,
// Capacitor mobile) must satisfy. The web implementation in this
// directory backs them with WebCrypto non-exportable Ed25519 keys
// and IndexedDB; later phases plug Wails Keychain / Capacitor
// Preferences and SQLite behind the same interfaces without touching
// the orchestration layer.
//
// Public signatures intentionally use only `Uint8Array`, `string`,
// and `unknown` — no `CryptoKey`, no `IDBDatabase`, no `idb` symbols
// — so callers stay portable across platforms. The `Signer` type from
// `api/galaxy-client.ts` is structurally compatible with
// `DeviceKeypair.sign`: the constructor takes
// `signer: keypair.sign.bind(keypair)` (or an arrow wrapper).
/**
* DeviceKeypair is the load-or-generate output of a [KeyStore]: the
* raw 32-byte Ed25519 public key in `publicKey`, and a `sign` method
* that produces the raw 64-byte Ed25519 signature for the given
* canonical bytes. The private key never leaves the platform's secure
* storage.
*/
export interface DeviceKeypair {
readonly publicKey: Uint8Array;
sign(canonicalBytes: Uint8Array): Promise<Uint8Array>;
}
/**
* KeyStore persists exactly one device keypair at a time. `load`
* returns `null` when no keypair has been generated yet; `generate`
* always overwrites any prior keypair with a fresh one; `clear`
* deletes the stored keypair so the next `load` returns `null`.
*/
export interface KeyStore {
load(): Promise<DeviceKeypair | null>;
generate(): Promise<DeviceKeypair>;
clear(): Promise<void>;
}
/**
* Cache is a generic local key-value store partitioned by namespace.
* Values flow through the platform's structured-clone equivalent, so
* `Uint8Array`, plain objects, nested arrays, and primitives all
* round-trip. `clear()` without a namespace wipes every entry; with
* a namespace it wipes only that namespace's keys.
*/
export interface Cache {
get<T = unknown>(namespace: string, key: string): Promise<T | undefined>;
put<T = unknown>(namespace: string, key: string, value: T): Promise<void>;
delete(namespace: string, key: string): Promise<void>;
clear(namespace?: string): Promise<void>;
}
export interface Store {
keyStore: KeyStore;
cache: Cache;
}
export type StoreLoader = () => Promise<Store>;
import { loadWebStore } from "./web";
/**
* loadStore resolves the [Store] implementation appropriate for the
* current build target. Phase 6 ships only the web adapter; later
* phases plug `WailsStore` and `CapacitorStore` here behind a
* build-time selector.
*/
export const loadStore: StoreLoader = loadWebStore;
+18
View File
@@ -0,0 +1,18 @@
// `loadWebStore` is the [StoreLoader] used by the browser build
// target. It opens the shared `galaxy-ui` IndexedDB connection and
// hands the same handle to both the keystore and the cache, so the
// page boots one DB connection regardless of which subsystem touches
// storage first.
import type { Store, StoreLoader } from "./index";
import { dbConnection } from "./idb";
import { IDBCache } from "./idb-cache";
import { WebCryptoKeyStore } from "./webcrypto-keystore";
export const loadWebStore: StoreLoader = async (): Promise<Store> => {
const db = await dbConnection();
return {
keyStore: new WebCryptoKeyStore(db),
cache: new IDBCache(db),
};
};
@@ -0,0 +1,76 @@
// `WebCryptoKeyStore` persists exactly one Ed25519 device keypair in
// IndexedDB using the WebCrypto subtle API. The private key is
// generated with `extractable: false`, which means even the page that
// owns the handle cannot read out the raw bytes — only the platform's
// signing primitive can use it. The matching public key is forced
// extractable by the WebCrypto spec for asymmetric pairs (you cannot
// publish a key you cannot read), and we cache its raw 32-byte
// export alongside the handle so consumers do not pay an exportKey
// round-trip on every page load.
//
// Both `CryptoKey` handles round-trip through IndexedDB's
// structured-clone path; the non-exportable property is preserved
// across page reloads. The browser baseline is documented in
// `ui/docs/storage.md` (Chrome ≥137, Firefox ≥130, Safari ≥17.4).
import type { IDBPDatabase } from "idb";
import type { DeviceKeypair, KeyStore } from "./index";
import {
DEVICE_KEYPAIR_KEY,
type GalaxyDB,
type KeypairRow,
} from "./idb";
const ED25519_ALG = { name: "Ed25519" } as const;
export class WebCryptoKeyStore implements KeyStore {
constructor(private readonly db: IDBPDatabase<GalaxyDB>) {}
async load(): Promise<DeviceKeypair | null> {
const row = await this.db.get("keypair", DEVICE_KEYPAIR_KEY);
if (!row) {
return null;
}
return makeDeviceKeypair(row);
}
async generate(): Promise<DeviceKeypair> {
const pair = (await crypto.subtle.generateKey(
ED25519_ALG,
false,
["sign", "verify"],
)) as CryptoKeyPair;
const publicKeyRaw = await crypto.subtle.exportKey("raw", pair.publicKey);
const row: KeypairRow = {
privateKey: pair.privateKey,
publicKey: pair.publicKey,
publicKeyRaw,
};
await this.db.put("keypair", row, DEVICE_KEYPAIR_KEY);
return makeDeviceKeypair(row);
}
async clear(): Promise<void> {
await this.db.delete("keypair", DEVICE_KEYPAIR_KEY);
}
}
function makeDeviceKeypair(row: KeypairRow): DeviceKeypair {
const publicKey = new Uint8Array(row.publicKeyRaw);
return {
publicKey,
async sign(canonicalBytes: Uint8Array): Promise<Uint8Array> {
// TS 5.7+ tightened BufferSource to require an ArrayBuffer-
// backed view; an arbitrary Uint8Array could in principle be
// SharedArrayBuffer-backed. The signature input is not, so
// the cast is safe and keeps the public Signer type
// (Uint8Array) un-narrowed.
const signature = await crypto.subtle.sign(
ED25519_ALG,
row.privateKey,
canonicalBytes as BufferSource,
);
return new Uint8Array(signature);
},
};
}
@@ -0,0 +1,119 @@
<script lang="ts">
import { onMount } from "svelte";
import {
clearDeviceSession,
loadDeviceSession,
setDeviceSessionId,
} from "../../../api/session";
import { loadStore } from "../../../platform/store/index";
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>;
}
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
let ready = $state(false);
function describe(err: unknown): string {
if (err instanceof Error) {
return `${err.name}: ${err.message}`;
}
return String(err);
}
onMount(async () => {
if (!import.meta.env.DEV) {
return;
}
const { keyStore, cache } = await loadStore();
const surface: DebugSurface = {
ready: true,
async loadSession() {
try {
const session = await loadDeviceSession(keyStore, cache);
return {
publicKey: Array.from(session.keypair.publicKey),
deviceSessionId: session.deviceSessionId,
};
} catch (err) {
throw new Error(`loadSession: ${describe(err)}`);
}
},
async clearSession() {
try {
await clearDeviceSession(keyStore, cache);
} catch (err) {
throw new Error(`clearSession: ${describe(err)}`);
}
},
async signWithDevice(message: number[]) {
try {
const session = await loadDeviceSession(keyStore, cache);
const sig = await session.keypair.sign(new Uint8Array(message));
return Array.from(sig);
} catch (err) {
throw new Error(`signWithDevice: ${describe(err)}`);
}
},
async setDeviceSessionId(id: string) {
try {
await setDeviceSessionId(cache, id);
} catch (err) {
throw new Error(`setDeviceSessionId: ${describe(err)}`);
}
},
async verifyWithStoredPublicKey(message, signature) {
try {
const session = await loadDeviceSession(keyStore, cache);
const importedPublic = await crypto.subtle.importKey(
"raw",
session.keypair.publicKey as BufferSource,
{ name: "Ed25519" },
false,
["verify"],
);
return await crypto.subtle.verify(
{ name: "Ed25519" },
importedPublic,
new Uint8Array(signature) as BufferSource,
new Uint8Array(message) as BufferSource,
);
} catch (err) {
throw new Error(`verifyWithStoredPublicKey: ${describe(err)}`);
}
},
};
(window as DebugWindow).__galaxyDebug = surface;
ready = true;
});
</script>
<main>
<h1>store debug</h1>
{#if ready}
<p data-testid="debug-store-ready">debug store ready</p>
{:else}
<p>booting…</p>
{/if}
</main>
<style>
main {
padding: 2rem;
font-family: system-ui, sans-serif;
}
</style>
@@ -0,0 +1,12 @@
// Debug-only route used by Playwright e2e tests in Phase 6 to drive
// the [KeyStore]/[Cache] surface from the browser. SSR is disabled so
// the keystore code only runs in the browser, and prerender is
// disabled so the static-adapter build never freezes a debug page
// into the production bundle.
//
// The route itself is gated at runtime by `import.meta.env.DEV`
// inside `+page.svelte` — a production build still emits an empty
// shell here, but the debug entry point never attaches.
export const prerender = false;
export const ssr = false;
@@ -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");
});
+80
View File
@@ -0,0 +1,80 @@
// IDBCache unit tests under JSDOM with `fake-indexeddb` standing in
// for the browser's IndexedDB factory. Each case opens a freshly
// named database so state cannot leak across tests.
import "fake-indexeddb/auto";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { IDBPDatabase } from "idb";
import { IDBCache } from "../src/platform/store/idb-cache";
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("IDBCache", () => {
test("round-trips a typed object", async () => {
const cache = new IDBCache(db);
const value = {
name: "ping",
payload: new Uint8Array([1, 2, 3]),
nested: { count: 7 },
};
await cache.put("commands", "k1", value);
const out = await cache.get<typeof value>("commands", "k1");
expect(out?.name).toBe("ping");
expect(out?.nested.count).toBe(7);
expect(Array.from(out?.payload ?? [])).toEqual([1, 2, 3]);
});
test("namespaces are isolated", async () => {
const cache = new IDBCache(db);
await cache.put("a", "shared-key", "from-a");
await cache.put("b", "shared-key", "from-b");
expect(await cache.get("a", "shared-key")).toBe("from-a");
expect(await cache.get("b", "shared-key")).toBe("from-b");
});
test("delete removes a single row without touching neighbours", async () => {
const cache = new IDBCache(db);
await cache.put("ns", "k1", "v1");
await cache.put("ns", "k2", "v2");
await cache.delete("ns", "k1");
expect(await cache.get("ns", "k1")).toBeUndefined();
expect(await cache.get("ns", "k2")).toBe("v2");
});
test("clear(namespace) wipes only that namespace", async () => {
const cache = new IDBCache(db);
await cache.put("a", "k1", "a1");
await cache.put("a", "k2", "a2");
await cache.put("b", "k1", "b1");
await cache.clear("a");
expect(await cache.get("a", "k1")).toBeUndefined();
expect(await cache.get("a", "k2")).toBeUndefined();
expect(await cache.get("b", "k1")).toBe("b1");
});
test("clear() wipes every namespace", async () => {
const cache = new IDBCache(db);
await cache.put("a", "k1", "a1");
await cache.put("b", "k1", "b1");
await cache.clear();
expect(await cache.get("a", "k1")).toBeUndefined();
expect(await cache.get("b", "k1")).toBeUndefined();
});
test("get on a missing key returns undefined", async () => {
const cache = new IDBCache(db);
expect(await cache.get("absent", "k")).toBeUndefined();
});
});
@@ -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),
);
});
});