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:
+200
-33
@@ -10,6 +10,11 @@ use std::{io, thread};
|
||||
|
||||
use kestrove_desktop::autolock::{self, Activity, Reason};
|
||||
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
||||
use kestrove_desktop::device_key::DeviceKey;
|
||||
use kestrove_desktop::sync::{
|
||||
self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService,
|
||||
SyncStatus,
|
||||
};
|
||||
use kestrove_desktop::{ipc, private_file};
|
||||
use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -22,6 +27,7 @@ const CLIPBOARD_CLEAR: Duration = Duration::from_secs(90);
|
||||
|
||||
struct App {
|
||||
core: Arc<VaultCore>,
|
||||
sync: Arc<SyncService>,
|
||||
broker: Arc<Broker>,
|
||||
activity: Arc<Activity>,
|
||||
data_dir: PathBuf,
|
||||
@@ -32,9 +38,6 @@ struct App {
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn secret_key_path(&self) -> PathBuf {
|
||||
self.data_dir.join("secret-key")
|
||||
}
|
||||
fn settings_path(&self) -> PathBuf {
|
||||
self.data_dir.join("settings.json")
|
||||
}
|
||||
@@ -137,36 +140,32 @@ impl Ui for TauriUi {
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_secret_key(app: &App, secret_key: &str) {
|
||||
if let Err(e) = private_file::write(&app.secret_key_path(), secret_key.trim().as_bytes()) {
|
||||
eprintln!("kestrove: could not store the Secret Key: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn stored_secret_key(app: &App) -> Option<Zeroizing<String>> {
|
||||
let b = private_file::read(&app.secret_key_path()).ok()??;
|
||||
String::from_utf8(b).ok().map(Zeroizing::new)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn state(app: State<'_, App>) -> AppState {
|
||||
AppState {
|
||||
has_account: app.core.has_account(),
|
||||
locked: app.core.is_locked(),
|
||||
remembers_secret_key: stored_secret_key(&app).is_some(),
|
||||
remembers_secret_key: app.sync.stored_secret_key().is_some(),
|
||||
ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||
idle_minutes: app.idle_minutes.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
// Argon2id takes ~0.5 s, so these run as async commands off the main thread.
|
||||
|
||||
/// "Create a new account": a new vault here, registered with a fresh server using its setup code.
|
||||
#[tauri::command]
|
||||
async fn create_account(app: State<'_, App>, master_password: String) -> CmdResult<String> {
|
||||
async fn create_account(
|
||||
app: State<'_, App>,
|
||||
server_url: String,
|
||||
pins: String,
|
||||
setup_code: String,
|
||||
master_password: String,
|
||||
) -> CmdResult<String> {
|
||||
let master_password = Zeroizing::new(master_password);
|
||||
let sk = app.core.create_account(master_password.to_string()).map_err(err)?;
|
||||
remember_secret_key(&app, &sk);
|
||||
let sk = app.sync.create_account(&server_url, sync::parse_pins(&pins), &setup_code, master_password).await;
|
||||
app.activity.touch();
|
||||
Ok(sk)
|
||||
sk.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// `secret_key` may be omitted once this device remembers it.
|
||||
@@ -174,18 +173,21 @@ async fn create_account(app: State<'_, App>, master_password: String) -> CmdResu
|
||||
async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option<String>) -> CmdResult<()> {
|
||||
let master_password = Zeroizing::new(master_password);
|
||||
let typed = secret_key.map(Zeroizing::new).filter(|s| !s.trim().is_empty());
|
||||
let sk = typed.clone().or_else(|| stored_secret_key(&app)).ok_or("Enter your Secret Key.")?;
|
||||
let sk = typed.clone().or_else(|| app.sync.stored_secret_key()).ok_or("Enter your Secret Key.")?;
|
||||
app.core.unlock(master_password.to_string(), sk.trim().to_string()).map_err(err)?;
|
||||
if let Some(sk) = typed {
|
||||
remember_secret_key(&app, &sk);
|
||||
app.sync.remember_secret_key(&sk);
|
||||
}
|
||||
app.activity.touch();
|
||||
app.sync.request_sync();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lock(app: State<'_, App>) {
|
||||
async fn lock(app: State<'_, App>) -> CmdResult<()> {
|
||||
app.core.lock();
|
||||
app.sync.on_lock().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// UI input (throttled in JS): feeds the idle timer where the OS has no system idle time.
|
||||
@@ -251,11 +253,14 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
|
||||
modified_at: 0,
|
||||
})
|
||||
.map_err(err)
|
||||
.inspect(|_| app.sync.request_sync())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_item(app: State<'_, App>, id: String) -> CmdResult<()> {
|
||||
app.core.delete_item(id).map_err(err)
|
||||
app.core.delete_item(id).map_err(err)?;
|
||||
app.sync.request_sync();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -341,7 +346,120 @@ fn pending_saves(app: State<'_, App>) -> Vec<SaveView> {
|
||||
|
||||
#[tauri::command]
|
||||
fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> {
|
||||
app.broker.resolve_save(&id, accept).map(|_| ())
|
||||
if app.broker.resolve_save(&id, accept)?.is_some() {
|
||||
app.sync.request_sync();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sync server (docs/03)
|
||||
|
||||
#[tauri::command]
|
||||
fn sync_status(app: State<'_, App>) -> SyncStatus {
|
||||
app.sync.status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn sync_now(app: State<'_, App>) -> CmdResult<SyncStatus> {
|
||||
app.sync.sync_now().await
|
||||
}
|
||||
|
||||
/// Settings: server URL and optional pins for a vault created before sync (or to change them).
|
||||
#[tauri::command]
|
||||
fn configure_server(app: State<'_, App>, server_url: String, pins: String) -> CmdResult<()> {
|
||||
app.sync.configure(&server_url, sync::parse_pins(&pins))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn server_setup_open(app: State<'_, App>) -> CmdResult<bool> {
|
||||
app.sync.setup_open().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn register_server(app: State<'_, App>, setup_code: String) -> CmdResult<()> {
|
||||
app.sync.register(&setup_code).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_devices(app: State<'_, App>) -> CmdResult<Vec<DeviceView>> {
|
||||
app.sync.devices().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn approve_device(app: State<'_, App>, device_id: String) -> CmdResult<()> {
|
||||
app.sync.approve_device(&device_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn revoke_device(app: State<'_, App>, device_id: String) -> CmdResult<()> {
|
||||
app.sync.revoke_device(&device_id).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- onboarding (docs/10)
|
||||
|
||||
#[tauri::command]
|
||||
async fn join_start(app: State<'_, App>, server_url: String) -> CmdResult<JoinView> {
|
||||
app.sync.join_start(&server_url).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn join_poll(app: State<'_, App>) -> CmdResult<JoinPollView> {
|
||||
app.sync.join_poll().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn join_cancel(app: State<'_, App>) {
|
||||
app.sync.join_cancel();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn setup_from_text(app: State<'_, App>, text: String) -> CmdResult<SetupView> {
|
||||
app.sync.setup_from_text(&Zeroizing::new(text))
|
||||
}
|
||||
|
||||
/// "Open Emergency Kit…": the file's bytes (raw IPC body), read by the web view's file picker.
|
||||
#[tauri::command]
|
||||
async fn setup_from_file(app: State<'_, App>, request: tauri::ipc::Request<'_>) -> CmdResult<SetupView> {
|
||||
let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { return Err("Expected the file's bytes.".into()) };
|
||||
app.sync.setup_from_file(bytes)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_setup(app: State<'_, App>) {
|
||||
app.sync.clear_setup();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn finish_setup(app: State<'_, App>, master_password: String) -> CmdResult<SignIn> {
|
||||
let r = app.sync.finish_setup(Zeroizing::new(master_password)).await;
|
||||
app.activity.touch();
|
||||
r
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_device(app: State<'_, App>) -> CmdResult<AddDeviceView> {
|
||||
app.sync.add_device().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cancel_add_device(app: State<'_, App>) -> CmdResult<()> {
|
||||
app.sync.cancel_add_device().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn approve_join(app: State<'_, App>, join_code: String) -> CmdResult<JoinConfirmView> {
|
||||
app.sync.approve_join(&join_code).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_join(app: State<'_, App>) -> CmdResult<()> {
|
||||
app.sync.send_join().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn kit_qr(app: State<'_, App>) -> CmdResult<String> {
|
||||
app.sync.kit_qr()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- startup
|
||||
@@ -365,13 +483,19 @@ fn start_ipc(broker: Arc<Broker>) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lock_and_notify(handle: &AppHandle, core: &VaultCore, reason: &str) {
|
||||
core.lock();
|
||||
fn lock_and_notify(handle: &AppHandle, sync: &Arc<SyncService>, reason: &str) {
|
||||
sync.core().lock();
|
||||
let sync = sync.clone();
|
||||
tauri::async_runtime::spawn(async move { sync.on_lock().await });
|
||||
let _ = handle.emit("locked", reason);
|
||||
}
|
||||
|
||||
fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let data_dir = app.path().app_data_dir()?;
|
||||
// KESTROVE_DATA_DIR runs a second instance with its own vault (development and the two-device tests).
|
||||
let data_dir = match std::env::var_os("KESTROVE_DATA_DIR") {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => app.path().app_data_dir()?,
|
||||
};
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -379,6 +503,23 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
let core = VaultCore::open(data_dir.join("vault.sqlite").to_string_lossy().into_owned())?;
|
||||
let key = if std::env::var_os("KESTROVE_DEVICE_KEY").is_some_and(|v| v == "file") {
|
||||
DeviceKey::load_or_create_file(&data_dir)
|
||||
} else {
|
||||
DeviceKey::load_or_create(&data_dir)
|
||||
};
|
||||
let sync = {
|
||||
let handle = app.handle().clone();
|
||||
SyncService::new(
|
||||
core.clone(),
|
||||
&data_dir,
|
||||
key.map_err(|e| e.to_string()),
|
||||
Box::new(move |s: &SyncStatus| {
|
||||
let _ = handle.emit("sync-status", s);
|
||||
}),
|
||||
)
|
||||
};
|
||||
tauri::async_runtime::spawn(sync.clone().run());
|
||||
let activity = Activity::new();
|
||||
let ui = Arc::new(TauriUi { handle: app.handle().clone(), activity: activity.clone() });
|
||||
let broker = Broker::new(core.clone(), ui, PairingStore::open(data_dir.join("pairings.json"))?);
|
||||
@@ -400,7 +541,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
|
||||
{
|
||||
let (handle, core, minutes) = (app.handle().clone(), core.clone(), idle_minutes.clone());
|
||||
let (handle, sync, minutes) = (app.handle().clone(), sync.clone(), idle_minutes.clone());
|
||||
let unlocked = core.clone();
|
||||
autolock::spawn(
|
||||
activity.clone(),
|
||||
@@ -412,7 +553,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Reason::ScreenLocked => "screen-locked",
|
||||
Reason::Sleep => "sleep",
|
||||
};
|
||||
lock_and_notify(&handle, &core, why);
|
||||
lock_and_notify(&handle, &sync, why);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -423,7 +564,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&open, &lock_item, &quit])?;
|
||||
let mut tray = TrayIconBuilder::with_id("main").menu(&menu).tooltip("Kestrove").on_menu_event({
|
||||
let core = core.clone();
|
||||
let sync = sync.clone();
|
||||
move |handle, event| match event.id().as_ref() {
|
||||
"open" => {
|
||||
if let Some(w) = handle.get_webview_window("main") {
|
||||
@@ -431,9 +572,14 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
"lock" => lock_and_notify(handle, &core, "manual"),
|
||||
"lock" => lock_and_notify(handle, &sync, "manual"),
|
||||
"quit" => {
|
||||
core.lock();
|
||||
sync.core().lock();
|
||||
// Revoke an "Add a device" ticket still on screen (bounded: never hang Quit on the network).
|
||||
let s = sync.clone();
|
||||
let _ = tauri::async_runtime::block_on(async move {
|
||||
tokio::time::timeout(Duration::from_secs(2), s.on_lock()).await
|
||||
});
|
||||
handle.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
@@ -446,6 +592,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
app.manage(App {
|
||||
core,
|
||||
sync,
|
||||
broker,
|
||||
activity,
|
||||
data_dir,
|
||||
@@ -485,7 +632,27 @@ fn main() {
|
||||
list_pairings,
|
||||
revoke_pairing,
|
||||
pending_saves,
|
||||
resolve_save
|
||||
resolve_save,
|
||||
sync_status,
|
||||
sync_now,
|
||||
configure_server,
|
||||
server_setup_open,
|
||||
register_server,
|
||||
list_devices,
|
||||
approve_device,
|
||||
revoke_device,
|
||||
join_start,
|
||||
join_poll,
|
||||
join_cancel,
|
||||
setup_from_text,
|
||||
setup_from_file,
|
||||
clear_setup,
|
||||
finish_setup,
|
||||
add_device,
|
||||
cancel_add_device,
|
||||
approve_join,
|
||||
send_join,
|
||||
kit_qr
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Kestrove");
|
||||
|
||||
Reference in New Issue
Block a user