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 |
| Secret Key for setup codes | HPKE-sealed in the account record | in memory while unlocked, never persisted |
Build and test
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 + onboarding 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.34 MB, 599 KB gzip, 488 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. ItemSummary also carries tags and modifiedAt (web only). Every VaultCore method returns a Promise.
class VaultCore {
static open(dbName?: string | null): Promise<VaultCore>; // IndexedDB, default "kestrove"
static newInMemory(): Promise<VaultCore>; // persists nothing
createAccount(masterPassword): Promise<string>; // -> Secret Key. Argon2id
unlock(masterPassword, secretKey): Promise<void>; // Argon2id
lock(): Promise<void>; isLocked(); hasAccount(); accountId(); accountFingerprint();
listItems(); getItem(id); saveItem(item: ItemDto): Promise<string>; deleteItem(id);
itemHistory(id); itemsForUrl(pageUrl); listPasskeys(); deletePasskey(credentialId: Uint8Array);
importPreview(data: Uint8Array, format: ImportFormat); importCommit(data, format, options);
exportEncrypted(passphrase): Promise<Uint8Array>; // Argon2id
configureServer(url, spkiPins?); setDevice(name, platform?); deviceId(); deviceKeyFingerprint();
serverInfo(); registerAccount(setupToken); login(masterPassword, secretKey, totp?): Promise<LoginResult>;
syncNow(): Promise<SyncReport>; listDevices(); approveDevice(deviceId); revokeDevice(deviceId);
logout(); isLoggedIn(); wipe();
// onboarding (kestrove-spec docs/10 §5)
setupPayloadForKit(): Promise<string>; // Emergency Kit Setup Code
addDevicePayload(): Promise<AddDevicePayload>; // {payload, ticketId, expiresAt}
revokeEnrollmentTicket(ticketId);
loginWithSetup(setup: SetupInfo, masterPassword): Promise<LoginResult>; // Argon2id
joinStart(serverUrl): Promise<JoinSession>; // {qr, expiresAt}
joinPoll(): Promise<JoinProgress>; // {code, setup}
joinCancel(); joinApprove(qr): Promise<JoinConfirm>; // {code, deviceName, platform, keyFingerprint}
joinSend();
}
generatePassword(length, symbols): string; totpCode(secretOrUri, unixTime): TotpCode;
matchUrl(itemUrl, pageUrl): MatchLevel; coreVersion(): string;
parseSetupPayload(s): SetupInfo; // {serverUrl, spkiPins, accountId, secretKey, ticket}
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:
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
- Run the core in the worker (
connectVault), never on the main thread. - Same origin. Serve the web vault from the server's origin and call
configureServer(location.origin). CSP needsscript-src 'self' 'wasm-unsafe-eval'andworker-src 'self';connect-src 'self'. - Enrollment. The browser is a device. A first
loginreturns"pendingApproval": showdeviceKeyFingerprint()and ask the user to approve it on an enrolled device (which shows the same fingerprint inlistDevices), then callloginagain.setDevice("Firefox on Mac")names it; the platform defaults to the OS from the user agent because the server has nowebplatform yet. - 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. The Secret Key that setup codes carry is kept in wasm memory only, set after an unlock/login proved it and dropped onlock(),logout()and any failed unlock or login; no getter exposes it (unlike kv-ffi, which keeps a sealed copy on disk). See kestrove-spec docs/10 §5 "Web vault". Lock on idle and onvisibilitychange/pagehideas the UI sees fit;close()drops the worker and every key in it. - Secrets in JS. Master password, Secret Key and decrypted
ItemDtos are JS strings the core cannot wipe. Do not keep them in long-lived state, logs or URLs. The core zeroizes its own copies. - Sync. Call
syncNow()after login, on an interval and on focus. HandleBusy(skip),NotAuthenticated(ask for the password andloginagain),PendingApproval,IntegrityAlarm(stop and alert: the server rolled back or tampered with a vault) andrestoredItemIds(tell the user an edit brought a deleted item back). - 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. - Sign out of this browser:
logout()(ends the server session), thenwipe()(removes the replica and device key; the next login is a new device). - Import/export: pass
new Uint8Array(await file.arrayBuffer());exportEncryptedresolves with the.kvexbytes for aBlobdownload. itemsForUrl/matchUrlmatch the same host only (no Public Suffix List in this build).- Onboarding (docs/10). Same flows and names as kv-ffi;
expiresAtis Unix seconds and codes look like"123 456", as in kv-ffi.- Sign in with a setup code:
parseSetupPayload(text)(scanned or pasted; it is a secret, so never put it in a URL or registerkestrove://as a handler), thenloginWithSetup(setup, password)andsyncNow(). A code for a different server than this web vault's origin is refused withInvalid(the browser only talks to its own origin); tell the user to open the web vault atsetup.serverUrl. - Emergency Kit:
setupPayloadForKit()needs an unlocked vault. - Add a device: show
addDevicePayload().payloadas a QR untilexpiresAt; on Cancel callrevokeEnrollmentTicket(ticketId). - Join from this browser (no account yet):
joinStart(location.origin), showqr, calljoinPoll()every 2 s; showcodewhen it appears, and whensetuparrives ask for the master password andloginWithSetup.NotFound= expired: start again.joinCancel()on leave. - Approve another device's join (signed in, unlocked):
joinApprove(qrText)→ showcode,deviceName,keyFingerprint; after the user confirms the codes match,joinSend(). The join state lives in the worker: a reload orclose()cancels it.
- Sign in with a setup code: