Files
samiandClaude Opus 5.5 83d56d43e2 Removed devices: DeviceRevoked, identity reset and one retry with the ticket
kv-sync: SyncError::DeviceRevoked for 403 device_revoked (drops the session);
the test server answers it after OPAQUE (before the ticket) and for valid
signatures by a revoked device's key.

kv-ffi: CoreError::DeviceRevoked; the foreign DeviceSigner gains reset() (new key
and id). On device_revoked the core resets the identity, forgets the session
and the kept OPAQUE input (no silent re-enrolment) and keeps the vault data; a
login carrying a ticket retries once as the new device. reset_device_identity()
runs the reset on demand. Signed calls (sync, devices, tickets, join) reset too.

kv-wasm: DeviceRevoked; the browser binding replaces the WebCrypto key and id in
IndexedDB (same name and platform), loginWithSetup retries once with a ticket;
resetDeviceIdentity().

Tests: end to end revoked -> reset -> re-enrol with the same ticket (kv-ffi and
web core), no-ticket path, in-browser IndexedDB reset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 22:25:03 +09:00

11 KiB
Raw Permalink Blame History

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

  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. The Secret Key that setup codes carry is kept in wasm memory only, set after an unlock/login proved it and dropped on lock(), 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 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 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.
  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. Removed browser (kestrove-spec docs/03 "Removed devices"). DeviceRevoked from 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. loginWithSetup with 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 next login enrols 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."
  8. 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.
  9. 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).
  10. Import/export: pass new Uint8Array(await file.arrayBuffer()); exportEncrypted resolves with the .kvex bytes for a Blob download.
  11. itemsForUrl / matchUrl match the same host only (no Public Suffix List in this build).
  12. Onboarding (docs/10). Same flows and names as kv-ffi; expiresAt is 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 register kestrove:// as a handler), then loginWithSetup(setup, password) and syncNow(). A code for a different server than this web vault's origin is refused with Invalid (the browser only talks to its own origin); tell the user to open the web vault at setup.serverUrl.
    • Emergency Kit: setupPayloadForKit() needs an unlocked vault.
    • Add a device: show addDevicePayload().payload as a QR until expiresAt; on Cancel call revokeEnrollmentTicket(ticketId).
    • Join from this browser (no account yet): joinStart(location.origin), show qr, call joinPoll() every 2 s; show code when it appears, and when setup arrives ask for the master password and loginWithSetup. NotFound = expired: start again. joinCancel() on leave.
    • Approve another device's join (signed in, unlocked): joinApprove(qrText) → show code, deviceName, keyFingerprint; after the user confirms the codes match, joinSend(). The join state lives in the worker: a reload or close() cancels it.