Sync with the server and scan-to-join onboarding
- Device key (DeviceSigner): Windows TPM via CNG with a DPAPI-sealed software fallback, Linux Secret Service with a 0600 file fallback (warned in Settings), macOS keychain for dev runs. The store used is recorded; no silent switching. - SyncService (Tauri-free): server URL and pins, create account with the server's setup code, sync after unlock/save/browser save, every 15 min while unlocked and on demand, status events, integrity alarm, device list with fingerprint approval and revoke. - Onboarding: "Use my phone" (join_start/poll, QR rendered locally, 6-digit code), Emergency Kit from PDF/PNG/JPEG (hayro + rqrr), paste setup code, new account. Then only the master password. - From a signed-in desktop: "Add a phone or tablet" QR with countdown (cancel, lock and quit revoke the ticket) and "Approve a computer" via pasted join code. - KESTROVE_DATA_DIR / KESTROVE_DEVICE_NAME for a second instance; ignored live test drives three instances against a real server. - deny: ignore three unmaintained notices for zbus 5 sub-crates (Linux keyring). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
//! End to end against a real kestrove-server (ignored by default):
|
||||
//!
|
||||
//! ```sh
|
||||
//! KV_SETUP_CODE=… KV_BIND=127.0.0.1:18090 KV_PUBLIC_URL=http://127.0.0.1:18090 kestrove-server &
|
||||
//! KV_TEST_SERVER=http://127.0.0.1:18090 KV_TEST_SETUP_CODE=… cargo test --test live_sync -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Three "app instances" with separate data folders go through the same `SyncService` calls the UI makes:
|
||||
//! A creates the account; B joins with "Use my phone", approved from A with the pasted join code; items sync both
|
||||
//! ways; C uses the Emergency Kit code (no ticket), waits for approval, A approves it by fingerprint.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use kestrove_desktop::device_key::DeviceKey;
|
||||
use kestrove_desktop::sync::{SignIn, SyncService};
|
||||
use kv_ffi::{ItemDto, ItemKindDto, VaultCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const PASSWORD: &str = "correct horse battery staple";
|
||||
|
||||
fn instance(dir: &Path) -> Arc<SyncService> {
|
||||
let core = VaultCore::open(dir.join("vault.sqlite").to_string_lossy().into_owned()).unwrap();
|
||||
let key = DeviceKey::load_or_create_file(dir).map_err(|e| e.to_string());
|
||||
SyncService::new(core, dir, key, Box::new(|_| {}))
|
||||
}
|
||||
|
||||
fn login(title: &str) -> ItemDto {
|
||||
ItemDto {
|
||||
id: String::new(),
|
||||
kind: ItemKindDto::Login,
|
||||
title: title.into(),
|
||||
username: Some(format!("{title}-user")),
|
||||
password: Some("hunter2".into()),
|
||||
urls: vec!["https://example.com".into()],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn titles(svc: &SyncService) -> Vec<String> {
|
||||
svc.core().list_items().unwrap().into_iter().map(|i| i.title).collect()
|
||||
}
|
||||
|
||||
/// The server allows 5 auth calls a minute per IP; three devices from one machine hit that.
|
||||
async fn patiently<T, F, Fut>(mut f: F) -> Result<T, String>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, String>>,
|
||||
{
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match f().await {
|
||||
Err(e) if e.contains("Too many attempts") && start.elapsed() < Duration::from_secs(150) => {
|
||||
eprintln!("rate limited; waiting");
|
||||
tokio::time::sleep(Duration::from_secs(15)).await;
|
||||
}
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "needs a running kestrove-server (KV_TEST_SERVER, KV_TEST_SETUP_CODE)"]
|
||||
async fn create_join_approve_and_sync() {
|
||||
let server = std::env::var("KV_TEST_SERVER").expect("KV_TEST_SERVER");
|
||||
let code = std::env::var("KV_TEST_SETUP_CODE").expect("KV_TEST_SETUP_CODE");
|
||||
let (da, db, dc) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
|
||||
// A: "Create a new account".
|
||||
let a = instance(da.path());
|
||||
let sk = a.create_account(&server, vec![], &code, Zeroizing::new(PASSWORD.into())).await.unwrap();
|
||||
assert!(sk.starts_with("K1"));
|
||||
assert!(a.status().logged_in);
|
||||
assert!(a.status().last_sync_at.is_some(), "first sync after registration: {:?}", a.status().last_error);
|
||||
a.core().save_item(login("from-a")).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
|
||||
// A: "Add a device" QR, then Cancel revokes the ticket.
|
||||
let add = a.add_device().await.unwrap();
|
||||
assert!(add.qr_svg.starts_with("data:image/svg+xml"));
|
||||
a.cancel_add_device().await;
|
||||
|
||||
// B: "Use my phone" → the QR (and its text for the paste fallback).
|
||||
let b = instance(db.path());
|
||||
let join = b.join_start(&server).await.unwrap();
|
||||
assert!(join.qr_text.starts_with("kestrove://join/v1?"));
|
||||
let first = b.join_poll().await.unwrap();
|
||||
assert!(first.code.is_none() && first.setup.is_none());
|
||||
|
||||
// A: "Approve a computer" with the pasted join code; both show the same code.
|
||||
let confirm = a.approve_join(&join.qr_text).await.unwrap();
|
||||
assert_eq!(Some(confirm.key_fingerprint.clone()), b.status().device_fingerprint);
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
let seen = b.join_poll().await.unwrap();
|
||||
assert_eq!(seen.code.as_deref(), Some(confirm.code.as_str()));
|
||||
a.send_join().await.unwrap();
|
||||
|
||||
// B: the bundle arrives; then only the master password.
|
||||
let setup = loop {
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
if let Some(s) = b.join_poll().await.unwrap().setup {
|
||||
break s;
|
||||
}
|
||||
};
|
||||
assert!(setup.preapproved);
|
||||
assert_eq!(setup.server_url, server.trim_end_matches('/'));
|
||||
let r = patiently(|| b.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap();
|
||||
assert_eq!(r, SignIn::Ok);
|
||||
assert_eq!(titles(&b), vec!["from-a"]);
|
||||
assert!(b.stored_secret_key().is_some(), "B remembers the Secret Key, so unlock needs only the password");
|
||||
|
||||
// An item from B reaches A.
|
||||
b.core().save_item(login("from-b")).unwrap();
|
||||
b.sync_now().await.unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
let mut got = titles(&a);
|
||||
got.sort();
|
||||
assert_eq!(got, vec!["from-a", "from-b"]);
|
||||
|
||||
// Lock clears keys: no sync while locked, and unlocking with the password works again.
|
||||
b.core().lock();
|
||||
b.on_lock().await;
|
||||
assert!(b.sync_now().await.is_err());
|
||||
b.core().unlock(PASSWORD.into(), b.stored_secret_key().unwrap().to_string()).unwrap();
|
||||
b.sync_now().await.unwrap();
|
||||
|
||||
// C: Emergency Kit code (no ticket) → pending → A approves after comparing fingerprints.
|
||||
let kit = a.core().setup_payload_for_kit().unwrap();
|
||||
let c = instance(dc.path());
|
||||
let view = c.setup_from_text(&kit).unwrap();
|
||||
assert!(!view.preapproved);
|
||||
let r = patiently(|| c.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap();
|
||||
assert_eq!(r, SignIn::PendingApproval);
|
||||
let devices = a.devices().await.unwrap();
|
||||
let pending: Vec<_> = devices.iter().filter(|d| !d.approved && !d.revoked).collect();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(Some(pending[0].key_fingerprint.clone()), c.status().device_fingerprint);
|
||||
assert_eq!(a.status().pending_devices, 1);
|
||||
a.approve_device(&pending[0].device_id).await.unwrap();
|
||||
let r = patiently(|| c.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap();
|
||||
assert_eq!(r, SignIn::Ok);
|
||||
let mut got = titles(&c);
|
||||
got.sort();
|
||||
assert_eq!(got, vec!["from-a", "from-b"]);
|
||||
|
||||
// Revoke C from A; C can no longer sync.
|
||||
let c_id = pending[0].device_id.clone();
|
||||
a.revoke_device(&c_id).await.unwrap();
|
||||
assert!(a.devices().await.unwrap().iter().any(|d| d.device_id == c_id && d.revoked));
|
||||
assert!(c.sync_now().await.is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user