//! Sync server connection and onboarding (docs/03, docs/10) on top of kv-ffi, without Tauri types so the //! integration tests can drive it. The UI layer (main.rs) wraps each method in a command. //! //! Secrets stay here: setup payloads (Secret Key, ticket) never go to the web view except as the QR image the //! user asked to see. use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use kv_ffi::{CoreError, DeviceDto, LoginResult, PlatformDto, SetupInfo, VaultCore}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::device_key::DeviceKey; use crate::{private_file, qr}; /// Background sync while unlocked (docs/03 "Triggers"). pub const SYNC_PERIOD: Duration = Duration::from_secs(15 * 60); /// Saves in quick succession (or an unlock right after a save) become one sync. const COALESCE: Duration = Duration::from_millis(800); /// What the UI shows about sync. Sent with every change (event `sync-status`). #[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] pub struct SyncStatus { pub server_url: Option, pub pins: Vec, pub logged_in: bool, pub syncing: bool, /// Unix seconds of the last successful sync. pub last_sync_at: Option, pub last_error: Option, /// This device waits for approval on another device. pub pending_approval: bool, /// Other devices waiting for this user's approval. pub pending_devices: u32, /// The server failed verification; syncing that vault has stopped (docs/03 "Integrity"). pub integrity_alarm: Option, pub device_fingerprint: Option, pub key_storage: Option, pub key_warning: Option, /// Items an edit here brought back after a delete elsewhere, from the last sync. pub restored_items: u32, /// Bumped whenever a sync changed local items, so the UI knows to refresh the list. pub changes: u64, } /// What we persist besides the core's own records (`sync.json`, 0600). #[derive(Default, Serialize, Deserialize)] struct Saved { server_url: Option, #[serde(default)] pins: Vec, last_sync_at: Option, integrity_alarm: Option, } #[derive(Debug, Clone, Serialize)] pub struct DeviceView { pub device_id: String, pub name: String, pub platform: String, pub approved: bool, pub revoked: bool, pub current: bool, pub created_at: String, pub key_fingerprint: String, } /// A setup payload waiting for the master password. The Secret Key stays in Rust. #[derive(Debug, Clone, Serialize)] pub struct SetupView { pub server_url: String, pub server_host: String, pub account_id: String, /// A ticket means no manual approval will be needed. pub preapproved: bool, } #[derive(Debug, Clone, Serialize)] pub struct JoinView { /// QR image (`data:` URI) to scan with a signed-in phone. pub qr_svg: String, /// The same as text, for "Paste a computer's join code" on another computer. pub qr_text: String, pub expires_at: i64, } #[derive(Debug, Clone, Serialize)] pub struct JoinPollView { /// `"123 456"` once a phone scanned the QR. pub code: Option, /// Set when the bundle arrived: ask for the master password and call `finish_setup`. pub setup: Option, } #[derive(Debug, Clone, Serialize)] pub struct AddDeviceView { pub qr_svg: String, pub expires_at: i64, } #[derive(Debug, Clone, Serialize)] pub struct JoinConfirmView { pub code: String, pub device_name: String, pub platform: String, pub key_fingerprint: String, } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SignIn { Ok, PendingApproval, } type Notify = Box; pub struct SyncService { core: Arc, data_dir: PathBuf, key: Result, String>, status: Mutex, /// From "Use my phone", the Emergency Kit or a pasted code, until `finish_setup` succeeds. pending_setup: Mutex>, /// The "Add a device" ticket on screen, revoked on cancel and on lock. add_device_ticket: Mutex>, wake: tokio::sync::Notify, notify: Notify, } fn lock(m: &Mutex) -> MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) } /// User-facing text for a core error. pub fn describe(e: &CoreError) -> String { match e { CoreError::Network { msg } => format!("Can't reach the server: {msg}"), CoreError::PinMismatch => { "The server's certificate doesn't match the pinned keys. Someone may be intercepting \ the connection, so Kestrove won't talk to it." .into() } CoreError::Server { status: 429, .. } => "Too many attempts. Wait a minute and try again.".into(), CoreError::BadCredentials => "Wrong master password or Secret Key.".into(), CoreError::PendingApproval => "This computer is waiting for approval on one of your other devices.".into(), CoreError::NotConfigured => "No sync server is set up yet.".into(), CoreError::NotAuthenticated => "Sign in to the sync server again (lock and unlock with your password).".into(), CoreError::IntegrityAlarm { vault_id, reason } => format!( "The server sent vault data that failed verification (vault {vault_id}: {reason}). Syncing stopped; \ your local data is unchanged." ), CoreError::Invalid { msg } => msg.clone(), other => other.to_string(), } } fn platform_name(p: PlatformDto) -> &'static str { match p { PlatformDto::Ios => "iOS", PlatformDto::Macos => "macOS", PlatformDto::Android => "Android", PlatformDto::Windows => "Windows", PlatformDto::Linux => "Linux", } } fn this_platform() -> PlatformDto { if cfg!(windows) { PlatformDto::Windows } else if cfg!(target_os = "macos") { PlatformDto::Macos } else { PlatformDto::Linux } } /// The computer's name for the device list ("Sami's PC"); `KESTROVE_DEVICE_NAME` overrides it. pub fn device_name() -> String { if let Ok(n) = std::env::var("KESTROVE_DEVICE_NAME") { return n; } #[cfg(windows)] let host = std::env::var("COMPUTERNAME").ok(); #[cfg(unix)] let host = { let mut buf = [0u8; 256]; // SAFETY: the buffer is valid for its length; gethostname NUL-terminates on success (we also cap it). let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len() - 1) }; (rc == 0).then(|| { let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); String::from_utf8_lossy(&buf[..end]).trim_end_matches(".local").to_string() }) }; host.filter(|h| !h.is_empty()).unwrap_or_else(|| format!("{} computer", platform_name(this_platform()))) } fn host_of(url: &str) -> String { url::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string)).unwrap_or_else(|| url.to_string()) } fn setup_view(s: &SetupInfo) -> SetupView { SetupView { server_url: s.server_url.clone(), server_host: host_of(&s.server_url), account_id: s.account_id.clone(), preapproved: s.ticket.is_some(), } } fn device_view(d: DeviceDto) -> DeviceView { DeviceView { device_id: d.device_id, name: d.name, platform: platform_name(d.platform).into(), approved: d.approved, revoked: d.revoked, current: d.current, created_at: d.created_at, key_fingerprint: d.key_fingerprint, } } /// Splits "a, b c" pin lists typed by the user. pub fn parse_pins(text: &str) -> Vec { text.split(|c: char| c == ',' || c.is_whitespace()).filter(|s| !s.is_empty()).map(str::to_string).collect() } impl SyncService { /// `key` is this device's signing key (or why it could not be loaded; sync is then unavailable). pub fn new(core: Arc, data_dir: &Path, key: Result, notify: Notify) -> Arc { let saved: Saved = private_file::read(&data_dir.join("sync.json")) .ok() .flatten() .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default(); let key = key.map(Arc::new); let mut status = SyncStatus { server_url: saved.server_url, pins: saved.pins, last_sync_at: saved.last_sync_at, integrity_alarm: saved.integrity_alarm, ..Default::default() }; match &key { Ok(k) => { status.key_storage = Some(k.storage().describe().into()); status.key_warning = k.warning().map(str::to_string); let signer: Arc = k.clone(); if let Err(e) = core.set_device_signer(signer, device_name(), this_platform()) { status.key_warning = Some(format!("Sync is unavailable: {}", describe(&e))); } status.device_fingerprint = core.device_key_fingerprint().ok(); } Err(e) => { status.key_warning = Some(format!("Sync is unavailable: this device's key could not be loaded ({e}).")) } } Arc::new(Self { core, data_dir: data_dir.to_path_buf(), key, status: Mutex::new(status), pending_setup: Mutex::new(None), add_device_ticket: Mutex::new(None), wake: tokio::sync::Notify::new(), notify, }) } pub fn core(&self) -> &Arc { &self.core } pub fn status(&self) -> SyncStatus { let mut s = lock(&self.status).clone(); s.logged_in = self.core.is_logged_in(); s } fn update(&self, f: impl FnOnce(&mut SyncStatus)) { let snapshot = { let mut s = lock(&self.status); f(&mut s); let saved = Saved { server_url: s.server_url.clone(), pins: s.pins.clone(), last_sync_at: s.last_sync_at, integrity_alarm: s.integrity_alarm.clone(), }; if let Ok(bytes) = serde_json::to_vec(&saved) { if let Err(e) = private_file::write(&self.data_dir.join("sync.json"), &bytes) { eprintln!("kestrove: could not save sync settings: {e}"); } } s.clone() }; let mut snapshot = snapshot; snapshot.logged_in = self.core.is_logged_in(); (self.notify)(&snapshot); } fn require_key(&self) -> Result<(), String> { self.key.as_ref().map(|_| ()).map_err(|e| format!("Sync is unavailable: {e}")) } // ------------------------------------------------------------ Secret Key on this device fn secret_key_path(&self) -> PathBuf { self.data_dir.join("secret-key") } pub fn remember_secret_key(&self, secret_key: &str) { if let Err(e) = private_file::write(&self.secret_key_path(), secret_key.trim().as_bytes()) { eprintln!("kestrove: could not store the Secret Key: {e}"); } } pub fn stored_secret_key(&self) -> Option> { let b = private_file::read(&self.secret_key_path()).ok()??; String::from_utf8(b).ok().map(Zeroizing::new) } // ------------------------------------------------------------ server settings and sign-in /// Saves the server URL and optional SPKI pins (base64 SHA-256). pub fn configure(&self, url: &str, pins: Vec) -> Result<(), String> { self.require_key()?; let url = url.trim().trim_end_matches('/').to_string(); self.core.configure_server(url.clone(), pins.clone()).map_err(|e| describe(&e))?; self.update(|s| { s.server_url = Some(url); s.pins = pins; }); Ok(()) } /// "Create a new account": creates the vault here and registers it with a fresh server using the setup code /// (`KV_SETUP_CODE`). Returns the Secret Key for the Emergency Kit. pub async fn create_account( &self, server_url: &str, pins: Vec, setup_code: &str, master_password: Zeroizing, ) -> Result, String> { if self.core.has_account() { return Err("This computer already has a vault.".into()); } if setup_code.trim().is_empty() { return Err("Enter the server's setup code.".into()); } self.configure(server_url, pins)?; let info = self.core.server_info().await.map_err(|e| describe(&e))?; if !info.setup_open { return Err( "This server already has an account. Choose “Use my phone” or “Paste setup code” instead.".into() ); } let sk = Zeroizing::new(self.core.create_account(master_password.to_string()).map_err(|e| describe(&e))?); self.remember_secret_key(&sk); if let Err(e) = self.core.register_account(setup_code.trim().to_string()).await { return Err(format!( "Your vault was created on this computer, but the server refused it: {}. Check the setup code in \ Settings → Sync and try again.", describe(&e) )); } let _ = self.sync_now().await; Ok(sk) } /// For a vault created before sync was set up: registers it with a fresh server. pub async fn register(&self, setup_code: &str) -> Result<(), String> { self.core.register_account(setup_code.trim().to_string()).await.map_err(|e| describe(&e))?; self.sync_now().await.map(|_| ()) } /// Whether the configured server still accepts a setup code (no account yet). pub async fn setup_open(&self) -> Result { Ok(self.core.server_info().await.map_err(|e| describe(&e))?.setup_open) } // ------------------------------------------------------------ sync /// Wakes the background loop (after unlock, after a save). pub fn request_sync(&self) { self.wake.notify_one(); } fn can_sync(&self) -> bool { self.key.is_ok() && self.core.has_account() && !self.core.is_locked() && lock(&self.status).server_url.is_some() } /// Runs forever: syncs when woken and every 15 minutes while unlocked. pub async fn run(self: Arc) { loop { let _ = tokio::time::timeout(SYNC_PERIOD, self.wake.notified()).await; tokio::time::sleep(COALESCE).await; if self.can_sync() { let _ = self.sync_now().await; } } } pub async fn sync_now(&self) -> Result { self.require_key()?; if self.core.is_locked() { return Err("Unlock Kestrove first.".into()); } self.update(|s| s.syncing = true); let result = self.core.sync_now().await; let mut pending_devices = None; if result.is_ok() { pending_devices = self .core .list_devices() .await .ok() .map(|ds| ds.iter().filter(|d| !d.approved && !d.revoked && !d.current).count() as u32); } let err = result.as_ref().err().map(describe); self.update(|s| { s.syncing = false; match &result { Ok(r) => { s.last_sync_at = Some(now_secs()); s.last_error = None; s.pending_approval = false; s.integrity_alarm = None; s.restored_items = r.restored_item_ids.len() as u32; if r.pulled + r.merged > 0 || !r.restored_item_ids.is_empty() { s.changes += 1; } } Err(CoreError::PendingApproval) => { s.pending_approval = true; s.last_error = None; } Err(e @ CoreError::IntegrityAlarm { .. }) => { s.integrity_alarm = Some(describe(e)); s.last_error = err.clone(); // Other vaults may still have synced. s.changes += 1; } Err(_) => s.last_error = err.clone(), } if let Some(n) = pending_devices { s.pending_devices = n; } }); match err { Some(e) if !matches!(result, Err(CoreError::PendingApproval)) => Err(e), _ => Ok(self.status()), } } // ------------------------------------------------------------ devices pub async fn devices(&self) -> Result, String> { let ds = self.core.list_devices().await.map_err(|e| describe(&e))?; let pending = ds.iter().filter(|d| !d.approved && !d.revoked && !d.current).count() as u32; self.update(|s| s.pending_devices = pending); Ok(ds.into_iter().map(device_view).collect()) } /// After the user compared `key_fingerprint` with the one the other device shows. pub async fn approve_device(&self, device_id: &str) -> Result<(), String> { self.core.approve_device(device_id.to_string()).await.map_err(|e| describe(&e))?; self.devices().await.map(|_| ()) } pub async fn revoke_device(&self, device_id: &str) -> Result<(), String> { self.core.revoke_device(device_id.to_string()).await.map_err(|e| describe(&e))?; self.devices().await.map(|_| ()) } // ------------------------------------------------------------ onboarding: a new computer /// "Use my phone": opens a rendezvous and returns the QR. Then poll `join_poll` every 2 seconds. pub async fn join_start(&self, server_url: &str) -> Result { self.require_key()?; let url = server_url.trim().trim_end_matches('/').to_string(); let session = self.core.join_start(url.clone()).await.map_err(|e| describe(&e))?; self.update(|s| { s.server_url = Some(url); s.pins = vec![]; }); Ok(JoinView { qr_svg: qr::svg_data_uri(&session.qr)?, qr_text: session.qr, expires_at: session.expires_at }) } pub async fn join_poll(&self) -> Result { match self.core.join_poll().await { Ok(p) => { let setup = p.setup.map(|s| { let view = setup_view(&s); *lock(&self.pending_setup) = Some(s); view }); Ok(JoinPollView { code: p.code, setup }) } Err(CoreError::NotFound) => Err("The code expired. Start again.".into()), // The server allows one poll per second per rendezvous; a 429 just means "not yet". Err(CoreError::Server { status: 429, .. }) => Ok(JoinPollView { code: None, setup: None }), Err(e) => Err(describe(&e)), } } pub fn join_cancel(&self) { self.core.join_cancel(); } /// "Paste setup code". pub fn setup_from_text(&self, text: &str) -> Result { let info = kv_ffi::parse_setup_payload(text.trim().to_string()).map_err(|e| describe(&e))?; let view = setup_view(&info); *lock(&self.pending_setup) = Some(info); Ok(view) } /// "Open Emergency Kit…": a PNG/JPEG photo or the Kit PDF. pub fn setup_from_file(&self, bytes: &[u8]) -> Result { let texts = qr::decode_file(bytes)?; let text = texts .iter() .find(|t| t.starts_with("kestrove://setup/")) .ok_or("No Kestrove setup code was found in this file. Try a sharper photo, or paste the code.")?; self.setup_from_text(text) } pub fn clear_setup(&self) { *lock(&self.pending_setup) = None; } /// Signs in with the pending setup payload and the master password, then downloads the vault. /// `PendingApproval` (a Kit code has no ticket): approve this computer elsewhere, then call again. pub async fn finish_setup(&self, master_password: Zeroizing) -> Result { self.require_key()?; let setup = lock(&self.pending_setup).clone().ok_or("Scan or paste a setup code first.")?; let result = self.core.login_with_setup(setup.clone(), master_password.to_string()).await.map_err(|e| describe(&e))?; self.update(|s| { s.server_url = Some(setup.server_url.clone()); s.pins = setup.spki_pins.clone(); s.pending_approval = result == LoginResult::PendingApproval; }); if result == LoginResult::PendingApproval { return Ok(SignIn::PendingApproval); } self.remember_secret_key(&setup.secret_key); self.clear_setup(); let _ = self.sync_now().await; Ok(SignIn::Ok) } // ------------------------------------------------------------ onboarding: from this (signed-in) computer /// "Add a device": a 10-minute, single-use QR. Cancel (or lock) revokes it. pub async fn add_device(&self) -> Result { self.cancel_add_device().await; let p = self.core.add_device_payload().await.map_err(|e| describe(&e))?; *lock(&self.add_device_ticket) = Some(p.ticket_id); Ok(AddDeviceView { qr_svg: qr::svg_data_uri(&p.payload)?, expires_at: p.expires_at }) } /// Revokes the "Add a device" ticket if one is on screen. A ticket already used or expired is fine. pub async fn cancel_add_device(&self) { let id = lock(&self.add_device_ticket).take(); if let Some(id) = id { if let Err(e) = self.core.revoke_enrollment_ticket(id).await { if !matches!(e, CoreError::NotFound) { eprintln!("kestrove: could not revoke the enrollment ticket: {e}"); } } } } /// "Approve a computer": the join code shown (as QR and text) on the new computer. pub async fn approve_join(&self, join_code: &str) -> Result { let c = self.core.join_approve(join_code.trim().to_string()).await.map_err(|e| match e { CoreError::NotFound => "That join code expired or was already used.".to_string(), e => describe(&e), })?; Ok(JoinConfirmView { code: c.code, device_name: c.device_name, platform: platform_name(c.platform).into(), key_fingerprint: c.key_fingerprint, }) } /// After the user saw the same code on both screens. pub async fn send_join(&self) -> Result<(), String> { self.core.join_send().await.map_err(|e| describe(&e)) } /// Emergency Kit setup code (server, pins, account, Secret Key; no ticket) as a QR. pub fn kit_qr(&self) -> Result { qr::svg_data_uri(&self.core.setup_payload_for_kit().map_err(|e| describe(&e))?) } /// Called whenever the vault locks: nothing secret stays live (the core drops its keys itself). pub async fn on_lock(&self) { self.core.join_cancel(); self.cancel_add_device().await; } } #[cfg(test)] mod tests { use super::*; #[test] fn pins_split_on_commas_and_spaces() { assert_eq!(parse_pins(" a=, b=\nc= "), vec!["a=", "b=", "c="]); assert!(parse_pins(" ").is_empty()); } #[test] fn device_name_is_never_empty() { assert!(!device_name().is_empty()); } #[tokio::test] async fn settings_persist_and_secrets_stay_out_of_views() { let dir = tempfile::tempdir().unwrap(); let core = VaultCore::new_in_memory(); let key = DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()); let svc = SyncService::new(core, dir.path(), key, Box::new(|_| {})); svc.configure("http://127.0.0.1:9/", vec![]).unwrap(); assert_eq!(svc.status().server_url.as_deref(), Some("http://127.0.0.1:9")); assert!(svc.status().device_fingerprint.is_some()); let payload = "kestrove://setup/v1?s=https%3A%2F%2Fkestrove.samialabri.com&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&k=K12TB9D5MPJTB9D5MPJTB9D5MPJTW"; let view = svc.setup_from_text(payload).unwrap(); assert_eq!(view.server_host, "kestrove.samialabri.com"); assert!(!view.preapproved); assert!(!serde_json::to_string(&view).unwrap().contains("K12TB9")); assert!(svc.setup_from_text("kestrove://setup/v9?s=x").is_err()); // A second service over the same folder sees the saved server. let again = SyncService::new( VaultCore::new_in_memory(), dir.path(), DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()), Box::new(|_| {}), ); assert_eq!(again.status().server_url.as_deref(), Some("http://127.0.0.1:9")); assert_eq!(again.status().device_fingerprint, svc.status().device_fingerprint); } #[tokio::test] async fn sync_needs_unlock() { let dir = tempfile::tempdir().unwrap(); let svc = SyncService::new( VaultCore::new_in_memory(), dir.path(), DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()), Box::new(|_| {}), ); assert!(svc.sync_now().await.is_err()); assert!(!svc.can_sync()); } }