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
+200 -33
View File
@@ -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");