Official app icon and Windows Hello unlock (beta)
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

Icons: the taskbar, title bar, tray and installer icons are now the Kestrove
mark (kestrel wing and keyhole on the Spruce tile) instead of the placeholder
keyhole. Generated with `tauri icon` from icons/kestrove-mark.svg, the brand
mark with the eye in the `accent` token (#b4481f), as the brand book asks.

Windows Hello (docs/02 "Local unlock"): Settings > General > "Unlock with
Windows Hello". Turning it on creates a TPM-backed KeyCredentialManager key
for this vault, which signs a fixed label plus a random salt; HKDF of the
deterministic RSA signature seals the core's Local Unlock Key with
XChaCha20-Poly1305 in windows-hello.json. The unlock screen then offers
"Unlock with Windows Hello" above the master password.

- The master password is still required every 14 days; Hello is not offered
  while it is due, and the LUK is kept.
- A cancelled prompt falls back to the password. The LUK is deleted only when
  Hello's key is gone or doesn't open it.
- Hello calls block, so they run on a blocking thread; the prompt is brought
  in front of the window.
- Not offered on Linux or where Windows Hello isn't set up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-30 00:14:32 +04:00
co-authored by Claude Opus 5.5
parent 10a9507aa2
commit 27f13b4743
23 changed files with 552 additions and 7 deletions
+54 -2
View File
@@ -3,15 +3,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::{io, thread};
use kestrove_desktop::autolock::{self, Activity, Reason};
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
use kestrove_desktop::passkey::PasskeyView;
use kestrove_desktop::device_key::DeviceKey;
use kestrove_desktop::hello::{self, HelloError};
use kestrove_desktop::passkey::PasskeyView;
use kestrove_desktop::sync::{
self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService,
SyncStatus,
@@ -34,6 +35,8 @@ struct App {
data_dir: PathBuf,
ipc_error: Mutex<Option<String>>,
idle_minutes: Arc<AtomicU64>,
/// Windows Hello is set up on this computer (checked off the main thread; see `refresh_hello`).
hello_available: Arc<AtomicBool>,
// Kept alive: on X11 the clipboard owner must outlive the copy.
clipboard: Mutex<Option<arboard::Clipboard>>,
}
@@ -42,6 +45,14 @@ impl App {
fn settings_path(&self) -> PathBuf {
self.data_dir.join("settings.json")
}
/// Re-checks Windows Hello in the background (it can be set up or removed while Kestrove runs).
fn refresh_hello(&self) {
let available = self.hello_available.clone();
let _ = thread::Builder::new().name("hello-check".into()).spawn(move || {
available.store(hello::is_supported(), Ordering::Relaxed);
});
}
}
#[derive(Serialize, Deserialize)]
@@ -58,6 +69,8 @@ struct AppState {
/// Browser integration could not start (e.g. another instance is running).
ipc_error: Option<String>,
idle_minutes: u64,
/// Unlock with Windows Hello: can be turned on, is on, is offered on the unlock screen now.
hello: hello::Status,
}
#[derive(Serialize)]
@@ -179,6 +192,7 @@ fn state(app: State<'_, App>) -> AppState {
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),
hello: hello::status(&app.core, &app.data_dir, app.hello_available.load(Ordering::Relaxed)),
}
}
@@ -210,11 +224,45 @@ async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option
if let Some(sk) = typed {
app.sync.remember_secret_key(&sk);
}
hello::password_unlocked(&app.data_dir);
app.activity.touch();
app.sync.request_sync();
Ok(())
}
/// Windows Hello calls block until the prompt closes, so they run off the main thread and the async runtime.
async fn off_thread<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> CmdResult<T> {
tauri::async_runtime::spawn_blocking(f).await.map_err(|e| e.to_string())
}
/// Unlock with Windows Hello. `false` when the prompt was cancelled (the password field stays).
#[tauri::command]
async fn hello_unlock(app: State<'_, App>) -> CmdResult<bool> {
let (core, dir) = (app.core.clone(), app.data_dir.clone());
match off_thread(move || hello::unlock(&core, &dir)).await? {
Ok(()) => {
app.activity.touch();
app.sync.request_sync();
Ok(true)
}
Err(HelloError::Cancelled) => Ok(false),
Err(e) => Err(e.to_string()),
}
}
/// Settings → "Unlock with Windows Hello". `false` when turning it on was cancelled.
#[tauri::command]
async fn set_hello(app: State<'_, App>, enabled: bool) -> CmdResult<bool> {
let (core, dir) = (app.core.clone(), app.data_dir.clone());
let r = off_thread(move || if enabled { hello::enable(&core, &dir) } else { hello::disable(&core, &dir) }).await?;
app.refresh_hello();
match r {
Ok(()) => Ok(true),
Err(HelloError::Cancelled) => Ok(false),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
async fn lock(app: State<'_, App>) -> CmdResult<()> {
app.core.lock();
@@ -713,8 +761,10 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
data_dir,
ipc_error: Mutex::new(ipc_error),
idle_minutes,
hello_available: Arc::new(AtomicBool::new(false)),
clipboard: Mutex::new(None),
});
app.state::<App>().refresh_hello();
Ok(())
}
@@ -733,6 +783,8 @@ fn main() {
set_title_bar_colors,
create_account,
unlock,
hello_unlock,
set_hello,
lock,
touch,
set_idle_minutes,