//! OS-protected vault unlocking (port of `security/biometrics/*`). //! //! The 32-byte vault key never leaves the Core except to the isolated native helper //! (macOS Secure Enclave wrap with Touch ID, Windows Hello + DPAPI, Linux fprintd + //! libsecret). `security/biometrics.json` keeps the 0.1.0 format (schema 1, vaultId, //! entryId, provider, optional `wrapped`), so existing enrollments keep working. use crate::core::error::{CoreError, CoreResult}; use crate::core::storage::atomic_write_json; use crate::core::vault::VaultStore; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; use parking_lot::Mutex; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::Notify; use zeroize::Zeroizing; pub fn biometric_error(code: &str) -> CoreError { CoreError::new(code, code) } #[derive(Clone, Debug)] pub struct ProviderInfo { pub provider: &'static str, pub protection: &'static str, } pub fn provider_info() -> ProviderInfo { if cfg!(target_os = "macos") { ProviderInfo { provider: "touch-id", protection: "biometry-current-set" } } else if cfg!(windows) { ProviderInfo { provider: "windows-hello", protection: "convenience" } } else if cfg!(target_os = "linux") { ProviderInfo { provider: "fprintd", protection: "convenience" } } else { ProviderInfo { provider: "none", protection: "unavailable" } } } /// Native helper invocation (port of `nativeRunner.js`): JSON request on stdin, bounded /// JSON reply on stdout, stderr discarded, never logged. pub struct NativeRunner { executable: PathBuf, args: Vec, timeout: Duration, } impl NativeRunner { pub fn for_resources(resources: &Path) -> Self { let root = resources.join("biometrics"); if cfg!(target_os = "linux") { Self { executable: PathBuf::from("/usr/bin/python3"), args: vec![root.join("linux.py").to_string_lossy().to_string()], timeout: Duration::from_secs(65) } } else { Self { executable: root.join(if cfg!(windows) { "chj-biometric.exe" } else { "chj-biometric" }), args: Vec::new(), timeout: Duration::from_secs(65) } } } pub async fn run(&self, request: &Value, cancel: &Notify) -> CoreResult { let op = request["op"].as_str().unwrap_or(""); let mut command = tokio::process::Command::new(&self.executable); command.args(&self.args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true); #[cfg(windows)] command.creation_flags(0x0800_0000); let mut child = command.spawn().map_err(|_| biometric_error("BIOMETRIC_UNAVAILABLE"))?; let input = Zeroizing::new(format!("{}\n", serde_json::to_string(request).unwrap_or_default())); if let Some(mut stdin) = child.stdin.take() { if stdin.write_all(input.as_bytes()).await.is_err() { return Err(biometric_error("BIOMETRIC_UNAVAILABLE")); } } drop(input); let mut stdout = child.stdout.take().ok_or_else(|| biometric_error("BIOMETRIC_UNAVAILABLE"))?; let timeout = if op == "status" { self.timeout.min(Duration::from_secs(8)) } else { self.timeout }; let work = async { let mut buffer = Zeroizing::new(Vec::new()); let mut chunk = [0u8; 4096]; loop { let n = stdout.read(&mut chunk).await.map_err(|_| biometric_error("BIOMETRIC_FAILED"))?; if n == 0 { break; } if buffer.len() + n > 16384 { return Err(biometric_error("BIOMETRIC_FAILED")); } buffer.extend_from_slice(&chunk[..n]); } let status = child.wait().await.map_err(|_| biometric_error("BIOMETRIC_FAILED"))?; let value: Value = serde_json::from_slice(&buffer).map_err(|_| biometric_error("BIOMETRIC_FAILED"))?; if !status.success() || value.get("ok") != Some(&Value::Bool(true)) { let code = value.get("code").and_then(Value::as_str).filter(|c| c.starts_with("BIOMETRIC_") && c[10..].chars().all(|ch| ch.is_ascii_uppercase() || ch == '_')).unwrap_or("BIOMETRIC_FAILED"); return Err(biometric_error(code)); } Ok(value) }; tokio::select! { result = tokio::time::timeout(timeout, work) => result.map_err(|_| biometric_error("BIOMETRIC_TIMEOUT"))?, _ = cancel.notified() => Err(biometric_error("BIOMETRIC_CANCELLED")), } } } pub struct BiometricService { file: PathBuf, vault: Arc, runner: NativeRunner, info: ProviderInfo, epoch: AtomicU64, active: AtomicBool, cancel: Arc, auto_armed: AtomicBool, auto_pending: tokio::sync::Mutex<()>, window_handle: Mutex>, require_auth: Box bool + Send + Sync>, reason: Box String + Send + Sync>, } struct ActiveGuard<'a>(&'a AtomicBool); impl Drop for ActiveGuard<'_> { fn drop(&mut self) { self.0.store(false, Ordering::SeqCst); } } impl BiometricService { pub fn new( storage_root: &Path, resources: &Path, vault: Arc, require_auth: Box bool + Send + Sync>, reason: Box String + Send + Sync>, ) -> Self { Self { file: storage_root.join("security").join("biometrics.json"), vault, runner: NativeRunner::for_resources(resources), info: provider_info(), epoch: AtomicU64::new(0), active: AtomicBool::new(false), cancel: Arc::new(Notify::new()), auto_armed: AtomicBool::new(true), auto_pending: tokio::sync::Mutex::new(()), window_handle: Mutex::new(None), require_auth, reason, } } /// Windows Hello needs the owner HWND of the main window (decimal string). pub fn set_window_handle(&self, hwnd: Option) { *self.window_handle.lock() = hwnd; } pub fn is_active(&self) -> bool { self.active.load(Ordering::SeqCst) } pub fn arm_auto_unlock(&self) { self.auto_armed.store(true, Ordering::SeqCst); } pub fn disarm_auto_unlock(&self) { self.auto_armed.store(false, Ordering::SeqCst); } pub fn cancel(&self) { self.epoch.fetch_add(1, Ordering::SeqCst); self.cancel.notify_waiters(); } fn metadata(&self) -> Option { let value: Value = serde_json::from_str(&std::fs::read_to_string(&self.file).ok()?).ok()?; let hex64 = |k: &str| value.get(k).and_then(Value::as_str).map(|s| s.len() == 64 && s.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))).unwrap_or(false); if value.get("schema").and_then(Value::as_u64) != Some(1) || !hex64("entryId") || !hex64("vaultId") { return None; } if let Some(wrapped) = value.get("wrapped") { if !wrapped.as_str().map(|w| w.len() <= 8192).unwrap_or(false) { return None; } } Some(value) } fn valid_metadata(&self) -> Option { let value = self.metadata()?; (value.get("vaultId").and_then(Value::as_str) == self.vault.instance_id().as_deref() && value.get("provider").and_then(Value::as_str) == Some(self.info.provider)).then_some(value) } async fn call(&self, op: &str, entry_id: Option<&str>, key: Option<&[u8]>, wrapped: Option<&str>) -> CoreResult { if self.info.provider == "none" { return Err(biometric_error("BIOMETRIC_UNAVAILABLE")); } if let Some(id) = entry_id { if id.len() != 64 || !id.chars().all(|c| c.is_ascii_hexdigit()) { return Err(biometric_error("BIOMETRIC_FAILED")); } } let mut hwnd = "0".to_string(); if cfg!(windows) && op != "status" && op != "remove" { hwnd = self.window_handle.lock().clone().ok_or_else(|| biometric_error("BIOMETRIC_CANCELLED"))?; } let reason: String = (self.reason)().chars().take(200).collect(); let request = json!({ "op": op, "entryId": entry_id, "reason": reason, "key": key.map(|k| B64.encode(k)), "wrapped": wrapped, "hwnd": hwnd, "pid": std::process::id() }); self.runner.run(&request, &self.cancel).await } pub async fn availability(&self) -> Value { match self.call("status", None, None, None).await { Ok(value) => { let available = value.get("available") == Some(&Value::Bool(true)); json!({ "provider": self.info.provider, "protection": self.info.protection, "available": available, "code": value.get("code").cloned().filter(|c| !c.is_null()).unwrap_or_else(|| json!(if available { "BIOMETRIC_AVAILABLE" } else { "BIOMETRIC_UNAVAILABLE" })) }) } Err(error) => json!({ "provider": self.info.provider, "protection": self.info.protection, "available": false, "code": error.code }), } } pub async fn status(&self) -> Value { let mut availability = self.availability().await; let enrolled = self.metadata().is_some(); let valid = self.valid_metadata().is_some(); availability["enrolled"] = json!(enrolled); availability["enabled"] = json!(valid); if enrolled && !valid { availability["code"] = json!("BIOMETRIC_ENROLLMENT_INVALIDATED"); } availability } async fn run>>(&self, work: impl FnOnce(u64) -> F) -> CoreResult { if self.active.swap(true, Ordering::SeqCst) { return Err(biometric_error("BIOMETRIC_BUSY")); } let _guard = ActiveGuard(&self.active); let epoch = self.epoch.load(Ordering::SeqCst); match tokio::time::timeout(Duration::from_secs(70), work(epoch)).await { Ok(result) => result, Err(_) => { self.cancel(); Err(biometric_error("BIOMETRIC_TIMEOUT")) } } } fn check(&self, epoch: u64) -> CoreResult<()> { if self.epoch.load(Ordering::SeqCst) != epoch { Err(biometric_error("BIOMETRIC_CANCELLED")) } else { Ok(()) } } pub async fn enable(&self) -> CoreResult { self.vault.assert_unlocked()?; self.run(|epoch| async move { let availability = self.availability().await; self.check(epoch)?; if availability["available"] != true { return Err(biometric_error(availability["code"].as_str().unwrap_or("BIOMETRIC_UNAVAILABLE"))); } let vault_id = self.vault.instance_id().ok_or_else(|| biometric_error("BIOMETRIC_ENROLLMENT_INVALIDATED"))?; let mut entry = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut entry); let entry_id = hex::encode(entry); let previous = self.metadata(); self.call("authenticate", Some(&entry_id), None, None).await?; self.check(epoch)?; let key = self.vault.unlock_key_copy()?; let stored = self.call("store", Some(&entry_id), Some(key.as_ref()), None).await; drop(key); let result = (|| -> CoreResult<()> { let stored = stored?; self.check(epoch)?; if Some(vault_id.as_str()) != self.vault.instance_id().as_deref() { return Err(biometric_error("BIOMETRIC_ENROLLMENT_INVALIDATED")); } let mut record = json!({ "schema": 1, "vaultId": vault_id, "entryId": entry_id, "provider": self.info.provider }); if let Some(wrapped) = stored.get("wrapped") { let ok = wrapped.as_str().map(|w| w.len() <= 8192 && !w.is_empty() && B64.decode(w).is_ok()).unwrap_or(false); if !ok { return Err(biometric_error("BIOMETRIC_CREDENTIAL_FAILED")); } record["wrapped"] = wrapped.clone(); } atomic_write_json(&self.file, &record) })(); if let Err(error) = result { let _ = self.call("remove", Some(&entry_id), None, None).await; return Err(error); } if let Some(previous) = previous { if previous["entryId"].as_str() != Some(entry_id.as_str()) { let _ = self.call("remove", previous["entryId"].as_str(), None, None).await; } } Ok(self.status().await) }) .await } pub async fn disable(&self, reset: bool) -> CoreResult { if !reset { self.vault.assert_unlocked()?; } self.cancel(); let previous = self.metadata(); match std::fs::remove_file(&self.file) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e.into()), } if let Some(previous) = previous { let _ = self.call("remove", previous["entryId"].as_str(), None, None).await; } Ok(json!({ "enabled": false, "enrolled": false })) } pub async fn unlock(&self) -> CoreResult { if self.vault.is_unlocked() { return Ok(serde_json::to_value(self.vault.status())?); } let meta = self.valid_metadata().ok_or_else(|| biometric_error("BIOMETRIC_MASTER_PASSWORD_REQUIRED"))?; let entry_id = meta["entryId"].as_str().unwrap_or("").to_string(); self.run(|epoch| async move { let outcome = async { let result = self.call("retrieve", Some(&entry_id), None, meta.get("wrapped").and_then(Value::as_str)).await?; self.check(epoch)?; let encoded = Zeroizing::new(result.get("key").and_then(Value::as_str).unwrap_or("").to_string()); if encoded.len() != 44 || !encoded.ends_with('=') { return Err(biometric_error("BIOMETRIC_INVALID_KEY")); } let key = Zeroizing::new(B64.decode(encoded.as_bytes()).map_err(|_| biometric_error("BIOMETRIC_INVALID_KEY"))?); if key.len() != 32 { return Err(biometric_error("BIOMETRIC_INVALID_KEY")); } if meta["vaultId"].as_str() != self.vault.instance_id().as_deref() || self.valid_metadata().and_then(|m| m["entryId"].as_str().map(str::to_string)) != Some(entry_id.clone()) { return Err(biometric_error("BIOMETRIC_ENROLLMENT_INVALIDATED")); } let status = self.vault.unlock_with_key(&key)?; self.disarm_auto_unlock(); Ok(serde_json::to_value(status)?) } .await; if let Err(error) = &outcome { if error.code == "BIOMETRIC_ENROLLMENT_INVALIDATED" || error.code == "BIOMETRIC_INVALID_KEY" { let _ = std::fs::remove_file(&self.file); let _ = self.call("remove", Some(&entry_id), None, None).await; } } outcome }) .await } /// Single-flight automatic prompt: armed at launch and after an automatic lock only. pub async fn auto_unlock(&self) -> Value { if self.vault.is_unlocked() { return json!({ "attempted": false, "reason": "UNLOCKED", "status": self.vault.status() }); } let Ok(_pending) = self.auto_pending.try_lock() else { return json!({ "attempted": false, "reason": "PENDING" }); }; if !self.auto_armed.load(Ordering::SeqCst) { return json!({ "attempted": false, "reason": "NOT_ARMED" }); } if self.valid_metadata().is_none() { return json!({ "attempted": false, "reason": "NOT_ENABLED" }); } self.auto_armed.store(false, Ordering::SeqCst); let availability = self.availability().await; if availability["available"] != true { return json!({ "attempted": false, "reason": availability["code"] }); } match self.unlock().await { Ok(status) => json!({ "attempted": true, "status": status }), Err(error) => json!({ "attempted": true, "error": { "code": error.code } }), } } pub async fn authenticate_sensitive_action(&self) -> CoreResult { self.vault.assert_unlocked()?; self.run(|epoch| async move { self.call("authenticate", None, None, None).await?; self.check(epoch)?; Ok(json!({ "authenticated": true })) }) .await } pub async fn require_sensitive_action(&self) -> CoreResult<()> { if (self.require_auth)() { self.authenticate_sensitive_action().await?; } Ok(()) } } #[cfg(test)] #[path = "../../tests/support/biometric_safety.rs"] mod safety_tests;