kv-wasm: onboarding (setup codes, enrollment tickets, scan-to-join) for the web vault
Mirrors kv-ffi's docs/10 §5 API in WebCore and binds it under the names the web app codes against: setupPayloadForKit, addDevicePayload, revokeEnrollmentTicket, loginWithSetup, joinStart/joinPoll/joinCancel, joinApprove, joinSend, and the free function parseSetupPayload (also callable through the worker client). Unlike kv-ffi, the Secret Key is not persisted: every unlock on the web takes it, so it is kept zeroized in the unlocked state and dropped on lock. loginWithSetup refuses a code for a different server than the configured origin (the browser only talks to its own origin). Tests: ticket enrollment and scan-to-join in both roles against kv-ffi via the kv-sync test server, a swapped-approver rejection, a Chrome test (IndexedDB never holds the Secret Key, JS shapes, error codes) and a Node smoke check of the built package. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -14,6 +14,7 @@ ciphertext leaves the browser.
|
||||
| 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
|
||||
|
||||
@@ -21,14 +22,14 @@ ciphertext leaves the browser.
|
||||
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
|
||||
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.27 MB, **572 KB gzip**, 467 KB brotli. JS glue ~60 KB.
|
||||
**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
|
||||
@@ -62,9 +63,19 @@ class VaultCore {
|
||||
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
|
||||
@@ -102,7 +113,8 @@ two replicas overwriting each other.
|
||||
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;
|
||||
leaves the vault locked. (The Secret Key that setup codes carry is kept in wasm memory while
|
||||
unlocked and dropped on `lock()`, unlike kv-ffi, which keeps a sealed copy on disk.) 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
|
||||
@@ -118,3 +130,20 @@ two replicas overwriting each other.
|
||||
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).
|
||||
11. **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.
|
||||
|
||||
Reference in New Issue
Block a user