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

160 lines
11 KiB
Markdown
Raw Permalink 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 |
| 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.