One encrypted settings item per account (a secure note carrying app_settings, at a fixed per-account id) holds the theme and light/dark appearance, so it syncs like any item and the server can't read it. Lists hide it; older apps see a note that explains itself. app_theme / set_app_theme in kv-ffi and kv-wasm. Also fixes a flaky test that checked random ciphertext for the letters K1. Co-Authored-By: Claude Opus 5.5 <[email protected]>
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, DeviceRevoked, 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). - Removed browser (kestrove-spec docs/03 "Removed devices").
DeviceRevokedfrom any call means the server removed this browser's device id for good. The vault has already replaced the WebCrypto key and device id in IndexedDB (deviceId()changes), dropped the session and kept the local data.loginWithSetupwith a ticket retries once by itself as the new device and normally resolves"ok". Otherwise show "This device was removed from your account. Set it up again." and the sign-in: the nextloginenrols the new identity ("pendingApproval").resetDeviceIdentity()does the same reset on demand. When the user removes a device, warn "To add it back later, set it up again as a new device." - 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: