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,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;
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user