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