- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs. - kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email, set_email, /v1/admin/* client calls. - kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email, admin_create_invite. kv-wasm: the same plus the full admin API for the web vault. - Live test: two users on one server stay isolated; a new device signs in by email. Co-Authored-By: Claude Opus 5.5 <[email protected]>
272 lines
13 KiB
Rust
272 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![],
|
|
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).
|
|
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})");
|
|
}
|