Sync with the server and scan-to-join onboarding
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

- 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:
2026-10-18 07:51:06 +09:00
co-authored by Claude Opus 5.5
parent 7701b65087
commit e19370f261
13 changed files with 3532 additions and 116 deletions
+156
View File
@@ -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());
}