diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index adc3c91..5eb9938 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3016,9 +3016,11 @@ version = "1.1.0" dependencies = [ "arboard", "base64 0.22.1", + "chacha20poly1305", "core-foundation", "getrandom 0.2.17", "hayro", + "hkdf 0.12.4", "image", "keyring", "kv-ffi", @@ -3037,6 +3039,7 @@ dependencies = [ "tokio", "url", "uuid", + "windows", "windows-sys 0.61.2", "zeroize", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2f8b2c6..703ec2f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,9 @@ rqrr = { version = "0.11", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } hayro = { version = "0.8", default-features = false } tokio = { version = "1", features = ["time", "sync", "macros"] } +# Windows Hello unlock (docs/02 "Local unlock"): a key derived from Hello's signature seals the LUK. +hkdf = "0.12" +chacha20poly1305 = "0.10" [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -68,7 +71,10 @@ windows-sys = { version = "0.61", features = [ "Win32_System_StationsAndDesktops", "Win32_System_SystemInformation", "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", ] } +# Windows Hello (KeyCredentialManager); the same version Tauri already pulls in. +windows = { version = "0.62", features = ["Foundation", "Security_Credentials", "Security_Cryptography", "Storage_Streams"] } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10" diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 47a5ab6..80bd1a3 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 44fbe11..97cba8b 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 8657b84..09d4297 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 007cf88..15288a4 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 5f0026b..d71c023 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 1ae5227..e93b9a0 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 977ef45..559ecf0 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 3ef9581..49a12f8 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index cf5fd6e..82ad1ed 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 32df927..2406f9a 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 2f6b220..258718c 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 759f220..a6890d0 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index a5866c3..023e000 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 68d2a01..aeebf3a 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 3e4d66d..96291e8 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 32227f5..d10abd4 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/kestrove-mark.svg b/src-tauri/icons/kestrove-mark.svg new file mode 100644 index 0000000..4a6a8d4 --- /dev/null +++ b/src-tauri/icons/kestrove-mark.svg @@ -0,0 +1 @@ + diff --git a/src-tauri/src/hello.rs b/src-tauri/src/hello.rs new file mode 100644 index 0000000..482dfa3 --- /dev/null +++ b/src-tauri/src/hello.rs @@ -0,0 +1,442 @@ +//! Unlocking with Windows Hello (docs/02 "Local unlock"): face, fingerprint or PIN instead of the master password. +//! +//! Turning it on creates a Windows Hello key for this vault (`KeyCredentialManager`, kept in the TPM) and has it +//! sign a challenge: a fixed label and a random salt. Windows Hello signs with RSA PKCS#1 v1.5, which is +//! deterministic, so the same challenge always gives the same signature. HKDF turns that signature into the key +//! that seals the core's Local Unlock Key (LUK) with XChaCha20-Poly1305. `windows-hello.json` holds the salt and +//! the sealed LUK; without a signature from Windows Hello they open nothing. +//! +//! * The master password is still required every [`PASSWORD_EVERY`]. When it is due, Windows Hello is not offered, +//! but the LUK is kept and works again right after the password. +//! * The LUK is deleted only when it is really gone or wrong: Windows Hello's key no longer exists (Hello or the +//! PIN was reset) or its signature does not open the LUK. A cancelled prompt just falls back to the password. + +use std::fmt; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; +use hkdf::Hkdf; +use kv_ffi::{CoreError, VaultCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::private_file; + +/// How often the master password is still asked for. +pub const PASSWORD_EVERY: Duration = Duration::from_secs(14 * 24 * 60 * 60); + +const FILE: &str = "windows-hello.json"; +const CHALLENGE_LABEL: &[u8] = b"kestrove/v1/windows-hello/challenge"; +const WRAP_INFO: &[u8] = b"kestrove/v1/windows-hello/luk-wrap"; +const AAD_LABEL: &[u8] = b"kestrove/v1/windows-hello/luk"; + +#[derive(Debug, PartialEq, Eq)] +pub enum HelloError { + /// Windows Hello isn't set up on this computer (or this isn't Windows). + Unavailable, + /// The user closed the prompt or chose another way. + Cancelled, + /// Windows Hello's key for this vault is gone or doesn't open it; it has been turned off. + Reset, + Other(String), +} + +impl fmt::Display for HelloError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HelloError::Unavailable => { + f.write_str("Windows Hello isn't set up on this computer. Set it up in Windows Settings → Accounts.") + } + HelloError::Cancelled => f.write_str("Windows Hello was cancelled."), + HelloError::Reset => f.write_str( + "Windows Hello was reset on this computer, so Kestrove turned it off. Unlock with your master \ + password, then turn it on again in Settings.", + ), + HelloError::Other(m) => f.write_str(m), + } + } +} + +impl From for HelloError { + fn from(e: CoreError) -> Self { + HelloError::Other(e.to_string()) + } +} + +/// `windows-hello.json`. +#[derive(Serialize, Deserialize)] +struct Record { + v: u32, + /// The Windows Hello key's name. + credential: String, + salt: String, + nonce: String, + ct: String, + /// The last master-password unlock (Unix seconds). + password_at: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Status { + /// Windows Hello can be turned on here. + pub available: bool, + /// It is on for this vault. + pub enabled: bool, + /// The unlock screen offers it now (on, and the master password isn't due). + pub offered: bool, +} + +pub fn is_supported() -> bool { + platform::is_supported() +} + +pub fn status(core: &VaultCore, data_dir: &Path, available: bool) -> Status { + let rec = load(data_dir); + let enabled = rec.is_some() && core.has_local_unlock(); + let offered = enabled && rec.is_some_and(|r| !password_due(r.password_at, now())); + Status { available, enabled, offered } +} + +/// Turns Windows Hello on for the unlocked vault: one Windows Hello prompt to create the key and sign. +pub fn enable(core: &VaultCore, data_dir: &Path) -> Result<(), HelloError> { + if core.is_locked() { + return Err(CoreError::Locked.into()); + } + let account_id = core.account_id()?; + let name = credential_name(data_dir, &account_id); + let salt: [u8; 32] = random(); + let sig = platform::create_and_sign(&name, &challenge(&salt))?; + let luk = Zeroizing::new(core.enable_local_unlock()?); + let saved = seal(&sig, &salt, &luk, &account_id).and_then(|(nonce, ct)| { + save( + data_dir, + &Record { + v: 1, + credential: name.clone(), + salt: B64.encode(salt), + nonce: B64.encode(nonce), + ct: B64.encode(ct), + password_at: now(), + }, + ) + }); + if let Err(e) = saved { + let _ = core.disable_local_unlock(); + platform::delete(&name); + return Err(e); + } + Ok(()) +} + +/// Unlocks with Windows Hello. `Cancelled` leaves everything as it was; `Reset` has turned it off. +pub fn unlock(core: &VaultCore, data_dir: &Path) -> Result<(), HelloError> { + let rec = load(data_dir).ok_or(HelloError::Unavailable)?; + if !core.has_local_unlock() { + forget(core, data_dir, &rec); + return Err(HelloError::Reset); + } + if password_due(rec.password_at, now()) { + return Err(HelloError::Other("Kestrove needs your master password this time.".into())); + } + let account_id = core.account_id()?; + let (Ok(salt), Ok(nonce), Ok(ct)) = (B64.decode(&rec.salt), B64.decode(&rec.nonce), B64.decode(&rec.ct)) else { + forget(core, data_dir, &rec); + return Err(HelloError::Reset); + }; + let sig = match platform::sign(&rec.credential, &challenge(&salt)) { + Err(HelloError::Reset) => { + forget(core, data_dir, &rec); + return Err(HelloError::Reset); + } + r => r?, + }; + let Some(luk) = open(&sig, &salt, &nonce, &ct, &account_id) else { + forget(core, data_dir, &rec); + return Err(HelloError::Reset); + }; + match core.unlock_with_local_key(luk.to_vec()) { + Ok(()) => Ok(()), + // The LUK doesn't open the keyset (the core reports a failed decrypt as bad credentials). + Err(CoreError::BadCredentials) => { + forget(core, data_dir, &rec); + Err(HelloError::Reset) + } + Err(e) => Err(e.into()), + } +} + +/// Turns Windows Hello off: its key, the sealed LUK and the core's LUK wrap. +pub fn disable(core: &VaultCore, data_dir: &Path) -> Result<(), HelloError> { + if let Some(rec) = load(data_dir) { + platform::delete(&rec.credential); + } + let _ = std::fs::remove_file(path(data_dir)); + if core.has_local_unlock() { + core.disable_local_unlock()?; + } + Ok(()) +} + +/// After a master-password unlock: restarts the clock for the next one. +pub fn password_unlocked(data_dir: &Path) { + if let Some(mut rec) = load(data_dir) { + rec.password_at = now(); + let _ = save(data_dir, &rec); + } +} + +fn forget(core: &VaultCore, data_dir: &Path, rec: &Record) { + platform::delete(&rec.credential); + let _ = std::fs::remove_file(path(data_dir)); + let _ = core.disable_local_unlock(); +} + +fn path(data_dir: &Path) -> PathBuf { + data_dir.join(FILE) +} + +fn load(data_dir: &Path) -> Option { + let bytes = private_file::read(&path(data_dir)).ok().flatten()?; + serde_json::from_slice::(&bytes).ok().filter(|r| r.v == 1) +} + +fn save(data_dir: &Path, rec: &Record) -> Result<(), HelloError> { + let bytes = serde_json::to_vec(rec).map_err(|e| HelloError::Other(e.to_string()))?; + private_file::write(&path(data_dir), &bytes).map_err(|e| HelloError::Other(e.to_string())) +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) +} + +/// Due after [`PASSWORD_EVERY`], and when the clock went backwards. +fn password_due(password_at: u64, now: u64) -> bool { + now < password_at || now - password_at >= PASSWORD_EVERY.as_secs() +} + +fn random() -> [u8; N] { + let mut b = [0u8; N]; + getrandom::getrandom(&mut b).expect("OS random number generator"); + b +} + +/// One Windows Hello key per vault folder (a second instance with `KESTROVE_DATA_DIR` gets its own). +fn credential_name(data_dir: &Path, account_id: &str) -> String { + let folder = Sha256::digest(data_dir.to_string_lossy().as_bytes()); + let short: String = folder[..4].iter().map(|b| format!("{b:02x}")).collect(); + format!("Kestrove {account_id} {short}") +} + +fn challenge(salt: &[u8]) -> Vec { + [CHALLENGE_LABEL, salt].concat() +} + +fn wrap_key(signature: &[u8], salt: &[u8]) -> Zeroizing<[u8; 32]> { + let mut out = Zeroizing::new([0u8; 32]); + Hkdf::::new(Some(salt), signature).expand(WRAP_INFO, out.as_mut()).expect("32 bytes is a valid length"); + out +} + +fn aad(account_id: &str) -> Vec { + [AAD_LABEL, account_id.as_bytes()].concat() +} + +fn seal(signature: &[u8], salt: &[u8], luk: &[u8], account_id: &str) -> Result<([u8; 24], Vec), HelloError> { + let key = wrap_key(signature, salt); + let nonce: [u8; 24] = random(); + let ct = XChaCha20Poly1305::new(Key::from_slice(key.as_ref())) + .encrypt(XNonce::from_slice(&nonce), Payload { msg: luk, aad: &aad(account_id) }) + .map_err(|_| HelloError::Other("Couldn't seal the unlock key.".into()))?; + Ok((nonce, ct)) +} + +fn open(signature: &[u8], salt: &[u8], nonce: &[u8], ct: &[u8], account_id: &str) -> Option>> { + if nonce.len() != 24 { + return None; + } + let key = wrap_key(signature, salt); + XChaCha20Poly1305::new(Key::from_slice(key.as_ref())) + .decrypt(XNonce::from_slice(nonce), Payload { msg: ct, aad: &aad(account_id) }) + .ok() + .map(Zeroizing::new) +} + +#[cfg(windows)] +mod platform { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + use windows::core::{Array, HSTRING}; + use windows::Security::Credentials::{ + KeyCredential, KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, + }; + use windows::Security::Cryptography::CryptographicBuffer; + use windows_sys::Win32::UI::WindowsAndMessaging::{FindWindowW, SetForegroundWindow}; + use zeroize::Zeroizing; + + use super::HelloError; + + fn other(e: windows::core::Error) -> HelloError { + HelloError::Other(format!("Windows Hello failed: {}", e.message())) + } + + pub fn is_supported() -> bool { + KeyCredentialManager::IsSupportedAsync().and_then(|op| op.join()).unwrap_or(false) + } + + /// Creates (or replaces) the key and signs `challenge` with it. + pub fn create_and_sign(name: &str, challenge: &[u8]) -> Result>, HelloError> { + if !is_supported() { + return Err(HelloError::Unavailable); + } + let _focus = raise_prompt(); + let r = KeyCredentialManager::RequestCreateAsync( + &HSTRING::from(name), + KeyCredentialCreationOption::ReplaceExisting, + ) + .and_then(|op| op.join()) + .map_err(other)?; + check(r.Status().map_err(other)?)?; + sign_with(&r.Credential().map_err(other)?, challenge) + } + + pub fn sign(name: &str, challenge: &[u8]) -> Result>, HelloError> { + if !is_supported() { + return Err(HelloError::Unavailable); + } + let r = KeyCredentialManager::OpenAsync(&HSTRING::from(name)).and_then(|op| op.join()).map_err(other)?; + check(r.Status().map_err(other)?)?; + let _focus = raise_prompt(); + sign_with(&r.Credential().map_err(other)?, challenge) + } + + pub fn delete(name: &str) { + let _ = KeyCredentialManager::DeleteAsync(&HSTRING::from(name)).and_then(|op| op.join()); + } + + fn sign_with(cred: &KeyCredential, challenge: &[u8]) -> Result>, HelloError> { + let data = CryptographicBuffer::CreateFromByteArray(challenge).map_err(other)?; + let r = cred.RequestSignAsync(&data).and_then(|op| op.join()).map_err(other)?; + check(r.Status().map_err(other)?)?; + let mut out = Array::::new(); + CryptographicBuffer::CopyToByteArray(&r.Result().map_err(other)?, &mut out).map_err(other)?; + let sig = Zeroizing::new(out.to_vec()); + out.fill(0); + Ok(sig) + } + + fn check(status: KeyCredentialStatus) -> Result<(), HelloError> { + match status { + KeyCredentialStatus::Success => Ok(()), + KeyCredentialStatus::UserCanceled | KeyCredentialStatus::UserPrefersPassword => Err(HelloError::Cancelled), + KeyCredentialStatus::NotFound => Err(HelloError::Reset), + KeyCredentialStatus::SecurityDeviceLocked => { + Err(HelloError::Other("Windows Hello is locked after too many tries. Use your master password.".into())) + } + _ => Err(HelloError::Other("Windows Hello couldn't confirm it's you. Try again.".into())), + } + } + + /// Stops raising prompts when dropped. + struct Focus(Arc); + + impl Drop for Focus { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } + } + + /// Windows Hello's prompt for a desktop app can open behind the app's window: bring each new one to the front. + fn raise_prompt() -> Focus { + let done = Arc::new(AtomicBool::new(false)); + let stop = done.clone(); + let _ = std::thread::Builder::new().name("hello-focus".into()).spawn(move || { + let class: Vec = "Credential Dialog Xaml Host\0".encode_utf16().collect(); + let mut raised = std::ptr::null_mut(); + for _ in 0..600 { + if stop.load(Ordering::Relaxed) { + return; + } + // SAFETY: a NUL-terminated class name and no window title. + let hwnd = unsafe { FindWindowW(class.as_ptr(), std::ptr::null()) }; + if !hwnd.is_null() && hwnd != raised { + // SAFETY: a window handle FindWindowW just returned; a stale one only makes the call fail. + unsafe { SetForegroundWindow(hwnd) }; + raised = hwnd; + } + std::thread::sleep(Duration::from_millis(100)); + } + }); + Focus(done) + } +} + +#[cfg(not(windows))] +mod platform { + use zeroize::Zeroizing; + + use super::HelloError; + + pub fn is_supported() -> bool { + false + } + + pub fn create_and_sign(_name: &str, _challenge: &[u8]) -> Result>, HelloError> { + Err(HelloError::Unavailable) + } + + pub fn sign(_name: &str, _challenge: &[u8]) -> Result>, HelloError> { + Err(HelloError::Unavailable) + } + + pub fn delete(_name: &str) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + const ACCOUNT: &str = "6f1c2c1e-6c0a-4b8e-9d55-0d6c3c1b2a10"; + + #[test] + fn seal_open_round_trip() { + let (sig, salt, luk) = ([7u8; 256], [1u8; 32], [9u8; 32]); + let (nonce, ct) = seal(&sig, &salt, &luk, ACCOUNT).unwrap(); + assert_eq!(open(&sig, &salt, &nonce, &ct, ACCOUNT).unwrap().as_slice(), &luk); + } + + #[test] + fn wrong_signature_salt_or_account_does_not_open() { + let (sig, salt, luk) = ([7u8; 256], [1u8; 32], [9u8; 32]); + let (nonce, ct) = seal(&sig, &salt, &luk, ACCOUNT).unwrap(); + let mut other_sig = sig; + other_sig[0] ^= 1; + assert!(open(&other_sig, &salt, &nonce, &ct, ACCOUNT).is_none()); + assert!(open(&sig, &[2u8; 32], &nonce, &ct, ACCOUNT).is_none()); + assert!(open(&sig, &salt, &nonce, &ct, "another-account").is_none()); + assert!(open(&sig, &salt, &nonce[..12], &ct, ACCOUNT).is_none()); + } + + #[test] + fn password_is_due_every_14_days_and_when_the_clock_goes_back() { + let t = 1_800_000_000; + assert!(!password_due(t, t)); + assert!(!password_due(t, t + PASSWORD_EVERY.as_secs() - 1)); + assert!(password_due(t, t + PASSWORD_EVERY.as_secs())); + assert!(password_due(t, t - 1)); + } + + #[test] + fn challenge_and_name_depend_on_salt_and_folder() { + assert_ne!(challenge(&[1; 32]), challenge(&[2; 32])); + let a = credential_name(Path::new("C:/a"), ACCOUNT); + assert_eq!(a, credential_name(Path::new("C:/a"), ACCOUNT)); + assert_ne!(a, credential_name(Path::new("C:/b"), ACCOUNT)); + assert!(a.starts_with("Kestrove ")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 73ae455..1fd74b9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ pub mod autolock; pub mod device_key; +pub mod hello; pub mod qr; pub mod sync; pub mod title_bar; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 80c1148..5a1bcfa 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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>, idle_minutes: Arc, + /// Windows Hello is set up on this computer (checked off the main thread; see `refresh_hello`). + hello_available: Arc, // Kept alive: on X11 the clipboard owner must outlive the copy. clipboard: Mutex>, } @@ -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, 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(f: impl FnOnce() -> T + Send + 'static) -> CmdResult { + 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 { + 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 { + 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> { data_dir, ipc_error: Mutex::new(ipc_error), idle_minutes, + hello_available: Arc::new(AtomicBool::new(false)), clipboard: Mutex::new(None), }); + app.state::().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, diff --git a/src/main.ts b/src/main.ts index 040609d..717c331 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,7 +8,8 @@ import { pill, ring, secret, tile, type IconName, } from "./ui/kv"; -interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number } +interface HelloStatus { available: boolean; enabled: boolean; offered: boolean } +interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number; hello: HelloStatus } interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean; tags: string[] } interface SignInView { provider: string; provider_name: string; account: string | null } interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null; tags: string[]; sign_in_with: SignInView | null } @@ -474,7 +475,22 @@ function renderUnlock(state: AppState) { const pw = input({ type: "password", autocomplete: "current-password" }); const sk = input({ placeholder: "K1-XXXXXX-XXXXX-…", spellcheck: false }, true); const err = errBox(); - const btn = button("Unlock", "primary", undefined, "block"); + // Windows Hello, when offered, is the screen's one primary action; the password stays below it. + const btn = button("Unlock", state.hello.offered ? "quiet" : "primary", undefined, "block"); + const hello = button("Unlock with Windows Hello", "primary", undefined, "block"); + hello.onclick = async () => { + hello.disabled = true; + err.replaceChildren(); + try { + if (await invoke("hello_unlock")) await render(); + else hello.disabled = false; + } catch (e) { + setErr(err, e); + // A reset turned Windows Hello off: drop the button. + if (!(await invoke("state")).hello.offered) hello.remove(); + else hello.disabled = false; + } + }; const submit = async () => { btn.disabled = true; err.replaceChildren(); @@ -494,11 +510,15 @@ function renderUnlock(state: AppState) { pw.onkeydown = (e) => { if (e.key === "Enter") void submit(); }; - const card = h("div", "kv-card auth-card", h("div", "unlock-mark", lockup(32)), h("h1", "kv-title-1", "Unlock your vault"), field("Master password", pw)); + const card = h("div", "kv-card auth-card", h("div", "unlock-mark", lockup(32)), h("h1", "kv-title-1", "Unlock your vault")); + if (state.hello.offered) card.append(hello, p("kv-small kv-muted", "Or use your master password:")); + else if (state.hello.enabled) card.append(p("kv-small kv-muted", "Kestrove asks for your master password every 14 days, even with Windows Hello on.")); + card.append(field("Master password", pw)); if (!state.remembers_secret_key) card.append(field("Secret Key", sk, "It's on your Emergency Kit. Dashes are optional.")); card.append(err, btn); centered(card); - pw.focus(); + if (state.hello.offered) hello.focus(); + else pw.focus(); } // ------------------------------------------------------------------ vault @@ -854,6 +874,25 @@ async function renderSettings(target: HTMLElement, state: AppState) { else await publishTheme(); await renderSettings(target, state); }; + const helloSwitch = input({ type: "checkbox", checked: state.hello.enabled }); + helloSwitch.className = "switch"; + helloSwitch.setAttribute("aria-label", "Unlock with Windows Hello"); + const helloErr = errBox(); + helloSwitch.onchange = async () => { + helloSwitch.disabled = true; + helloErr.replaceChildren(); + try { + if (!(await invoke("set_hello", { enabled: helloSwitch.checked }))) helloSwitch.checked = !helloSwitch.checked; + } catch (e) { + helloSwitch.checked = !helloSwitch.checked; + setErr(helloErr, e); + } + helloSwitch.disabled = false; + }; + const helloRow = state.hello.available || state.hello.enabled + ? h("div", "kv-frow", h("div", "val", h("span", "", "Unlock with Windows Hello"), + h("span", "kv-hint", "Your face, fingerprint or PIN instead of your master password. Kestrove still asks for the password every 14 days."), helloErr), helloSwitch) + : null; const list = h("div", "kv-card rows"); for (const pr of pairings) { list.append(h("div", "kv-frow", @@ -875,7 +914,8 @@ async function renderSettings(target: HTMLElement, state: AppState) { h("div", "kv-frow", h("div", "val", h("span", "", "Light or dark")), theme), h("div", "kv-frow", h("div", "val", h("span", "", "Same theme on all my devices"), h("span", "kv-hint", "Saved in your encrypted vault, and used on every device where this is on.")), syncTheme), - h("div", "kv-frow", h("div", "val", h("span", "", "Lock after idle for"), h("span", "kv-hint", "Kestrove also locks on screen lock and sleep.")), idle))), + h("div", "kv-frow", h("div", "val", h("span", "", "Lock after idle for"), h("span", "kv-hint", "Kestrove also locks on screen lock and sleep.")), idle), + helloRow)), section("Sync", syncSection), section("Devices", devicesSection), section("Emergency Kit", kitSection()),