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]>
274 lines
13 KiB
Rust
274 lines
13 KiB
Rust
//! Two devices against a real, freshly started kestrove-server. Ignored by default.
|
|
//!
|
|
//! ```sh
|
|
//! # in kestrove-server (empty database; without KV_SETUP_CODE it prints SETUP_TOKEN=… on boot instead):
|
|
//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 \
|
|
//! KV_SETUP_CODE=live-test-setup-code cargo run
|
|
//! # here:
|
|
//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=live-test-setup-code \
|
|
//! cargo test -p kv-ffi --test live_server -- --ignored --nocapture
|
|
//! ```
|
|
//! The setup code works once, so restart the server with an empty database for every run (one test per run:
|
|
//! `two_devices_against_local_server`, `removed_device_reenrols_against_local_server` or
|
|
//! `delete_account_against_local_server`). They take a few minutes
|
|
//! because they wait out the server's rate limit on the login endpoints.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use kv_ffi::{CoreError, DeviceSigner, ItemDto, ItemKindDto, LoginResult, PlatformDto, VaultCore};
|
|
use kv_sync::signing::SoftwareSigner;
|
|
use uuid::Uuid;
|
|
|
|
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>, CoreError> {
|
|
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| CoreError::Internal { msg: e.to_string() })
|
|
}
|
|
fn reset(&self) -> Result<(), CoreError> {
|
|
Err(CoreError::Internal { msg: "not supported in this test".into() })
|
|
}
|
|
}
|
|
|
|
fn connect(core: &VaultCore, url: &str, name: &str) {
|
|
core.configure_server(url.into(), vec![]).unwrap();
|
|
core.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), name.into(), PlatformDto::Linux)
|
|
.unwrap();
|
|
}
|
|
|
|
fn 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,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "needs a fresh local kestrove-server; see the module docs"]
|
|
async fn two_devices_against_local_server() {
|
|
let url = std::env::var("KESTROVE_SERVER_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into());
|
|
let token =
|
|
std::env::var("KESTROVE_SETUP_TOKEN").expect("set KESTROVE_SETUP_TOKEN to the token the server printed");
|
|
let pw = "a long master password for the live test";
|
|
|
|
let a = VaultCore::new_in_memory();
|
|
let sk = a.create_account(pw.into()).unwrap();
|
|
connect(&a, &url, "device A");
|
|
assert!(a.server_info().await.unwrap().setup_open, "server must be fresh (setup open)");
|
|
a.register_account(token, None).await.unwrap();
|
|
let id = a.save_item(item("Bank", "pw-a")).unwrap();
|
|
let r = a.sync_now().await.unwrap();
|
|
assert_eq!(r.pushed, 1);
|
|
|
|
// The server allows 5 unauthenticated auth calls per minute per IP and both devices share one.
|
|
tokio::time::sleep(std::time::Duration::from_secs(61)).await;
|
|
let b = VaultCore::new_in_memory();
|
|
connect(&b, &url, "device B");
|
|
if b.login(pw.into(), sk.clone(), None, None).await.unwrap() == LoginResult::PendingApproval {
|
|
let devices = a.list_devices().await.unwrap();
|
|
let pending = devices.iter().find(|d| !d.approved).expect("B listed as pending");
|
|
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
|
|
a.approve_device(pending.device_id.clone()).await.unwrap();
|
|
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
|
|
assert_eq!(b.login(pw.into(), sk.clone(), None, None).await.unwrap(), LoginResult::Ok);
|
|
}
|
|
b.sync_now().await.unwrap();
|
|
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
|
|
|
|
// Conflicting edits merge.
|
|
let mut on_a = a.get_item(id.clone()).unwrap();
|
|
on_a.notes = Some("A".into());
|
|
a.save_item(on_a).unwrap();
|
|
let mut on_b = b.get_item(id.clone()).unwrap();
|
|
on_b.username = Some("b".into());
|
|
b.save_item(on_b).unwrap();
|
|
a.sync_now().await.unwrap();
|
|
assert_eq!(b.sync_now().await.unwrap().merged, 1);
|
|
a.sync_now().await.unwrap();
|
|
let (ia, ib) = (a.get_item(id.clone()).unwrap(), b.get_item(id).unwrap());
|
|
assert_eq!((ia.notes.as_deref(), ia.username.as_deref()), (Some("A"), Some("b")));
|
|
assert_eq!((ib.notes, ib.username), (ia.notes, ia.username));
|
|
|
|
// A delete carries the sealed tombstone, and B authenticates it.
|
|
let temp = a.save_item(item("Temp", "pw-t")).unwrap();
|
|
a.sync_now().await.unwrap();
|
|
b.sync_now().await.unwrap();
|
|
assert!(b.get_item(temp.clone()).is_ok());
|
|
a.delete_item(temp.clone()).unwrap();
|
|
assert_eq!(a.sync_now().await.unwrap().pushed, 1);
|
|
b.sync_now().await.unwrap();
|
|
assert!(matches!(b.get_item(temp), Err(CoreError::NotFound)));
|
|
|
|
// Scan-to-join (docs/10 §4): a third device that knows only the server URL needs no approval.
|
|
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
|
|
let c = VaultCore::new_in_memory();
|
|
connect(&c, &url, "device C");
|
|
let session = c.join_start(url.clone()).await.unwrap();
|
|
let confirm = a.join_approve(session.qr).await.unwrap();
|
|
assert_eq!(confirm.device_name, "device C");
|
|
assert_eq!(c.join_poll().await.unwrap().code.as_deref(), Some(confirm.code.as_str()));
|
|
a.join_send().await.unwrap();
|
|
tokio::time::sleep(std::time::Duration::from_millis(1100)).await; // the server allows one poll per second
|
|
let setup = c.join_poll().await.unwrap().setup.expect("bundle");
|
|
assert_eq!(setup.secret_key, sk);
|
|
assert_eq!(c.login_with_setup(setup, pw.into()).await.unwrap(), LoginResult::Ok);
|
|
c.sync_now().await.unwrap();
|
|
assert!(c.list_items().unwrap().iter().any(|i| i.title == "Bank"));
|
|
|
|
b.logout().await.unwrap();
|
|
println!("live sync OK against {url}");
|
|
}
|
|
|
|
/// A platform signer whose identity can be reset, like the iOS Keychain one (its items survive a reinstall).
|
|
struct Resettable(std::sync::Mutex<SoftwareSigner>);
|
|
|
|
impl DeviceSigner for Resettable {
|
|
fn device_id(&self) -> String {
|
|
kv_sync::DeviceSigner::device_id(&*self.0.lock().unwrap()).to_string()
|
|
}
|
|
fn public_key(&self) -> Vec<u8> {
|
|
kv_sync::DeviceSigner::public_key(&*self.0.lock().unwrap())
|
|
}
|
|
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, CoreError> {
|
|
kv_sync::DeviceSigner::sign(&*self.0.lock().unwrap(), &message)
|
|
.map_err(|e| CoreError::Internal { msg: e.to_string() })
|
|
}
|
|
fn reset(&self) -> Result<(), CoreError> {
|
|
*self.0.lock().unwrap() = SoftwareSigner::generate(Uuid::new_v4());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// docs/03 "Removed devices" against the real server: the phone waits for approval, is denied, keeps its identity
|
|
/// over a "reinstall", and a fresh "Add a device" QR gets it straight in as a new device.
|
|
#[tokio::test]
|
|
#[ignore = "needs a fresh local kestrove-server; see the module docs"]
|
|
async fn removed_device_reenrols_against_local_server() {
|
|
let url = std::env::var("KESTROVE_SERVER_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into());
|
|
let token =
|
|
std::env::var("KESTROVE_SETUP_TOKEN").expect("set KESTROVE_SETUP_TOKEN to the token the server printed");
|
|
let pw = "a long master password for the live test";
|
|
|
|
let web = VaultCore::new_in_memory();
|
|
web.create_account(pw.into()).unwrap();
|
|
connect(&web, &url, "web vault");
|
|
web.register_account(token, None).await.unwrap();
|
|
let id = web.save_item(item("Bank", "pw-a")).unwrap();
|
|
web.sync_now().await.unwrap();
|
|
|
|
let signer = Arc::new(Resettable(std::sync::Mutex::new(SoftwareSigner::generate(Uuid::new_v4()))));
|
|
let phone = VaultCore::new_in_memory();
|
|
phone.set_device_signer(signer.clone(), "iPhone".into(), PlatformDto::Ios).unwrap();
|
|
let kit = kv_ffi::parse_setup_payload(web.setup_payload_for_kit().unwrap()).unwrap();
|
|
assert_eq!(phone.login_with_setup(kit, pw.into()).await.unwrap(), LoginResult::PendingApproval);
|
|
let old_id = signer.device_id();
|
|
web.revoke_device(old_id.clone()).await.unwrap();
|
|
|
|
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit
|
|
// Reinstall: a new core, the same identity, a fresh unbound ticket.
|
|
let phone = VaultCore::new_in_memory();
|
|
phone.set_device_signer(signer.clone(), "iPhone".into(), PlatformDto::Ios).unwrap();
|
|
let setup = kv_ffi::parse_setup_payload(web.add_device_payload().await.unwrap().payload).unwrap();
|
|
assert_eq!(phone.login_with_setup(setup, pw.into()).await.unwrap(), LoginResult::Ok);
|
|
assert_ne!(signer.device_id(), old_id);
|
|
phone.sync_now().await.unwrap();
|
|
assert_eq!(phone.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
|
|
let devices = web.list_devices().await.unwrap();
|
|
assert!(devices.iter().any(|d| d.device_id == old_id && d.revoked && !d.approved));
|
|
assert!(devices.iter().any(|d| d.device_id == signer.device_id() && d.approved));
|
|
assert!(matches!(web.approve_device(old_id).await, Err(CoreError::NotFound)));
|
|
println!("removed device re-enrolled OK against {url}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "needs a fresh local kestrove-server; see the module docs"]
|
|
async fn delete_account_against_local_server() {
|
|
let url = std::env::var("KESTROVE_SERVER_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into());
|
|
let token =
|
|
std::env::var("KESTROVE_SETUP_TOKEN").expect("set KESTROVE_SETUP_TOKEN to the token the server printed");
|
|
let pw = "a long master password for the live test";
|
|
|
|
let app = VaultCore::new_in_memory();
|
|
app.create_account(pw.into()).unwrap();
|
|
connect(&app, &url, "phone");
|
|
app.register_account(token.clone(), None).await.unwrap();
|
|
app.save_item(item("Bank", "pw-a")).unwrap();
|
|
app.sync_now().await.unwrap();
|
|
|
|
assert!(matches!(app.delete_account("wrong".into()).await, Err(CoreError::BadCredentials)));
|
|
app.delete_account(pw.into()).await.unwrap();
|
|
assert!(!app.has_account());
|
|
// The server forgot the account: setup is open again and accepts its configured code.
|
|
let fresh = VaultCore::new_in_memory();
|
|
fresh.create_account(pw.into()).unwrap();
|
|
connect(&fresh, &url, "new phone");
|
|
fresh.register_account(token, None).await.unwrap();
|
|
println!("account deleted and server reusable OK against {url}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "needs a fresh local kestrove-server; see the module docs"]
|
|
async fn two_users_on_one_server() {
|
|
let url = std::env::var("KESTROVE_SERVER_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into());
|
|
let token =
|
|
std::env::var("KESTROVE_SETUP_TOKEN").expect("set KESTROVE_SETUP_TOKEN to the token the server printed");
|
|
let (pw_a, pw_b) = ("the admin's long master password", "the second user's long master password");
|
|
|
|
// The admin: first account, with the setup code.
|
|
let admin = VaultCore::new_in_memory();
|
|
admin.create_account(pw_a.into()).unwrap();
|
|
connect(&admin, &url, "admin laptop");
|
|
admin.register_account(token, Some("[email protected]".into())).await.unwrap();
|
|
let details = admin.account_details().await.unwrap();
|
|
assert_eq!((details.email.as_deref(), details.is_admin), (Some("[email protected]"), true));
|
|
admin.save_item(item("Admin bank", "admin-secret")).unwrap();
|
|
admin.sync_now().await.unwrap();
|
|
|
|
// A second user, invited by the admin.
|
|
let invite = admin.admin_create_invite(Some("[email protected]".into()), None).await.unwrap();
|
|
let b = VaultCore::new_in_memory();
|
|
let sk_b = b.create_account(pw_b.into()).unwrap();
|
|
connect(&b, &url, "b phone");
|
|
b.register_account(invite.code.clone(), Some("[email protected]".into())).await.unwrap();
|
|
assert!(!b.account_details().await.unwrap().is_admin);
|
|
let b_item = b.save_item(item("B mail", "b-secret")).unwrap();
|
|
b.sync_now().await.unwrap();
|
|
// The invite is used up.
|
|
let c = VaultCore::new_in_memory();
|
|
c.create_account("someone else's password!!".into()).unwrap();
|
|
connect(&c, &url, "c");
|
|
assert!(c.register_account(invite.code, Some("[email protected]".into())).await.is_err());
|
|
|
|
// Neither sees the other's items after syncing.
|
|
admin.sync_now().await.unwrap();
|
|
let titles = |core: &VaultCore| core.list_items().unwrap().into_iter().map(|i| i.title).collect::<Vec<_>>();
|
|
assert_eq!(titles(&admin), vec!["Admin bank".to_string()]);
|
|
assert_eq!(titles(&b), vec!["B mail".to_string()]);
|
|
|
|
// B's new device signs in with email + password + Secret Key and gets only B's vault (after approval).
|
|
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // the per-IP limit on the auth endpoints
|
|
let laptop = VaultCore::new_in_memory();
|
|
connect(&laptop, &url, "b laptop");
|
|
let r = laptop.login(pw_b.into(), sk_b, None, Some("[email protected]".into())).await.unwrap();
|
|
assert_eq!(r, LoginResult::PendingApproval);
|
|
let pending = b.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
|
|
b.approve_device(pending.device_id).await.unwrap();
|
|
println!("two users on {url}: isolated OK ({b_item})");
|
|
}
|