# kv-wasm: the Kestrove core for the web vault The web vault runs the same Rust core as the apps, compiled to WebAssembly: same 2SKD (Argon2id + Secret Key), same envelopes, same item model, same OPAQUE login, RFC 9421 request signing and sync engine (three-way merge, signed manifests, replay checks). The server stays zero-knowledge: only ciphertext leaves the browser. | Concern | Apps (kv-ffi) | Web (kv-wasm) | |---|---|---| | Local replica | SQLite (`SqliteStore`) | `WebStore`: in-memory, each changed entry written to IndexedDB before the call resolves | | Transport | reqwest + rustls, TLS 1.3, SPKI pins | reqwest over `fetch`; the browser's TLS; same origin; pins accepted but not enforced | | Device key | Secure Enclave / StrongBox / TPM | non-extractable WebCrypto ECDSA P-256 key, kept in IndexedDB | | Randomness, clock | OS | `crypto.getRandomValues`, `Date.now()` | | Biometric unlock (LUK) | yes | **no**: every unlock needs master password + Secret Key | | URL matching | eTLD+1 via the Public Suffix List | same host only (the list would be a third of the download) | | Passkeys | create, assert, list, delete | list, delete | ## Build and test ```sh crates/kv-wasm/build.sh # pkg/: wasm-pack (size profile, wasm-opt -Oz) + worker files wasm-pack test --headless --chrome crates/kv-wasm # in Chrome: IndexedDB, WebCrypto, native fixture node crates/kv-wasm/js/smoke.mjs # the built package in Node (in-memory), Argon2 timing cargo test -p kv-wasm # native: store, fixtures vs kv-ffi, sync with kv-ffi via test server ``` `wasm-pack build crates/kv-wasm --target web` alone also works (opt-level 3, a bit bigger). `smoke.mjs` runs a two-device sync round trip against a real server when `KESTROVE_SERVER_URL` and `KESTROVE_SETUP_TOKEN` are set (see the file header). **Size** (`build.sh`): `kv_wasm_bg.wasm` 1.27 MB, **572 KB gzip**, 467 KB brotli. JS glue ~60 KB. Serve it with compression and `Content-Type: application/wasm`. **Argon2id** (m = 64 MiB, t = 3, p = 4, the account default): about **100–120 ms** per derivation in Chrome and Node on an Apple Silicon Mac (native is in the same range). Expect several times that on low-end phones. It runs on the calling thread, hence the worker below. **Envelope compatibility** is checked both ways with shared fixtures in `tests/fixtures/`: `native-vault.json` is made by kv-ffi and opened in Chrome; `wasm-vault.json` is made by the wasm package and opened by kv-ffi (`tests/fixture.rs`). ## JS API The generated `pkg/kv_wasm.d.ts` is the reference. It mirrors kv-ffi's `VaultCore` in camelCase; records are plain objects with the same fields as kv-ffi's records (`ItemDto`, `ItemSummary`, `SyncReport`, `DeviceDto`, `PasskeySummary`, `ImportPreview`, …); enums are camelCase strings (`"login" | "secureNote" | "card" | "identity"`, `"ok" | "pendingApproval"`); bytes are `Uint8Array`; absent optionals come back as `null`. **Every `VaultCore` method returns a `Promise`.** ```ts class VaultCore { static open(dbName?: string | null): Promise; // IndexedDB, default "kestrove" static newInMemory(): Promise; // persists nothing createAccount(masterPassword): Promise; // -> Secret Key. Argon2id unlock(masterPassword, secretKey): Promise; // Argon2id lock(): Promise; isLocked(); hasAccount(); accountId(); accountFingerprint(); listItems(); getItem(id); saveItem(item: ItemDto): Promise; deleteItem(id); itemHistory(id); itemsForUrl(pageUrl); listPasskeys(); deletePasskey(credentialId: Uint8Array); importPreview(data: Uint8Array, format: ImportFormat); importCommit(data, format, options); exportEncrypted(passphrase): Promise; // Argon2id configureServer(url, spkiPins?); setDevice(name, platform?); deviceId(); deviceKeyFingerprint(); serverInfo(); registerAccount(setupToken); login(masterPassword, secretKey, totp?): Promise; syncNow(): Promise; listDevices(); approveDevice(deviceId); revokeDevice(deviceId); logout(); isLoggedIn(); wipe(); } generatePassword(length, symbols): string; totpCode(secretOrUri, unixTime): TotpCode; matchUrl(itemUrl, pageUrl): MatchLevel; coreVersion(): string; ``` Errors reject with an `Error` whose `name` is `"CoreError"` and whose `code` is one of kv-ffi's `CoreError` cases (`Locked`, `NoAccount`, `BadCredentials`, `Invalid`, `NotFound`, `Internal`, `NotConfigured`, `NotAuthenticated`, `PendingApproval`, `Network`, `Server` + `status`, `IntegrityAlarm` + `vaultId`/`reason`), plus `Busy` (a sync is already running) and, from the worker client, `InUse` (another tab has the vault open). ### Use it from a Web Worker `createAccount`, `unlock`, `login`, `exportEncrypted` and importing a `.kvex` run Argon2id and block their thread. The package ships a worker and a typed client: ```ts import { connectVault } from "@kestrove/kv-wasm/client.js"; const vault = await connectVault(); // RemoteVault: every VaultCore method + free functions, async await vault.configureServer(location.origin); if (await vault.hasAccount()) await vault.unlock(password, secretKey); const items = await vault.listItems(); await vault.close(); // frees the core (zeroizing keys) and ends the worker ``` The worker holds a Web Lock per database for its lifetime, so a second tab gets `InUse` instead of two replicas overwriting each other. ## What the web UI must handle 1. **Run the core in the worker** (`connectVault`), never on the main thread. 2. **Same origin.** Serve the web vault from the server's origin and call `configureServer(location.origin)`. CSP needs `script-src 'self' 'wasm-unsafe-eval'` and `worker-src 'self'`; `connect-src 'self'`. 3. **Enrollment.** The browser is a device. A first `login` returns `"pendingApproval"`: show `deviceKeyFingerprint()` and ask the user to approve it on an enrolled device (which shows the same fingerprint in `listDevices`), then call `login` again. `setDevice("Firefox on Mac")` names it; the platform defaults to the OS from the user agent because the server has no `web` platform yet. 4. **Every unlock needs the master password and Secret Key.** There is no Local Unlock Key on the web: nothing secret is ever written to IndexedDB, so a reload, `lock()` or closing the tab leaves the vault locked. Lock on idle and on `visibilitychange`/`pagehide` as the UI sees fit; `close()` drops the worker and every key in it. 5. **Secrets in JS.** Master password, Secret Key and decrypted `ItemDto`s are JS strings the core cannot wipe. Do not keep them in long-lived state, logs or URLs. The core zeroizes its own copies. 6. **Sync.** Call `syncNow()` after login, on an interval and on focus. Handle `Busy` (skip), `NotAuthenticated` (ask for the password and `login` again), `PendingApproval`, `IntegrityAlarm` (stop and alert: the server rolled back or tampered with a vault) and `restoredItemIds` (tell the user an edit brought a deleted item back). 7. **Storage.** Call `navigator.storage.persist()`. If the browser evicts IndexedDB, unsynced edits and the device key are lost and the browser must be approved again. 8. **Sign out of this browser:** `logout()` (ends the server session), then `wipe()` (removes the replica and device key; the next login is a new device). 9. **Import/export:** pass `new Uint8Array(await file.arrayBuffer())`; `exportEncrypted` resolves with the `.kvex` bytes for a `Blob` download. 10. `itemsForUrl` / `matchUrl` match the same host only (no Public Suffix List in this build).