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]>
7.5 KiB
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
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.
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();
}
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:
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. 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).