Official app icon and Windows Hello unlock (beta)
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]>
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 775 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 764 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 9.6 KiB |
@@ -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 |
@@ -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 "));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub mod autolock;
|
||||
pub mod device_key;
|
||||
pub mod hello;
|
||||
pub mod qr;
|
||||
pub mod sync;
|
||||
pub mod title_bar;
|
||||
|
||||
@@ -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,
|
||||
|
||||