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]>
160 lines
11 KiB
Markdown
160 lines
11 KiB
Markdown
# 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
|
||
|
||
```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 + 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`.**
|
||
|
||
```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();
|
||
// 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:
|
||
|
||
```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. 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 `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. **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.
|