From 2e2c8cd26f0694dc6c157a43b6269d20f288c9fa Mon Sep 17 00:00:00 2001 From: samialabri Date: Sun, 18 Oct 2026 06:58:13 +0900 Subject: [PATCH] kv-wasm: onboarding (setup codes, enrollment tickets, scan-to-join) for the web vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 1 + crates/kv-wasm/Cargo.toml | 1 + crates/kv-wasm/README.md | 35 +++- crates/kv-wasm/js/client.d.ts | 2 + crates/kv-wasm/js/smoke.mjs | 15 ++ crates/kv-wasm/js/worker.js | 2 +- crates/kv-wasm/src/core.rs | 280 +++++++++++++++++++++++++++++-- crates/kv-wasm/src/dto.rs | 78 +++++++++ crates/kv-wasm/src/wasm/mod.rs | 128 ++++++++++++++ crates/kv-wasm/src/wasm/tests.rs | 54 ++++++ crates/kv-wasm/tests/sync.rs | 197 ++++++++++++++++++++++ 11 files changed, 779 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74e20ac..13b0f38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1490,6 +1490,7 @@ dependencies = [ name = "kv-wasm" version = "0.1.0" dependencies = [ + "base64 0.22.1", "getrandom 0.2.17", "js-sys", "kv-crypto", diff --git a/crates/kv-wasm/Cargo.toml b/crates/kv-wasm/Cargo.toml index 9c9ebca..4b2e666 100644 --- a/crates/kv-wasm/Cargo.toml +++ b/crates/kv-wasm/Cargo.toml @@ -22,6 +22,7 @@ thiserror.workspace = true uuid.workspace = true zeroize.workspace = true sha2.workspace = true +base64.workspace = true serde_bytes = "0.11" [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/kv-wasm/README.md b/crates/kv-wasm/README.md index ed6c1d9..f6cc7f8 100644 --- a/crates/kv-wasm/README.md +++ b/crates/kv-wasm/README.md @@ -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; syncNow(): Promise; listDevices(); approveDevice(deviceId); revokeDevice(deviceId); logout(); isLoggedIn(); wipe(); + // onboarding (kestrove-spec docs/10 §5) + setupPayloadForKit(): Promise; // Emergency Kit Setup Code + addDevicePayload(): Promise; // {payload, ticketId, expiresAt} + revokeEnrollmentTicket(ticketId); + loginWithSetup(setup: SetupInfo, masterPassword): Promise; // Argon2id + joinStart(serverUrl): Promise; // {qr, expiresAt} + joinPoll(): Promise; // {code, setup} + joinCancel(); joinApprove(qr): Promise; // {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. diff --git a/crates/kv-wasm/js/client.d.ts b/crates/kv-wasm/js/client.d.ts index e8ee320..df914c8 100644 --- a/crates/kv-wasm/js/client.d.ts +++ b/crates/kv-wasm/js/client.d.ts @@ -6,6 +6,7 @@ import type { totpCode, matchUrl, coreVersion, + parseSetupPayload, } from "./kv_wasm.js"; export * from "./kv_wasm.js"; @@ -22,6 +23,7 @@ export type RemoteVault = CoreMethods & { totpCode: Async; matchUrl: Async; coreVersion: Async; + parseSetupPayload: Async; /** Frees the core (zeroizing keys), releases the tab lock and terminates the worker. */ close(): Promise; }; diff --git a/crates/kv-wasm/js/smoke.mjs b/crates/kv-wasm/js/smoke.mjs index 07c07f2..8391c46 100644 --- a/crates/kv-wasm/js/smoke.mjs +++ b/crates/kv-wasm/js/smoke.mjs @@ -92,6 +92,21 @@ assert.equal(kv.generatePassword(20, false).length, 20); const exported = await a.exportEncrypted("export passphrase"); assert.ok(exported instanceof Uint8Array && exported.length > 100); +// Onboarding (docs/10): the Kit's Setup Code round-trips through the free parser. +const k = await kv.VaultCore.newInMemory(); +const kSecret = await k.createAccount(PW); +await k.configureServer("https://vault.example.org", []); +const setup = kv.parseSetupPayload(await k.setupPayloadForKit()); +assert.deepEqual(setup, { + serverUrl: "https://vault.example.org", + spkiPins: [], + accountId: await k.accountId(), + secretKey: kSecret, + ticket: null, +}); +await rejects(k.joinPoll(), "Invalid"); +k.free(); + if (process.argv.includes("--write-fixture")) { const items = []; for (const s of await a.listItems()) items.push(await a.getItem(s.id)); diff --git a/crates/kv-wasm/js/worker.js b/crates/kv-wasm/js/worker.js index 327b024..ac8a466 100644 --- a/crates/kv-wasm/js/worker.js +++ b/crates/kv-wasm/js/worker.js @@ -56,7 +56,7 @@ async function open(dbName, inMemory) { return null; } -const FREE_FUNCTIONS = new Set(["generatePassword", "totpCode", "matchUrl", "coreVersion"]); +const FREE_FUNCTIONS = new Set(["generatePassword", "totpCode", "matchUrl", "coreVersion", "parseSetupPayload"]); self.onmessage = async ({ data: { id, method, args } }) => { try { diff --git a/crates/kv-wasm/src/core.rs b/crates/kv-wasm/src/core.rs index b60ea1a..544ac04 100644 --- a/crates/kv-wasm/src/core.rs +++ b/crates/kv-wasm/src/core.rs @@ -10,25 +10,34 @@ //! * no passkey authenticator calls (`create_passkey`/`get_assertion`): the web vault only lists and //! deletes passkeys; //! * no SPKI pins: the browser does TLS for the page's own origin; -//! * `sync_now` fails with `Busy` instead of queueing when a sync is already running. +//! * `sync_now` fails with `Busy` instead of queueing when a sync is already running; +//! * the Secret Key (for setup payloads, docs/10) is held only in memory while unlocked, never +//! persisted: every unlock on the web takes it anyway, so kv-ffi's sealed copy is not needed. use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, }; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use kv_crypto::{ aad::{Aad, ObjectType}, aead::SymKey, kdf, keys::{open_item, seal_item, AccountKeyset, SealedItem}, + pairing::{self, PairingKeypair}, + random_bytes, secret_key::SecretKey, }; use kv_model::{generator, matching, totp::Totp, Item, ItemKind, Passkey}; -use kv_proto::{b64, DeviceEnroll, Envelope, ItemRecord, KdfParams, Platform}; +use kv_proto::{b64, DeviceEnroll, Envelope, ItemRecord, JoinState as WireJoinState, KdfParams, Platform}; use kv_store::{HistoryEntry, LocalItem, Store, VaultRow}; -use kv_sync::{ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError}; +use kv_sync::{ + onboarding::{JoinQr, SetupPayload}, + ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError, +}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use uuid::Uuid; use zeroize::Zeroizing; @@ -43,7 +52,8 @@ pub const META_SERVER: &str = "server"; pub const META_SESSION: &str = "session"; /// Everything needed to unlock: all public or wrapped. Same JSON as kv-ffi's `AccountRecord` -/// (minus the native-only `local_unlock`), so a record moves between the two unchanged. +/// (minus the native-only `local_unlock` and the sealed `secret_key`), so a record moves between the +/// two unchanged. #[derive(Clone, Serialize, Deserialize)] pub struct AccountRecord { pub account_id: Uuid, @@ -69,6 +79,8 @@ struct Unlocked { vault_key: Arc, /// OPAQUE password, kept while unlocked so an expired session can be renewed without prompting. auth_input: Option>, + /// The Secret Key, for setup payloads (Emergency Kit, "Add a device", scan-to-join). + secret_key: Zeroizing<[u8; 16]>, } struct Device { @@ -77,6 +89,20 @@ struct Device { platform: Platform, } +/// Local state of an in-flight scan-to-join (same as kv-ffi's). +enum JoinState { + /// This browser is joining (it showed the QR). + Joining { + rendezvous_id: String, + read_token: String, + keypair: PairingKeypair, + qr_secret: Zeroizing<[u8; 32]>, + sender_pub: Option>, + }, + /// This browser scanned a QR and said hello; waiting for the user to confirm the code. + Approving { qr: JoinQr, keypair: PairingKeypair, device: DeviceEnroll }, +} + #[derive(Default)] struct Inner { account: Option, @@ -90,6 +116,8 @@ pub struct WebCore { store: Arc, inner: Mutex, syncing: AtomicBool, + /// In-flight scan-to-join, on either side (docs/10 §4). + join: Mutex>, } struct SyncGuard<'a>(&'a AtomicBool); @@ -116,6 +144,7 @@ impl WebCore { store, inner: Mutex::new(Inner { account, server, ..Default::default() }), syncing: AtomicBool::new(false), + join: Mutex::new(None), }) } @@ -162,6 +191,7 @@ impl WebCore { keyset: Arc::new(keyset), vault_key: Arc::new(vault_key), auth_input: Some(derived.auth_input), + secret_key: Zeroizing::new(*secret_key.expose()), }); Ok(secret_key.to_display().to_string()) } @@ -178,6 +208,7 @@ impl WebCore { keyset: Arc::new(keyset), vault_key: Arc::new(vault_key), auth_input: Some(derived.auth_input), + secret_key: Zeroizing::new(*sk.expose()), }); Ok(()) } @@ -212,6 +243,7 @@ impl WebCore { let mut inner = self.lock_inner(); self.store.wipe()?; *inner = Inner::default(); + self.join_cancel(); Ok(()) } @@ -458,14 +490,26 @@ impl WebCore { device, ) .await?; - self.do_login(&api, acct.account_id, auth.as_ref(), None).await.map(|_| ()) + self.do_login(&api, acct.account_id, auth.as_ref(), None, None).await.map(|_| ()) } /// Logs in with the master password and Secret Key. A browser with no account fetches it from the /// server; call `sync_now` afterwards to download the vault. pub async fn login(&self, master_password: &str, secret_key: &str, totp: Option) -> Result { - let api = self.api()?; let sk = SecretKey::parse(secret_key)?; + self.login_inner(master_password, sk, totp, None, None).await + } + + /// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`. + async fn login_inner( + &self, + master_password: &str, + sk: SecretKey, + totp: Option, + ticket: Option, + expect_account: Option, + ) -> Result { + let api = self.api()?; let local = self.lock_inner().account.clone(); let (account_id, kdf_params) = match &local { Some(a) => (a.account_id, a.kdf.clone()), @@ -474,10 +518,15 @@ impl WebCore { (pre.account_id, pre.kdf) } }; + if expect_account.is_some_and(|want| want != account_id) { + return Err(CoreError::Invalid { msg: "the server holds a different account than the setup code".into() }); + } // A downgraded KDF from the server is refused before any work is done. kdf::validate(&kdf_params)?; let derived = kdf::derive_keys(master_password, &sk, account_id, &kdf_params)?; - if self.do_login(&api, account_id, derived.auth_input.as_ref(), totp).await? == LoginResult::PendingApproval { + if self.do_login(&api, account_id, derived.auth_input.as_ref(), totp, ticket).await? + == LoginResult::PendingApproval + { return Ok(LoginResult::PendingApproval); } let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk); @@ -512,6 +561,7 @@ impl WebCore { keyset: Arc::new(keyset), vault_key: Arc::new(vault_key), auth_input: Some(derived.auth_input), + secret_key: Zeroizing::new(*sk.expose()), }); Ok(LoginResult::Ok) } @@ -530,7 +580,8 @@ impl WebCore { let report = match run().await { Err(SyncError::NotAuthenticated) => { let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?; - if self.do_login(&api, u.keyset.account_id, auth.as_ref(), None).await? == LoginResult::PendingApproval + if self.do_login(&api, u.keyset.account_id, auth.as_ref(), None, None).await? + == LoginResult::PendingApproval { return Err(CoreError::PendingApproval); } @@ -589,8 +640,188 @@ impl WebCore { self.store.get_meta(META_SESSION).ok().flatten().is_some() } + // ------------------------------------------------------------ onboarding (docs/10) + + /// The Setup Code for the Emergency Kit: server, pins, account id and Secret Key, no ticket. + /// Needs an unlocked vault and a configured server. + pub fn setup_payload_for_kit(&self) -> Result { + Ok(self.setup_payload(None)?.encode()?.to_string()) + } + + /// The "Add a device" QR: issues a 10-minute, single-use, unbound enrollment ticket and returns + /// the payload. Show it as a secret view; on Cancel call `revoke_enrollment_ticket`. + pub async fn add_device_payload(&self) -> Result { + self.unlocked_snapshot()?; + let created = self.api()?.create_ticket(None).await?; + let payload = self.setup_payload(Some(created.ticket))?.encode()?.to_string(); + Ok(AddDevicePayload { payload, ticket_id: created.ticket_id.to_string(), expires_at: created.expires_at }) + } + + pub async fn revoke_enrollment_ticket(&self, ticket_id: &str) -> Result<()> { + Ok(self.api()?.revoke_ticket(parse_id(ticket_id)?).await?) + } + + /// New browser: logs in with the master password and the Secret Key from `setup`, presenting its + /// ticket (if any) so no manual approval is needed. Then call `sync_now`. + /// + /// Web only: the browser talks to its own origin (`connect-src 'self'`), so a setup code for + /// another server than the configured one is refused instead of switching servers. + pub async fn login_with_setup(&self, setup: &SetupInfo, master_password: &str) -> Result { + let p = from_info(setup)?; + let (account, server) = { + let inner = self.lock_inner(); + (inner.account.as_ref().map(|a| a.account_id), inner.server.clone()) + }; + if account.is_some_and(|a| a != p.account_id) { + return Err(invalid("this browser holds a different account")); + } + if let Some(server) = server { + if kv_sync::transport::normalize_base_url(&server.url)? != p.server_url { + return Err(invalid(&format!("this setup code is for {}; open the web vault there", p.server_url))); + } + } + self.configure_server(&p.server_url, p.spki_pins.clone())?; + let sk = SecretKey::parse(&p.secret_key)?; + self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id)).await + } + + /// Joining browser: opens a rendezvous on `server_url` and returns the QR to show. Then call + /// `join_poll` every 2 seconds. + pub async fn join_start(&self, server_url: &str) -> Result { + if self.has_account() { + return Err(invalid("this browser already has an account")); + } + // Only ciphertext and public data cross this connection; the setup arrives sealed. + self.configure_server(server_url, vec![])?; + let api = self.api()?; + let created = api.join_create(self.enroll()?).await?; + let keypair = PairingKeypair::generate(); + let qr_secret = Zeroizing::new(random_bytes::<32>()); + let qr = JoinQr { + server_url: api.base_url().to_string(), + rendezvous_id: created.rendezvous_id.clone(), + public_key: keypair.public_bytes(), + qr_secret: qr_secret.clone(), + } + .encode()?; + *self.lock_join() = Some(JoinState::Joining { + rendezvous_id: created.rendezvous_id, + read_token: created.read_token, + keypair, + qr_secret, + sender_pub: None, + }); + Ok(JoinSession { qr, expires_at: created.expires_at }) + } + + /// Joining browser: polls the rendezvous. `NotFound` means it expired (start again). + pub async fn join_poll(&self) -> Result { + let (rid, token) = match &*self.lock_join() { + Some(JoinState::Joining { rendezvous_id, read_token, .. }) => (rendezvous_id.clone(), read_token.clone()), + _ => return Err(invalid("no join in progress")), + }; + let poll = match self.api()?.join_poll(&rid, &token).await { + Err(SyncError::NotFound) => { + self.join_cancel(); + return Err(CoreError::NotFound); + } + other => other?, + }; + let mut guard = self.lock_join(); + let Some(JoinState::Joining { rendezvous_id, keypair, qr_secret, sender_pub, .. }) = guard.as_mut() else { + return Err(invalid("join was cancelled")); + }; + if *rendezvous_id != rid { + // Cancelled and restarted while this poll was in flight. + return Err(invalid("join was restarted")); + } + let rid_bytes = URL_SAFE_NO_PAD.decode(&rid).map_err(internal)?; + if let Some(p) = &poll.sender_pub { + // The code was shown for this key; a different one now means someone swapped it. + if sender_pub.as_ref().is_some_and(|known| known != p) { + *guard = None; + return Err(invalid("the approving device changed; start again")); + } + *sender_pub = Some(p.clone()); + } + let code = sender_pub.as_ref().map(|p| pairing::sas(&rid_bytes, &keypair.public_bytes(), p)); + if poll.state != WireJoinState::Sealed { + return Ok(JoinProgress { code, setup: None }); + } + let (Some(bundle), Some(p)) = (&poll.bundle, sender_pub.as_ref()) else { + *guard = None; + return Err(internal("sealed rendezvous without bundle or sender key")); + }; + let opened = pairing::open(keypair, p, qr_secret.as_ref(), &rid_bytes, bundle); + *guard = None; + let plaintext = + Zeroizing::new(opened.map_err(|_| invalid("the setup bundle could not be opened; start again"))?); + let text = std::str::from_utf8(&plaintext).map_err(|_| invalid("malformed setup bundle"))?; + Ok(JoinProgress { code, setup: Some(to_info(SetupPayload::parse(text)?)) }) + } + + /// Forgets the local join state on either side. + pub fn join_cancel(&self) { + *self.lock_join() = None; + } + + /// Approving browser (signed in, unlocked): answers a scanned or pasted join QR and returns the + /// code to compare. Refuses a QR for a different server. Nothing secret is sent until `join_send`. + pub async fn join_approve(&self, qr: &str) -> Result { + let qr = JoinQr::parse(qr)?; + let api = self.api()?; + if kv_sync::transport::normalize_base_url(&qr.server_url)? != api.base_url() { + return Err(invalid("this computer is connecting to a different server")); + } + self.unlocked_snapshot()?; + let keypair = PairingKeypair::generate(); + let hello = api.join_hello(&qr.rendezvous_id, keypair.public_bytes().to_vec()).await?; + let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?; + let confirm = JoinConfirm { + code: pairing::sas(&rid_bytes, &qr.public_key, &keypair.public_bytes()), + device_name: hello.device.name.clone(), + platform: platform_to_dto(hello.device.platform), + key_fingerprint: device_key_fingerprint(&hello.device.p256_pk), + }; + *self.lock_join() = Some(JoinState::Approving { qr, keypair, device: hello.device }); + Ok(confirm) + } + + /// Approving browser, after the user confirmed the code: issues a ticket bound to the joining + /// device's key and sends the sealed setup bundle. + pub async fn join_send(&self) -> Result<()> { + let Some(JoinState::Approving { qr, keypair, device }) = self.lock_join().take() else { + return Err(invalid("no join to approve")); + }; + let api = self.api()?; + // Fail before issuing a ticket if the vault was locked meanwhile. + self.setup_payload(None)?; + let ticket = api.create_ticket(Some(Sha256::digest(&device.p256_pk).to_vec())).await?; + let payload = self.setup_payload(Some(ticket.ticket))?.encode()?; + let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?; + let bundle = pairing::seal(&keypair, &qr.public_key, qr.qr_secret.as_ref(), &rid_bytes, payload.as_bytes())?; + Ok(api.join_send(&qr.rendezvous_id, bundle).await?) + } + // ------------------------------------------------------------ internals + fn lock_join(&self) -> std::sync::MutexGuard<'_, Option> { + self.join.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// This browser's account as a setup payload (unlocked, server configured). + fn setup_payload(&self, ticket: Option) -> Result { + let (acct, u) = self.unlocked_snapshot()?; + let server = self.lock_inner().server.clone().ok_or(CoreError::NotConfigured)?; + Ok(SetupPayload { + server_url: kv_sync::transport::normalize_base_url(&server.url)?, + spki_pins: server.pins, + account_id: acct.account_id, + secret_key: SecretKey::from_bytes(*u.secret_key).to_display(), + ticket, + }) + } + fn unlocked_snapshot(&self) -> Result<(AccountRecord, Unlocked)> { let inner = self.lock_inner(); let acct = inner.account.clone().ok_or(CoreError::NoAccount)?; @@ -704,9 +935,10 @@ impl WebCore { account_id: Uuid, auth_input: &[u8], totp: Option, + ticket: Option, ) -> Result { let device = self.enroll()?; - match api.login(account_id, auth_input, device, totp).await? { + match api.login_with_ticket(account_id, auth_input, device, totp, ticket).await? { LoginOutcome::Ok { session } => { self.store.put_meta(META_SESSION, session.as_bytes())?; Ok(LoginResult::Ok) @@ -748,6 +980,35 @@ impl WebCore { // ---------------------------------------------------------------- free functions +/// Validates a scanned or pasted Setup Code (`kestrove://setup/v1?…`, docs/10 §2). +pub fn parse_setup_payload(payload: &str) -> Result { + Ok(to_info(SetupPayload::parse(payload)?)) +} + +fn to_info(p: SetupPayload) -> SetupInfo { + SetupInfo { + server_url: p.server_url, + spki_pins: p.spki_pins, + account_id: p.account_id.to_string(), + secret_key: p.secret_key.to_string(), + ticket: p.ticket, + } +} + +fn from_info(i: &SetupInfo) -> Result { + Ok(SetupPayload { + server_url: kv_sync::transport::normalize_base_url(&i.server_url)?, + spki_pins: i.spki_pins.clone(), + account_id: parse_id(&i.account_id)?, + secret_key: Zeroizing::new(i.secret_key.clone()), + ticket: i.ticket.clone(), + }) +} + +fn invalid(msg: &str) -> CoreError { + CoreError::Invalid { msg: msg.into() } +} + pub fn generate_password(length: u32, symbols: bool) -> String { generator::generate_password(&generator::PasswordOptions { length: length as usize, symbols, ..Default::default() }) } @@ -858,7 +1119,6 @@ fn platform_to_dto(p: Platform) -> PlatformDto { /// Hex of the first 16 bytes of SHA-256(P-256 public key), as kv-ffi shows it. pub fn device_key_fingerprint(p256_pk: &[u8]) -> String { - use sha2::{Digest, Sha256}; Sha256::digest(p256_pk)[..16].iter().map(|b| format!("{b:02x}")).collect() } diff --git a/crates/kv-wasm/src/dto.rs b/crates/kv-wasm/src/dto.rs index d12c501..6687dbb 100644 --- a/crates/kv-wasm/src/dto.rs +++ b/crates/kv-wasm/src/dto.rs @@ -181,3 +181,81 @@ pub struct ImportReport { pub warnings: Vec, pub item_ids: Vec, } + +// ---------------------------------------------------------------- onboarding (docs/10) + +/// A parsed, validated setup payload: everything a new device needs except the master password. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetupInfo { + /// Normalized server URL. + pub server_url: String, + /// Standard base64 SPKI pins, the format `configure_server` takes. Empty = WebPKI only. + #[serde(default)] + pub spki_pins: Vec, + pub account_id: String, + /// Display form `K1-…`. + pub secret_key: String, + /// Enrollment ticket; `login_with_setup` presents it so no manual approval is needed. + #[serde(default)] + pub ticket: Option, +} + +impl zeroize::Zeroize for SetupInfo { + fn zeroize(&mut self) { + self.secret_key.zeroize(); + self.ticket.zeroize(); + } +} + +impl std::fmt::Debug for SetupInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SetupInfo") + .field("server_url", &self.server_url) + .field("spki_pins", &self.spki_pins) + .field("account_id", &self.account_id) + .field("secret_key", &"") + .field("ticket", &self.ticket.as_ref().map(|_| "")) + .finish() + } +} + +/// The "Add a device" QR: a setup payload with a fresh, unbound ticket. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddDevicePayload { + pub payload: String, + /// For `revoke_enrollment_ticket` when the user cancels. + pub ticket_id: String, + /// Unix seconds; hide the QR after this. + pub expires_at: i64, +} + +/// Shown by the joining device: a QR for a signed-in phone to scan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinSession { + pub qr: String, + /// Unix seconds. + pub expires_at: i64, +} + +/// Result of `join_poll`: `code` once a phone has scanned the QR, `setup` once it sent the bundle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinProgress { + pub code: Option, + pub setup: Option, +} + +/// Shown by the approving device after scanning: the code to compare and who is asking. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinConfirm { + /// `"123 456"`. The joining device shows the same code. + pub code: String, + pub device_name: String, + pub platform: PlatformDto, + /// Same format as `DeviceDto.key_fingerprint`. + pub key_fingerprint: String, +} diff --git a/crates/kv-wasm/src/wasm/mod.rs b/crates/kv-wasm/src/wasm/mod.rs index 66fadac..c64debb 100644 --- a/crates/kv-wasm/src/wasm/mod.rs +++ b/crates/kv-wasm/src/wasm/mod.rs @@ -123,6 +123,52 @@ export interface ImportReport { itemIds: string[]; } +/** A parsed setup payload (`kestrove://setup/v1?…`): everything a new device needs but the master password. */ +export interface SetupInfo { + /** Normalized server URL. */ + serverUrl: string; + /** Standard base64 SPKI SHA-256 pins. Stored but not enforced in the browser. */ + spkiPins: string[]; + accountId: string; + /** Display form `K1-…`. A secret: do not keep it in long-lived state. */ + secretKey: string; + /** Enrollment ticket ("Add a device", scan-to-join); null on the Emergency Kit. */ + ticket: string | null; +} + +export interface AddDevicePayload { + /** Text for the QR (a secret view): the setup payload with a fresh 10-minute, single-use ticket. */ + payload: string; + /** For `revokeEnrollmentTicket` when the user cancels. */ + ticketId: string; + /** Unix seconds. */ + expiresAt: number; +} + +/** Joining side of scan-to-join: the QR to show (`kestrove://join/v1?…`). */ +export interface JoinSession { + qr: string; + /** Unix seconds. */ + expiresAt: number; +} + +export interface JoinProgress { + /** `"123 456"`, once the approving device scanned the QR. Show it for comparison. */ + code: string | null; + /** Set once the bundle arrived: ask for the master password and call `loginWithSetup`. */ + setup: SetupInfo | null; +} + +/** Approving side of scan-to-join: what the user checks before confirming. */ +export interface JoinConfirm { + /** `"123 456"`; the joining device shows the same code. */ + code: string; + deviceName: string; + platform: Platform; + /** Same format as `DeviceDto.keyFingerprint`. */ + keyFingerprint: string; +} + export type CoreErrorCode = | "Locked" | "NoAccount" | "BadCredentials" | "Invalid" | "NotFound" | "Internal" | "NotConfigured" | "NotAuthenticated" | "PendingApproval" | "Network" | "PinMismatch" | "Server" | "IntegrityAlarm" | "Busy"; @@ -617,6 +663,80 @@ impl VaultCore { self.run(|s| async move { Ok(s.core.is_logged_in()) }) } + // ------------------------------------------------------------ onboarding (docs/10) + + /// The Emergency Kit's Setup Code (server, account id, Secret Key; no ticket). Needs an unlocked + /// vault. Show it as a secret. + #[wasm_bindgen(js_name = setupPayloadForKit, unchecked_return_type = "Promise")] + pub fn setup_payload_for_kit(&self) -> Promise { + self.run(|s| async move { s.core.setup_payload_for_kit() }) + } + + /// The "Add a device" QR: a setup payload with a fresh 10-minute, single-use ticket, so the new + /// device needs only the master password and no approval. On Cancel call `revokeEnrollmentTicket`. + #[wasm_bindgen(js_name = addDevicePayload, unchecked_return_type = "Promise")] + pub fn add_device_payload(&self) -> Promise { + self.run(|s| async move { s.core.add_device_payload().await }) + } + + #[wasm_bindgen(js_name = revokeEnrollmentTicket, unchecked_return_type = "Promise")] + pub fn revoke_enrollment_ticket(&self, #[wasm_bindgen(js_name = "ticketId")] ticket_id: String) -> Promise { + self.run(move |s| async move { s.core.revoke_enrollment_ticket(&ticket_id).await }) + } + + /// Logs in with a setup code (from `parseSetupPayload` or `joinPoll`) and the master password, + /// presenting its ticket so no approval is needed. Refuses a code for another server than this + /// web vault's. Then call `syncNow`. Runs Argon2id. + #[wasm_bindgen(js_name = loginWithSetup, unchecked_return_type = "Promise")] + pub fn login_with_setup( + &self, + #[wasm_bindgen(unchecked_param_type = "SetupInfo")] setup: JsValue, + #[wasm_bindgen(js_name = "masterPassword")] master_password: String, + ) -> Promise { + let (setup, pw) = (from_js::(setup), zeroize::Zeroizing::new(master_password)); + self.write(move |s| async move { + let setup = zeroize::Zeroizing::new(setup?); + s.core.login_with_setup(&setup, &pw).await + }) + } + + /// Joining browser (no account yet): opens a rendezvous and resolves with the QR to show. Then + /// call `joinPoll` every 2 seconds. + #[wasm_bindgen(js_name = joinStart, unchecked_return_type = "Promise")] + pub fn join_start(&self, #[wasm_bindgen(js_name = "serverUrl")] server_url: String) -> Promise { + self.write(move |s| async move { s.core.join_start(&server_url).await }) + } + + /// Joining browser: `code` appears once a phone scanned the QR, `setup` once it sent the bundle. + /// Rejects with `NotFound` when the rendezvous expired (start again). + #[wasm_bindgen(js_name = joinPoll, unchecked_return_type = "Promise")] + pub fn join_poll(&self) -> Promise { + self.run(|s| async move { s.core.join_poll().await }) + } + + /// Forgets the local join state on either side. + #[wasm_bindgen(js_name = joinCancel, unchecked_return_type = "Promise")] + pub fn join_cancel(&self) -> Promise { + self.run(|s| async move { + s.core.join_cancel(); + Ok(()) + }) + } + + /// Approving browser (signed in, unlocked): answers a join QR and resolves with the code to + /// compare. Nothing secret is sent until `joinSend`. + #[wasm_bindgen(js_name = joinApprove, unchecked_return_type = "Promise")] + pub fn join_approve(&self, qr: String) -> Promise { + self.run(move |s| async move { s.core.join_approve(&qr).await }) + } + + /// Approving browser, after the user confirmed the code: sends the sealed setup bundle with a + /// ticket bound to the joining device's key. + #[wasm_bindgen(js_name = joinSend, unchecked_return_type = "Promise")] + pub fn join_send(&self) -> Promise { + self.run(|s| async move { s.core.join_send().await }) + } + /// Diagnostics and the cross-platform fixture (`js/smoke.mjs`): the replica entries exactly as /// IndexedDB holds them (ciphertext, ids, bookkeeping). Not part of the typed API. #[wasm_bindgen(js_name = __replicaEntries, skip_typescript)] @@ -662,6 +782,14 @@ pub fn match_url( to_js(&core::match_url(&item_url, &page_url)) } +/// Validates a scanned or pasted Setup Code (`kestrove://setup/v1?…`). Throws `CoreError` `Invalid`. +#[wasm_bindgen(js_name = parseSetupPayload, unchecked_return_type = "SetupInfo")] +pub fn parse_setup_payload(payload: String) -> std::result::Result { + let payload = zeroize::Zeroizing::new(payload); + let info = zeroize::Zeroizing::new(core::parse_setup_payload(&payload).map_err(|e| core_error(&e))?); + to_js(&*info) +} + #[cfg(test)] mod tests; diff --git a/crates/kv-wasm/src/wasm/tests.rs b/crates/kv-wasm/src/wasm/tests.rs index 4c23000..a968616 100644 --- a/crates/kv-wasm/src/wasm/tests.rs +++ b/crates/kv-wasm/src/wasm/tests.rs @@ -197,3 +197,57 @@ async fn in_memory_vault_import_export_and_helpers() { // No Public Suffix List in the web build: different hosts never match. assert_eq!(match_url("https://a.example.com".into(), "https://b.example.com".into()).unwrap(), "none"); } + +#[wasm_bindgen_test] +async fn setup_codes_in_the_browser_keep_the_secret_key_in_memory_only() { + let db_name = format!("kv-test-{}", Uuid::new_v4()); + let core = open_db(&db_name).await; + assert_eq!(err_code(core.setup_payload_for_kit()).await, "NoAccount"); + let secret_key = ok(core.create_account(PW.into())).await.as_string().unwrap(); + assert_eq!(err_code(core.setup_payload_for_kit()).await, "NotConfigured"); + ok(core.configure_server("https://vault.example.org/".into(), None)).await; + + // The Kit's Setup Code, parsed by the free function into a plain camelCase object. + let kit = ok(core.setup_payload_for_kit()).await.as_string().unwrap(); + assert!(kit.starts_with("kestrove://setup/v1?"), "{kit}"); + let info = parse_setup_payload(kit.clone()).unwrap(); + let get = |k: &str| Reflect::get(&info, &k.into()).unwrap(); + assert_eq!(get("serverUrl").as_string().as_deref(), Some("https://vault.example.org")); + assert_eq!(get("secretKey").as_string(), Some(secret_key.clone())); + assert_eq!(get("accountId").as_string(), ok(core.account_id()).await.as_string()); + assert!(get("ticket").is_null() && js_sys::Array::is_array(&get("spkiPins"))); + let e = parse_setup_payload("kestrove://setup/v2?s=x".into()).unwrap_err(); + assert_eq!(Reflect::get(&e, &"code".into()).unwrap().as_string().as_deref(), Some("Invalid")); + + // A setup code for another server is refused (connect-src 'self'), as is a malformed object. + let mut other: Value = serde_wasm_bindgen::from_value(info.clone()).unwrap(); + other["serverUrl"] = json!("https://elsewhere.example.net"); + assert_eq!(err_code(core.login_with_setup(js(other), PW.into())).await, "Invalid"); + assert_eq!(err_code(core.login_with_setup(js(json!({"serverUrl": 1})), PW.into())).await, "Invalid"); + + // Join calls without a join in progress, or with a bad QR. + assert_eq!(err_code(core.join_poll()).await, "Invalid"); + assert_eq!(err_code(core.join_send()).await, "Invalid"); + assert_eq!(err_code(core.join_approve("kestrove://join/v1?s=x".into())).await, "Invalid"); + assert_eq!(err_code(core.join_start("https://vault.example.org".into())).await, "Invalid", "has an account"); + ok(core.join_cancel()).await; + + // Nothing in IndexedDB holds the Secret Key, in either form. + let compact: String = secret_key.chars().filter(|c| *c != '-').collect(); + let db = core.shared.db.clone().unwrap(); + for (key, value) in idb::load_entries(&db).await.unwrap() { + let text = String::from_utf8_lossy(&value); + assert!(!text.contains(&secret_key) && !text.contains(&compact[2..]), "{key} holds the Secret Key"); + } + ok(core.lock()).await; + assert_eq!(err_code(core.setup_payload_for_kit()).await, "Locked"); + db.close(); + drop(core); + + // After a reload the code comes back with the unlock (which takes the Secret Key). + let core = open_db(&db_name).await; + assert_eq!(err_code(core.setup_payload_for_kit()).await, "Locked"); + ok(core.unlock(PW.into(), secret_key)).await; + assert_eq!(ok(core.setup_payload_for_kit()).await.as_string(), Some(kit)); + ok(core.wipe()).await; +} diff --git a/crates/kv-wasm/tests/sync.rs b/crates/kv-wasm/tests/sync.rs index 7b0d654..57fb45b 100644 --- a/crates/kv-wasm/tests/sync.rs +++ b/crates/kv-wasm/tests/sync.rs @@ -163,3 +163,200 @@ async fn concurrent_sync_is_refused_not_interleaved() { web.lock(); web.unlock(PW, &sk).unwrap(); } + +// ---------------------------------------------------------------- onboarding (docs/10) + +/// An app device (kv-ffi) signed in with one item, on a server that requires approval for new devices. +async fn app_with_item(server: &TestServer) -> (Arc, String, String) { + let app = kv_ffi::VaultCore::new_in_memory(); + let sk = app.create_account(PW.into()).unwrap(); + app.configure_server(server.url(), vec![]).unwrap(); + app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios) + .unwrap(); + app.register_account(SETUP_TOKEN.into()).await.unwrap(); + let id = app + .save_item(kv_ffi::ItemDto { + id: String::new(), + kind: kv_ffi::ItemKindDto::Login, + title: "Bank".into(), + username: None, + password: Some("from the app".into()), + urls: vec![], + totp: None, + notes: None, + favorite: false, + tags: vec![], + modified_at: 0, + }) + .unwrap(); + app.sync_now().await.unwrap(); + server.state().require_approval = true; + (app, sk, id) +} + +fn new_web(server: &TestServer) -> (Arc, WebCore) { + let store = Arc::new(WebStore::new()); + let web = web_core(store.clone(), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4()))); + (store, web) +} + +/// Nothing secret reaches the persisted replica: not the Secret Key in either form. +fn assert_no_secret_key(store: &WebStore, sk: &str) { + let compact: String = sk.chars().filter(|c| *c != '-').collect(); + for (k, v) in store.entries() { + let v = String::from_utf8_lossy(&v); + assert!(!v.contains(sk) && !v.contains(&compact[2..]), "Secret Key persisted under {k}"); + } +} + +#[tokio::test] +async fn web_vault_enrolls_by_ticket_both_ways() { + let server = TestServer::start().await; + let (app, sk, bank) = app_with_item(&server).await; + + // App -> browser: the "Add a device" QR plus the master password, no approval. + let add = app.add_device_payload().await.unwrap(); + let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap(); + assert_eq!((setup.secret_key.as_str(), setup.server_url.as_str()), (sk.as_str(), server.url().as_str())); + assert!(setup.ticket.is_some() && !format!("{setup:?}").contains(&sk)); + + // A code for another server is refused before anything is sent (the browser only talks to its origin). + let (_, other) = new_web(&server); + other.configure_server("https://vault.example.org", vec![]).unwrap(); + assert!(matches!(other.login_with_setup(&setup, PW).await, Err(CoreError::Invalid { .. }))); + + let (store, web) = new_web(&server); + assert!(matches!(web.login_with_setup(&setup, "wrong password!!").await, Err(CoreError::BadCredentials))); + assert_eq!(web.login_with_setup(&setup, PW).await.unwrap(), LoginResult::Ok); + web.sync_now().await.unwrap(); + assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app")); + assert!(app.list_devices().await.unwrap().iter().all(|d| d.approved)); + + // The browser's Kit payload matches the app's; it lives only in memory. + let kit = web.setup_payload_for_kit().unwrap(); + assert_eq!(kit, app.setup_payload_for_kit().unwrap()); + assert_eq!(kv_wasm::core::parse_setup_payload(&kit).unwrap().ticket, None); + assert_no_secret_key(&store, &sk); + web.lock(); + assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked))); + assert!(matches!(web.add_device_payload().await, Err(CoreError::Locked))); + // A reload: unlocking (which takes the Secret Key) brings it back. + let reloaded = web_core(Arc::new(WebStore::load(store.entries()).unwrap()), &server.url(), { + Arc::new(SoftwareSigner::generate(Uuid::new_v4())) + }); + reloaded.unlock(PW, &sk).unwrap(); + assert_eq!(reloaded.setup_payload_for_kit().unwrap(), kit); + web.unlock(PW, &sk).unwrap(); + + // Single use: another browser with the same QR waits for approval. + let (_, third) = new_web(&server); + assert_eq!(third.login_with_setup(&setup, PW).await.unwrap(), LoginResult::PendingApproval); + + // Browser -> app: the web vault's "Add a device" QR enrolls a new app device. + let add = web.add_device_payload().await.unwrap(); + let tablet = kv_ffi::VaultCore::new_in_memory(); + tablet + .set_device_signer( + Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), + "tablet".into(), + PlatformDto::Android, + ) + .unwrap(); + let info = kv_ffi::parse_setup_payload(add.payload).unwrap(); + assert_eq!(tablet.login_with_setup(info, PW.into()).await.unwrap(), kv_ffi::LoginResult::Ok); + tablet.sync_now().await.unwrap(); + assert_eq!(tablet.get_item(bank).unwrap().password.as_deref(), Some("from the app")); + + // A cancelled QR is revoked. + let add = web.add_device_payload().await.unwrap(); + web.revoke_enrollment_ticket(&add.ticket_id).await.unwrap(); + let (_, late) = new_web(&server); + let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap(); + assert_eq!(late.login_with_setup(&setup, PW).await.unwrap(), LoginResult::PendingApproval); +} + +#[tokio::test] +async fn web_vault_scan_to_join_both_roles() { + let server = TestServer::start().await; + let (phone, sk, bank) = app_with_item(&server).await; + + // The browser joins: it shows the QR, the phone scans it. + let (store, web) = new_web(&server); + assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. }))); + let session = web.join_start(&server.url()).await.unwrap(); + assert!(session.qr.starts_with("kestrove://join/v1?")); + assert_eq!(web.join_poll().await.unwrap().code, None); + let confirm = phone.join_approve(session.qr.clone()).await.unwrap(); + assert_eq!(confirm.key_fingerprint, web.device_key_fingerprint().unwrap()); + assert_eq!(confirm.device_name, "Web vault"); + let progress = web.join_poll().await.unwrap(); + assert_eq!((progress.code.as_deref(), progress.setup.as_ref()), (Some(confirm.code.as_str()), None)); + phone.join_send().await.unwrap(); + let setup = web.join_poll().await.unwrap().setup.expect("bundle arrived"); + assert_eq!(setup.secret_key, sk); + assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })), "one read"); + assert_eq!(web.login_with_setup(&setup, PW).await.unwrap(), LoginResult::Ok); + web.sync_now().await.unwrap(); + assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app")); + assert_no_secret_key(&store, &sk); + assert!(matches!(web.join_start(&server.url()).await, Err(CoreError::Invalid { .. }))); + + // The browser approves: an app desktop joins through it. + let desktop = kv_ffi::VaultCore::new_in_memory(); + desktop + .set_device_signer( + Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), + "desktop".into(), + PlatformDto::Linux, + ) + .unwrap(); + let session = desktop.join_start(server.url()).await.unwrap(); + assert!(matches!(web.join_send().await, Err(CoreError::Invalid { .. }))); + let elsewhere = session.qr.replace("127.0.0.1", "localhost"); + assert!(matches!(web.join_approve(&elsewhere).await, Err(CoreError::Invalid { .. }))); + let confirm = web.join_approve(&session.qr).await.unwrap(); + assert_eq!((confirm.device_name.as_str(), confirm.platform), ("desktop", kv_wasm::dto::PlatformDto::Linux)); + assert_eq!(confirm.key_fingerprint, desktop.device_key_fingerprint().unwrap()); + assert_eq!(desktop.join_poll().await.unwrap().code.as_deref(), Some(confirm.code.as_str())); + web.join_send().await.unwrap(); + assert!(matches!(web.join_send().await, Err(CoreError::Invalid { .. })), "one send per approval"); + { + let st = server.state(); + let raw = serde_json::to_string(st.joins.values().next().unwrap().bundle.as_ref().unwrap()).unwrap(); + assert!(!raw.contains("K1") && !raw.contains("kestrove://")); + } + let setup = desktop.join_poll().await.unwrap().setup.unwrap(); + assert_eq!(desktop.login_with_setup(setup, PW.into()).await.unwrap(), kv_ffi::LoginResult::Ok); + desktop.sync_now().await.unwrap(); + assert_eq!(desktop.get_item(bank).unwrap().password.as_deref(), Some("from the app")); +} + +#[tokio::test] +async fn web_vault_join_refuses_a_swapped_approver() { + let server = TestServer::start().await; + let (phone, _, _) = app_with_item(&server).await; + let (_, web) = new_web(&server); + let session = web.join_start(&server.url()).await.unwrap(); + phone.join_approve(session.qr.clone()).await.unwrap(); + web.join_poll().await.unwrap(); + + // The server swaps the hello key and bundle for its own (it never had the QR secret). + let qr = kv_sync::onboarding::JoinQr::parse(&session.qr).unwrap(); + let rid = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &qr.rendezvous_id).unwrap(); + let forger = kv_crypto::pairing::PairingKeypair::generate(); + let forged = + kv_crypto::pairing::seal(&forger, &qr.public_key, &[0u8; 32], &rid, b"kestrove://setup/v1?evil").unwrap(); + { + let mut st = server.state(); + let rv = st.joins.values_mut().next().unwrap(); + rv.hello = Some((Uuid::new_v4(), forger.public_bytes().to_vec())); + rv.bundle = Some(forged); + } + assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. }))); + assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })), "state cleared"); + + // Cancelled on the joining side; a fresh start works. + web.join_start(&server.url()).await.unwrap(); + web.join_cancel(); + assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. }))); +}