Add kv-wasm: the core compiled to WebAssembly for the web vault

New crate crates/kv-wasm runs the same crypto, item model and sync engine in the browser:
- WebStore: in-memory replica of kv-store's Store, each changed entry written to IndexedDB
  (ciphertext only) before the JS call resolves.
- Device key: non-extractable WebCrypto ECDSA P-256 key in IndexedDB (raw r||s signatures).
- WebCore mirrors kv-ffi's VaultCore; wasm-bindgen surface in camelCase with TypeScript types,
  plus a Web Worker wrapper (connectVault) so Argon2id never blocks the page.
- Tests: Chrome (IndexedDB, WebCrypto, RFC 9421 verify), shared envelope fixtures both ways
  with kv-ffi, native sync with kv-ffi via the test server, Node smoke of the built package.

Small changes to shared crates so they build for wasm32:
- kv-store: SqliteStore moved to a native-only module.
- kv-sync: rustls/pinning/tokio native only (fetch on wasm); DeviceSigner::sign_async (default
  wraps sign) used by ApiClient; unix_now() instead of SystemTime/now_utc.
- kv-model: Public Suffix List behind a default `psl` feature (kv-ffi keeps it; the web build
  drops ~0.9 MB of code and matches same-host only).
- wasm-release profile (opt-level z, Argon2 at 3) and a wasm CI job.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 23:58:07 +09:00
co-authored by Claude Opus 5.5
parent f20380d293
commit b5ced32be3
35 changed files with 4488 additions and 363 deletions
+41
View File
@@ -0,0 +1,41 @@
import type {
VaultCore,
CoreError,
CoreErrorCode,
generatePassword,
totpCode,
matchUrl,
coreVersion,
} from "./kv_wasm.js";
export * from "./kv_wasm.js";
type Async<F> = F extends (...args: infer A) => infer R ? (...args: A) => Promise<Awaited<R>> : never;
type CoreMethods = {
[K in keyof VaultCore as K extends "free" | typeof Symbol.dispose ? never : K]: Async<VaultCore[K]>;
};
/** `VaultCore` running in a Web Worker: same methods, all async, plus the free functions. */
export type RemoteVault = CoreMethods & {
generatePassword: Async<typeof generatePassword>;
totpCode: Async<typeof totpCode>;
matchUrl: Async<typeof matchUrl>;
coreVersion: Async<typeof coreVersion>;
/** Frees the core (zeroizing keys), releases the tab lock and terminates the worker. */
close(): Promise<void>;
};
/** `"InUse"`: another tab already has this vault open (one tab per vault). */
export type ClientErrorCode = CoreErrorCode | "InUse";
export interface ConnectOptions {
/** IndexedDB database name. Default "kestrove". */
dbName?: string;
/** Persist nothing (previews, tests). */
inMemory?: boolean;
/** Bring your own worker (e.g. a bundler's `new Worker(new URL(...))`). It must run worker.js. */
worker?: Worker;
}
export function connectVault(options?: ConnectOptions): Promise<RemoteVault>;
+60
View File
@@ -0,0 +1,60 @@
// Page-side handle to the vault worker. Every VaultCore method (and the free functions) becomes an
// async call into the worker; errors come back as `CoreError`s with the same `code`s.
//
// import { connectVault } from "@kestrove/core/client.js";
// const vault = await connectVault(); // IndexedDB "kestrove"
// if (!(await vault.hasAccount())) { ... }
// await vault.unlock(password, secretKey); // Argon2id runs in the worker
// const items = await vault.listItems();
export async function connectVault(options = {}) {
const worker =
options.worker ?? new Worker(new URL("./worker.js", import.meta.url), { type: "module", name: "kestrove-vault" });
let next = 1;
const pending = new Map();
worker.onmessage = ({ data: { id, ok, err } }) => {
const p = pending.get(id);
if (!p) return;
pending.delete(id);
if (err) p.reject(Object.assign(new Error(err.message), err));
else p.resolve(ok);
};
worker.onerror = (event) => {
const e = Object.assign(new Error(event.message || "vault worker failed"), { name: "CoreError", code: "Internal" });
for (const p of pending.values()) p.reject(e);
pending.clear();
};
const call = (method, args) =>
new Promise((resolve, reject) => {
const id = next++;
pending.set(id, { resolve, reject });
worker.postMessage({ id, method, args });
});
try {
await call("open", [options.dbName ?? null, Boolean(options.inMemory)]);
} catch (e) {
worker.terminate(); // e.g. "InUse": the vault is open in another tab
throw e;
}
const handle = {
/** Frees the core, releases the tab lock and stops the worker (key material goes with it). */
async close() {
try {
await call("close", []);
} finally {
worker.terminate();
}
},
};
return new Proxy(handle, {
get(target, prop) {
if (prop in target) return target[prop];
if (typeof prop !== "string" || prop === "then") return undefined;
return (...args) => call(prop, args);
},
});
}
+149
View File
@@ -0,0 +1,149 @@
// Node smoke test of the built package (pkg/, `--target web`): the generated JS glue, camelCase
// records, CoreError codes, Argon2id timing. No IndexedDB in Node, so it uses in-memory vaults
// (the IndexedDB path is covered by `wasm-pack test --headless --chrome`).
//
// node crates/kv-wasm/js/smoke.mjs # checks + timing
// node crates/kv-wasm/js/smoke.mjs --write-fixture # also writes tests/fixtures/wasm-vault.json
//
// With a fresh local kestrove-server it also runs a two-device sync round trip, both devices in wasm
// (see crates/kv-ffi/tests/live_server.rs for starting one; it takes ~2 minutes because of the
// server's auth rate limit):
//
// KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=<token> node crates/kv-wasm/js/smoke.mjs
import assert from "node:assert/strict";
import { readFile, writeFile } from "node:fs/promises";
import init, * as kv from "../pkg/kv_wasm.js";
const pkg = new URL("../pkg/", import.meta.url);
await init({ module_or_path: await readFile(new URL("kv_wasm_bg.wasm", pkg)) });
const PW = "wasm fixture master password";
const ms = (t) => `${Math.round(performance.now() - t)} ms`;
async function rejects(promise, code) {
try {
await promise;
} catch (e) {
assert.equal(e.name, "CoreError");
assert.equal(e.code, code, e.message);
return e;
}
assert.fail(`expected ${code}`);
}
function login(title, password, extra = {}) {
return {
id: "",
kind: "login",
title,
username: "[email protected]",
password,
urls: ["https://example.com/login"],
totp: null,
notes: null,
favorite: false,
tags: [],
modifiedAt: 0,
...extra,
};
}
// ------------------------------------------------------------ local checks, Argon2 timing
const a = await kv.VaultCore.newInMemory();
await rejects(a.unlock(PW, "K1-AAAAAA-AAAAA-AAAAA-AAAAA-AAAAA-A"), "NoAccount");
let t = performance.now();
const secretKey = await a.createAccount(PW);
console.log(`createAccount (Argon2id m=64 MiB t=3 p=4): ${ms(t)}`);
await a.lock();
t = performance.now();
await a.unlock(PW, secretKey);
console.log(`unlock (Argon2id m=64 MiB t=3 p=4): ${ms(t)}`);
const bankId = await a.saveItem(
login("Bank", "first password", {
totp: "otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank",
tags: ["finance", "ünïcode ✓"],
favorite: true,
}),
);
const bank = await a.getItem(bankId);
assert.equal(bank.password, "first password");
assert.equal(bank.notes, null);
await a.saveItem({ ...bank, password: "second password" });
await a.saveItem({ id: "", kind: "secureNote", title: "Recovery codes", notes: "multi\nline 🔐" });
const list = await a.listItems();
assert.deepEqual(
list.map((i) => [i.title, i.kind, i.hasTotp]),
[
["Bank", "login", true],
["Recovery codes", "secureNote", false],
],
);
assert.equal((await a.itemHistory(bankId))[0].password, "first password");
await rejects(a.getItem("not-a-uuid"), "Invalid");
const code = kv.totpCode("JBSWY3DPEHPK3PXP", Math.floor(Date.now() / 1000));
assert.match(code.code, /^\d{6}$/);
assert.equal(kv.matchUrl("example.com", "https://example.com/x"), "exact");
assert.equal(kv.generatePassword(20, false).length, 20);
const exported = await a.exportEncrypted("export passphrase");
assert.ok(exported instanceof Uint8Array && exported.length > 100);
if (process.argv.includes("--write-fixture")) {
const items = [];
for (const s of await a.listItems()) items.push(await a.getItem(s.id));
items.sort((x, y) => (x.title < y.title ? -1 : 1));
const fixture = { masterPassword: PW, secretKey, entries: await a.__replicaEntries(), items };
const path = new URL("../tests/fixtures/wasm-vault.json", import.meta.url);
await writeFile(path, JSON.stringify(fixture, null, 2) + "\n");
console.log(`wrote ${path.pathname}`);
}
// ------------------------------------------------------------ live server round trip
const url = process.env.KESTROVE_SERVER_URL;
const token = process.env.KESTROVE_SETUP_TOKEN;
if (url && token) {
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
await a.setDevice("wasm device A", "linux");
await a.configureServer(url, []);
assert.equal((await a.serverInfo()).setupOpen, true, "server must be fresh");
await a.registerAccount(token);
let r = await a.syncNow();
assert.equal(r.pushed, 2);
console.log("A registered and pushed", r);
console.log("waiting 61 s for the server's auth rate limit…");
await sleep(61);
const b = await kv.VaultCore.newInMemory();
await b.setDevice("wasm device B", "macos");
await b.configureServer(url, []);
let res = await b.login(PW, secretKey, null);
if (res === "pendingApproval") {
const pending = (await a.listDevices()).find((d) => !d.approved);
assert.equal(pending.keyFingerprint, await b.deviceKeyFingerprint());
await a.approveDevice(pending.deviceId);
console.log("A approved B; waiting 61 s again…");
await sleep(61);
res = await b.login(PW, secretKey, null);
}
assert.equal(res, "ok");
r = await b.syncNow();
console.log("B synced", r);
const bItems = await b.listItems();
assert.deepEqual(bItems.map((i) => i.title).sort(), ["Bank", "Recovery codes"]);
const onB = await b.getItem(bankId);
assert.equal(onB.password, "second password");
await b.saveItem({ ...onB, notes: "edited on B" });
assert.equal((await b.syncNow()).pushed, 1);
await a.syncNow();
assert.equal((await a.getItem(bankId)).notes, "edited on B");
await b.deleteItem((await b.listItems()).find((i) => i.title === "Recovery codes").id);
await b.syncNow();
await a.syncNow();
assert.deepEqual((await a.listItems()).map((i) => i.title), ["Bank"]);
console.log("live round trip ok");
}
console.log("smoke ok");
+84
View File
@@ -0,0 +1,84 @@
// Kestrove vault worker: owns the wasm VaultCore so Argon2id (createAccount, unlock, login,
// encrypted export/import) never blocks the page. Started by `connectVault()` in client.js.
//
// Protocol (structured clone): {id, method, args} -> {id, ok: value} | {id, err: {name, message, code, ...}}.
// The first message must be {id, method: "open", args: [dbName | null, inMemory]}.
import init, * as kv from "./kv_wasm.js";
let core = null;
let releaseLock = null;
function serializeError(e) {
if (e && typeof e === "object") {
return {
name: e.name || "Error",
message: e.message || String(e),
code: e.code ?? "Internal",
status: e.status,
vaultId: e.vaultId,
reason: e.reason,
};
}
return { name: "CoreError", message: String(e), code: "Internal" };
}
// One tab per vault: two in-memory replicas over the same IndexedDB database would overwrite
// each other's writes. The lock is held for the worker's lifetime.
async function acquireLock(dbName) {
if (!self.navigator?.locks) return true; // very old browsers: best effort
return new Promise((resolve) => {
self.navigator.locks.request(`kestrove-vault:${dbName}`, { ifAvailable: true }, (lock) => {
if (!lock) {
resolve(false);
return undefined;
}
resolve(true);
return new Promise((release) => (releaseLock = release));
});
});
}
async function open(dbName, inMemory) {
await init({ module_or_path: new URL("./kv_wasm_bg.wasm", import.meta.url) });
if (inMemory) {
core = await kv.VaultCore.newInMemory();
return null;
}
const name = dbName || "kestrove";
if (!(await acquireLock(name))) {
const e = new Error("the vault is open in another tab");
e.name = "CoreError";
e.code = "InUse";
throw e;
}
core = await kv.VaultCore.open(name);
return null;
}
const FREE_FUNCTIONS = new Set(["generatePassword", "totpCode", "matchUrl", "coreVersion"]);
self.onmessage = async ({ data: { id, method, args } }) => {
try {
let value;
if (method === "open") {
value = await open(...args);
} else if (method === "close") {
core?.free();
core = null;
releaseLock?.();
value = null;
} else if (FREE_FUNCTIONS.has(method)) {
value = kv[method](...args);
} else if (!core) {
throw Object.assign(new Error("vault is not open"), { name: "CoreError", code: "NotConfigured" });
} else if (typeof core[method] !== "function" || method.startsWith("_") || method === "free") {
throw Object.assign(new Error(`unknown method ${method}`), { name: "CoreError", code: "Invalid" });
} else {
value = await core[method](...args);
}
self.postMessage({ id, ok: value });
} catch (e) {
self.postMessage({ id, err: serializeError(e) });
}
};