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:
@@ -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