//! The web core (compiled natively here, same code as in the browser) syncing with an app device //! (kv-ffi) through kv-sync's in-memory test server: enrollment by approval, both directions of //! edits, deletes, and a restart from the persisted replica. #![cfg(not(target_arch = "wasm32"))] use std::sync::Arc; use kv_ffi::{DeviceSigner, PlatformDto}; use kv_proto::Platform; use kv_sync::{ signing::SoftwareSigner, test_server::{TestServer, SETUP_TOKEN}, }; use kv_wasm::{ dto::{ItemDto, ItemKindDto, LoginResult}, store::WebStore, CoreError, WebCore, }; use uuid::Uuid; const PW: &str = "a long master password"; struct Signer(SoftwareSigner); impl DeviceSigner for Signer { fn device_id(&self) -> String { kv_sync::DeviceSigner::device_id(&self.0).to_string() } fn public_key(&self) -> Vec { kv_sync::DeviceSigner::public_key(&self.0) } fn sign(&self, message: Vec) -> Result, kv_ffi::CoreError> { kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| kv_ffi::CoreError::Internal { msg: e.to_string() }) } fn reset(&self) -> Result<(), kv_ffi::CoreError> { Err(kv_ffi::CoreError::Internal { msg: "not supported in this test".into() }) } } fn web_item(title: &str, password: &str) -> ItemDto { ItemDto { id: String::new(), kind: ItemKindDto::Login, title: title.into(), username: Some("me".into()), password: Some(password.into()), urls: vec!["https://example.com".into()], totp: None, notes: None, favorite: false, tags: vec![], modified_at: 0, } } fn web_core(store: Arc, url: &str, signer: Arc) -> WebCore { let core = WebCore::new(store).unwrap(); core.configure_server(url, vec![]).unwrap(); core.set_device_signer(signer, "Web vault", Platform::Macos); core } #[tokio::test] async fn web_vault_enrolls_and_syncs_with_an_app_device() { let server = TestServer::start().await; // The app (kv-ffi) is the first device. 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 mut on_app = kv_ffi::ItemDto { id: String::new(), kind: kv_ffi::ItemKindDto::Login, title: "Bank".into(), username: Some("me".into()), password: Some("from the app".into()), urls: vec![], totp: None, notes: None, favorite: false, tags: vec![], modified_at: 0, }; let bank = app.save_item(on_app.clone()).unwrap(); app.sync_now().await.unwrap(); // The browser logs in as a new device and waits for approval. server.state().require_approval = true; let store = Arc::new(WebStore::new()); let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4())); let web = web_core(store.clone(), &server.url(), signer.clone()); assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked))); assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::PendingApproval); assert!(!web.has_account()); let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap(); assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap()); app.approve_device(pending.device_id).await.unwrap(); assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::Ok); let r = web.sync_now().await.unwrap(); assert_eq!((r.pulled, r.vaults_synced), (1, 1)); assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app")); // Browser edit and new item reach the app. let mut edit = web.get_item(&bank).unwrap(); edit.notes = Some("edited in the browser".into()); web.save_item(edit).unwrap(); let new_id = web.save_item(web_item("Mail", "from the web")).unwrap(); assert_eq!(web.sync_now().await.unwrap().pushed, 2); app.sync_now().await.unwrap(); assert_eq!(app.get_item(bank.clone()).unwrap().notes.as_deref(), Some("edited in the browser")); assert_eq!(app.get_item(new_id.clone()).unwrap().password.as_deref(), Some("from the web")); // Concurrent edits of different fields merge. on_app = app.get_item(bank.clone()).unwrap(); on_app.password = Some("rotated on the app".into()); app.save_item(on_app).unwrap(); let mut edit = web.get_item(&bank).unwrap(); edit.tags = vec!["web".into()]; web.save_item(edit).unwrap(); app.sync_now().await.unwrap(); let r = web.sync_now().await.unwrap(); assert_eq!(r.merged, 1); app.sync_now().await.unwrap(); for item in [web.get_item(&bank).unwrap(), { let d = app.get_item(bank.clone()).unwrap(); ItemDto { password: d.password, tags: d.tags, ..web.get_item(&bank).unwrap() } }] { assert_eq!(item.password.as_deref(), Some("rotated on the app")); assert_eq!(item.tags, vec!["web".to_string()]); } // Delete in the browser; the app sees it. web.delete_item(&new_id).unwrap(); web.sync_now().await.unwrap(); app.sync_now().await.unwrap(); assert!(matches!(app.get_item(new_id), Err(kv_ffi::CoreError::NotFound))); // A page reload: the replica comes back from its persisted entries, locked, session kept. let reloaded = Arc::new(WebStore::load(store.entries()).unwrap()); let web = web_core(reloaded, &server.url(), signer); assert!(web.has_account() && web.is_locked() && web.is_logged_in()); assert!(matches!(web.list_items(), Err(CoreError::Locked))); web.unlock(PW, &sk).unwrap(); assert_eq!(web.sync_now().await.unwrap().pulled, 0); assert_eq!(web.list_items().unwrap().len(), 1); // Revoked by the app: the browser can no longer sync. let me = web.device_id().unwrap().to_string(); app.revoke_device(me).await.unwrap(); assert!(web.sync_now().await.is_err()); } #[tokio::test] async fn concurrent_sync_is_refused_not_interleaved() { let server = TestServer::start().await; let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4()))); let sk = web.create_account(PW).unwrap(); web.register_account(SETUP_TOKEN).await.unwrap(); web.save_item(web_item("A", "1")).unwrap(); let (a, b) = tokio::join!(web.sync_now(), web.sync_now()); assert!(matches!((&a, &b), (Ok(_), Err(CoreError::Busy)) | (Err(CoreError::Busy), Ok(_))), "{a:?} {b:?}"); assert!(web.sync_now().await.is_ok()); 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(); // A failed unlock (or login) forgets it until the next one that succeeds. assert!(matches!(web.unlock("wrong password!!", &sk), Err(CoreError::BadCredentials))); assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked))); web.unlock(PW, &sk).unwrap(); assert!(web.login("wrong password!!", &sk, None).await.is_err()); assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked))); 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); // Logging out forgets the Secret Key too. web.logout().await.unwrap(); assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked))); } #[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 { .. }))); } /// docs/03 "Removed devices" for the web core: a removed browser gets `DeviceRevoked` (only with the right /// password), forgets its session, and the same "Add a device" ticket gets it back in under a new key and id /// (what the browser binding does: `forget_revoked_device`, new WebCrypto key, one retry). #[tokio::test] async fn removed_web_vault_reenrols_with_the_same_ticket() { let server = TestServer::start().await; let (app, _sk, id) = app_with_item(&server).await; let add = app.add_device_payload().await.unwrap(); let setup: kv_wasm::dto::SetupInfo = { let i = kv_ffi::parse_setup_payload(add.payload.clone()).unwrap(); kv_wasm::dto::SetupInfo { server_url: i.server_url, spki_pins: i.spki_pins, account_id: i.account_id, secret_key: i.secret_key, ticket: i.ticket, } }; // The browser enrolled once (pending), then was denied. let (store, web) = new_web(&server); let old = web.device_id().unwrap(); let no_ticket = kv_wasm::dto::SetupInfo { ticket: None, ..setup.clone() }; assert_eq!(web.login_with_setup(&no_ticket, PW).await.unwrap(), LoginResult::PendingApproval); app.revoke_device(old.to_string()).await.unwrap(); assert!(matches!(web.login_with_setup(&setup, "wrong password!!").await, Err(CoreError::BadCredentials))); assert!(matches!(web.login_with_setup(&setup, PW).await, Err(CoreError::DeviceRevoked))); web.forget_revoked_device().unwrap(); web.set_device_signer(Arc::new(SoftwareSigner::generate(Uuid::new_v4())), "Web vault", Platform::Macos); assert_ne!(web.device_id().unwrap(), old); assert_eq!(web.login_with_setup(&setup, PW).await.unwrap(), LoginResult::Ok); web.sync_now().await.unwrap(); assert_eq!(web.get_item(&id).unwrap().password.as_deref(), Some("from the app")); drop(store); // Signed in, then removed: sync says so; the old id never comes back. app.revoke_device(web.device_id().unwrap().to_string()).await.unwrap(); assert!(matches!(web.sync_now().await, Err(CoreError::DeviceRevoked))); web.forget_revoked_device().unwrap(); assert!(matches!(app.approve_device(old.to_string()).await, Err(kv_ffi::CoreError::NotFound))); }