Items record sign_in_with {provider, account}; the browser bridge reports it
with the site's identities and saves it after the usual app prompt. Items
now keep fields they don't know, so editing in an older app never drops what
a newer one added.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
423 lines
19 KiB
Rust
423 lines
19 KiB
Rust
//! 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<u8> {
|
|
kv_sync::DeviceSigner::public_key(&self.0)
|
|
}
|
|
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, 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![],
|
|
sign_in_with: None,
|
|
modified_at: 0,
|
|
}
|
|
}
|
|
|
|
fn web_core(store: Arc<WebStore>, url: &str, signer: Arc<SoftwareSigner>) -> 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(), None).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![],
|
|
sign_in_with: None,
|
|
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, 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, 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, None).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<kv_ffi::VaultCore>, 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(), None).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![],
|
|
sign_in_with: None,
|
|
modified_at: 0,
|
|
})
|
|
.unwrap();
|
|
app.sync_now().await.unwrap();
|
|
server.state().require_approval = true;
|
|
(app, sk, id)
|
|
}
|
|
|
|
fn new_web(server: &TestServer) -> (Arc<WebStore>, 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, 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)));
|
|
}
|