- 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]>
671 lines
25 KiB
Rust
671 lines
25 KiB
Rust
//! 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<String>,
|
|
pub pins: Vec<String>,
|
|
pub logged_in: bool,
|
|
pub syncing: bool,
|
|
/// Unix seconds of the last successful sync.
|
|
pub last_sync_at: Option<u64>,
|
|
pub last_error: Option<String>,
|
|
/// 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<String>,
|
|
pub device_fingerprint: Option<String>,
|
|
pub key_storage: Option<String>,
|
|
pub key_warning: Option<String>,
|
|
/// 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<String>,
|
|
#[serde(default)]
|
|
pins: Vec<String>,
|
|
last_sync_at: Option<u64>,
|
|
integrity_alarm: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// Set when the bundle arrived: ask for the master password and call `finish_setup`.
|
|
pub setup: Option<SetupView>,
|
|
}
|
|
|
|
#[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<dyn Fn(&SyncStatus) + Send + Sync>;
|
|
|
|
pub struct SyncService {
|
|
core: Arc<VaultCore>,
|
|
data_dir: PathBuf,
|
|
key: Result<Arc<DeviceKey>, String>,
|
|
status: Mutex<SyncStatus>,
|
|
/// From "Use my phone", the Emergency Kit or a pasted code, until `finish_setup` succeeds.
|
|
pending_setup: Mutex<Option<SetupInfo>>,
|
|
/// The "Add a device" ticket on screen, revoked on cancel and on lock.
|
|
add_device_ticket: Mutex<Option<String>>,
|
|
wake: tokio::sync::Notify,
|
|
notify: Notify,
|
|
}
|
|
|
|
fn lock<T>(m: &Mutex<T>) -> 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<String> {
|
|
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<VaultCore>, data_dir: &Path, key: Result<DeviceKey, String>, notify: Notify) -> Arc<Self> {
|
|
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<dyn kv_ffi::DeviceSigner> = 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<VaultCore> {
|
|
&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<Zeroizing<String>> {
|
|
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<String>) -> 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<String>,
|
|
setup_code: &str,
|
|
master_password: Zeroizing<String>,
|
|
) -> Result<Zeroizing<String>, 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<bool, String> {
|
|
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<Self>) {
|
|
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<SyncStatus, String> {
|
|
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<Vec<DeviceView>, 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<JoinView, String> {
|
|
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<JoinPollView, String> {
|
|
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<SetupView, String> {
|
|
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<SetupView, String> {
|
|
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<String>) -> Result<SignIn, String> {
|
|
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<AddDeviceView, String> {
|
|
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<JoinConfirmView, String> {
|
|
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<String, String> {
|
|
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());
|
|
}
|
|
}
|