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