Files
kestrove-core/crates/kv-wasm/js/worker.js
T
samiandClaude Opus 5.5 2e2c8cd26f kv-wasm: onboarding (setup codes, enrollment tickets, scan-to-join) for the web vault
Mirrors kv-ffi's docs/10 §5 API in WebCore and binds it under the names the web app codes
against: setupPayloadForKit, addDevicePayload, revokeEnrollmentTicket, loginWithSetup,
joinStart/joinPoll/joinCancel, joinApprove, joinSend, and the free function parseSetupPayload
(also callable through the worker client).

Unlike kv-ffi, the Secret Key is not persisted: every unlock on the web takes it, so it is
kept zeroized in the unlocked state and dropped on lock. loginWithSetup refuses a code for a
different server than the configured origin (the browser only talks to its own origin).

Tests: ticket enrollment and scan-to-join in both roles against kv-ffi via the kv-sync test
server, a swapped-approver rejection, a Chrome test (IndexedDB never holds the Secret Key,
JS shapes, error codes) and a Node smoke check of the built package.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 06:58:13 +09:00

85 lines
2.8 KiB
JavaScript

// 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", "parseSetupPayload"]);
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) });
}
};