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 = [
|
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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
|
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 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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||