Files
kestrove-core/crates/kv-wasm/README.md
T
samiandClaude Opus 5.5 b5ced32be3 Add kv-wasm: the core compiled to WebAssembly for the web vault
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]>
2026-10-17 23:58:07 +09:00

121 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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:
```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).