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
+3
View File
@@ -3016,9 +3016,11 @@ version = "1.1.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"base64 0.22.1", "base64 0.22.1",
"chacha20poly1305",
"core-foundation", "core-foundation",
"getrandom 0.2.17", "getrandom 0.2.17",
"hayro", "hayro",
"hkdf 0.12.4",
"image", "image",
"keyring", "keyring",
"kv-ffi", "kv-ffi",
@@ -3037,6 +3039,7 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"uuid", "uuid",
"windows",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"zeroize", "zeroize",
] ]
+6
View File
@@ -46,6 +46,9 @@ rqrr = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
hayro = { version = "0.8", default-features = false } hayro = { version = "0.8", default-features = false }
tokio = { version = "1", features = ["time", "sync", "macros"] } 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] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
@@ -68,7 +71,10 @@ windows-sys = { version = "0.61", features = [
"Win32_System_StationsAndDesktops", "Win32_System_StationsAndDesktops",
"Win32_System_SystemInformation", "Win32_System_SystemInformation",
"Win32_UI_Input_KeyboardAndMouse", "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] [target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10" core-foundation = "0.10"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="#19604f"/><path fill="#fff" d="M14 34 C 18 22, 26 14, 38 11 C 33 16, 29 21, 27 27 L 34 37 L 27 37 L 22 30 Z"/><circle cx="18.5" cy="17.5" r="3.5" fill="#b4481f"/><rect x="16.5" y="21" width="4" height="13" rx="2" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 336 B

+442
View File
@@ -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<CoreError> 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<Record> {
let bytes = private_file::read(&path(data_dir)).ok().flatten()?;
serde_json::from_slice::<Record>(&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<const N: usize>() -> [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<u8> {
[CHALLENGE_LABEL, salt].concat()
}
fn wrap_key(signature: &[u8], salt: &[u8]) -> Zeroizing<[u8; 32]> {
let mut out = Zeroizing::new([0u8; 32]);
Hkdf::<Sha256>::new(Some(salt), signature).expand(WRAP_INFO, out.as_mut()).expect("32 bytes is a valid length");
out
}
fn aad(account_id: &str) -> Vec<u8> {
[AAD_LABEL, account_id.as_bytes()].concat()
}
fn seal(signature: &[u8], salt: &[u8], luk: &[u8], account_id: &str) -> Result<([u8; 24], Vec<u8>), 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<Zeroizing<Vec<u8>>> {
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<Zeroizing<Vec<u8>>, 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<Zeroizing<Vec<u8>>, 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<Zeroizing<Vec<u8>>, 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::<u8>::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<AtomicBool>);
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<u16> = "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<Zeroizing<Vec<u8>>, HelloError> {
Err(HelloError::Unavailable)
}
pub fn sign(_name: &str, _challenge: &[u8]) -> Result<Zeroizing<Vec<u8>>, 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 "));
}
}
+1
View File
@@ -5,6 +5,7 @@
pub mod autolock; pub mod autolock;
pub mod device_key; pub mod device_key;
pub mod hello;
pub mod qr; pub mod qr;
pub mod sync; pub mod sync;
pub mod title_bar; pub mod title_bar;
+54 -2
View File
@@ -3,15 +3,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use std::{io, thread}; use std::{io, thread};
use kestrove_desktop::autolock::{self, Activity, Reason}; use kestrove_desktop::autolock::{self, Activity, Reason};
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui}; use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
use kestrove_desktop::passkey::PasskeyView;
use kestrove_desktop::device_key::DeviceKey; use kestrove_desktop::device_key::DeviceKey;
use kestrove_desktop::hello::{self, HelloError};
use kestrove_desktop::passkey::PasskeyView;
use kestrove_desktop::sync::{ use kestrove_desktop::sync::{
self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService, self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService,
SyncStatus, SyncStatus,
@@ -34,6 +35,8 @@ struct App {
data_dir: PathBuf, data_dir: PathBuf,
ipc_error: Mutex<Option<String>>, ipc_error: Mutex<Option<String>>,
idle_minutes: Arc<AtomicU64>, 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. // Kept alive: on X11 the clipboard owner must outlive the copy.
clipboard: Mutex<Option<arboard::Clipboard>>, clipboard: Mutex<Option<arboard::Clipboard>>,
} }
@@ -42,6 +45,14 @@ impl App {
fn settings_path(&self) -> PathBuf { fn settings_path(&self) -> PathBuf {
self.data_dir.join("settings.json") 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)] #[derive(Serialize, Deserialize)]
@@ -58,6 +69,8 @@ struct AppState {
/// Browser integration could not start (e.g. another instance is running). /// Browser integration could not start (e.g. another instance is running).
ipc_error: Option<String>, ipc_error: Option<String>,
idle_minutes: u64, 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)] #[derive(Serialize)]
@@ -179,6 +192,7 @@ fn state(app: State<'_, App>) -> AppState {
remembers_secret_key: app.sync.stored_secret_key().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(), ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
idle_minutes: app.idle_minutes.load(Ordering::Relaxed), 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 { if let Some(sk) = typed {
app.sync.remember_secret_key(&sk); app.sync.remember_secret_key(&sk);
} }
hello::password_unlocked(&app.data_dir);
app.activity.touch(); app.activity.touch();
app.sync.request_sync(); app.sync.request_sync();
Ok(()) 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] #[tauri::command]
async fn lock(app: State<'_, App>) -> CmdResult<()> { async fn lock(app: State<'_, App>) -> CmdResult<()> {
app.core.lock(); app.core.lock();
@@ -713,8 +761,10 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
data_dir, data_dir,
ipc_error: Mutex::new(ipc_error), ipc_error: Mutex::new(ipc_error),
idle_minutes, idle_minutes,
hello_available: Arc::new(AtomicBool::new(false)),
clipboard: Mutex::new(None), clipboard: Mutex::new(None),
}); });
app.state::<App>().refresh_hello();
Ok(()) Ok(())
} }
@@ -733,6 +783,8 @@ fn main() {
set_title_bar_colors, set_title_bar_colors,
create_account, create_account,
unlock, unlock,
hello_unlock,
set_hello,
lock, lock,
touch, touch,
set_idle_minutes, set_idle_minutes,
+45 -5
View File
@@ -8,7 +8,8 @@ import {
pill, ring, secret, tile, type IconName, pill, ring, secret, tile, type IconName,
} from "./ui/kv"; } 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 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 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 } 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 pw = input({ type: "password", autocomplete: "current-password" });
const sk = input({ placeholder: "K1-XXXXXX-XXXXX-…", spellcheck: false }, true); const sk = input({ placeholder: "K1-XXXXXX-XXXXX-…", spellcheck: false }, true);
const err = errBox(); 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<boolean>("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<AppState>("state")).hello.offered) hello.remove();
else hello.disabled = false;
}
};
const submit = async () => { const submit = async () => {
btn.disabled = true; btn.disabled = true;
err.replaceChildren(); err.replaceChildren();
@@ -494,11 +510,15 @@ function renderUnlock(state: AppState) {
pw.onkeydown = (e) => { pw.onkeydown = (e) => {
if (e.key === "Enter") void submit(); 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.")); if (!state.remembers_secret_key) card.append(field("Secret Key", sk, "It's on your Emergency Kit. Dashes are optional."));
card.append(err, btn); card.append(err, btn);
centered(card); centered(card);
pw.focus(); if (state.hello.offered) hello.focus();
else pw.focus();
} }
// ------------------------------------------------------------------ vault // ------------------------------------------------------------------ vault
@@ -854,6 +874,25 @@ async function renderSettings(target: HTMLElement, state: AppState) {
else await publishTheme(); else await publishTheme();
await renderSettings(target, state); 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<boolean>("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"); const list = h("div", "kv-card rows");
for (const pr of pairings) { for (const pr of pairings) {
list.append(h("div", "kv-frow", 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", "", "Light or dark")), theme),
h("div", "kv-frow", h("div", "val", h("span", "", "Same theme on all my devices"), 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("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("Sync", syncSection),
section("Devices", devicesSection), section("Devices", devicesSection),
section("Emergency Kit", kitSection()), section("Emergency Kit", kitSection()),