CH-J Toolsserver management over SSH
Menu
Published source

CH-J Tools

Browse directories and files for a specific application release.

Download source ZIP
CH-J / Server Manager 1704 files
0.1.2-beta Added: · Built:
biometrics.rs Added: Date not recorded 17.2 KB · 414 lines
Code Download file
1//! OS-protected vault unlocking (port of `security/biometrics/*`).
2//!
3//! The 32-byte vault key never leaves the Core except to the isolated native helper
4//! (macOS Secure Enclave wrap with Touch ID, Windows Hello + DPAPI, Linux fprintd +
5//! libsecret). `security/biometrics.json` keeps the 0.1.0 format (schema 1, vaultId,
6//! entryId, provider, optional `wrapped`), so existing enrollments keep working.
8use crate::core::error::{CoreError, CoreResult};
9use crate::core::storage::atomic_write_json;
10use crate::core::vault::VaultStore;
11use base64::engine::general_purpose::STANDARD as B64;
12use base64::Engine;
13use parking_lot::Mutex;
14use serde_json::{json, Value};
15use std::path::{Path, PathBuf};
16use std::process::Stdio;
17use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
18use std::sync::Arc;
19use std::time::Duration;
20use tokio::io::{AsyncReadExt, AsyncWriteExt};
21use tokio::sync::Notify;
22use zeroize::Zeroizing;
24pub fn biometric_error(code: &str) -> CoreError {
25 CoreError::new(code, code)
28#[derive(Clone, Debug)]
29pub struct ProviderInfo {
30 pub provider: &'static str,
31 pub protection: &'static str,
34pub fn provider_info() -> ProviderInfo {
35 if cfg!(target_os = "macos") {
36 ProviderInfo { provider: "touch-id", protection: "biometry-current-set" }
37 } else if cfg!(windows) {
38 ProviderInfo { provider: "windows-hello", protection: "convenience" }
39 } else if cfg!(target_os = "linux") {
40 ProviderInfo { provider: "fprintd", protection: "convenience" }
41 } else {
42 ProviderInfo { provider: "none", protection: "unavailable" }
43 }
46/// Native helper invocation (port of `nativeRunner.js`): JSON request on stdin, bounded
47/// JSON reply on stdout, stderr discarded, never logged.
48pub struct NativeRunner {
49 executable: PathBuf,
50 args: Vec<String>,
51 timeout: Duration,
54impl NativeRunner {
55 pub fn for_resources(resources: &Path) -> Self {
56 let root = resources.join("biometrics");
57 if cfg!(target_os = "linux") {
58 Self { executable: PathBuf::from("/usr/bin/python3"), args: vec![root.join("linux.py").to_string_lossy().to_string()], timeout: Duration::from_secs(65) }
59 } else {
60 Self { executable: root.join(if cfg!(windows) { "chj-biometric.exe" } else { "chj-biometric" }), args: Vec::new(), timeout: Duration::from_secs(65) }
61 }
62 }
64 pub async fn run(&self, request: &Value, cancel: &Notify) -> CoreResult<Value> {
65 let op = request["op"].as_str().unwrap_or("");
66 let mut command = tokio::process::Command::new(&self.executable);
67 command.args(&self.args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true);
68 #[cfg(windows)]
69 command.creation_flags(0x0800_0000);
70 let mut child = command.spawn().map_err(|_| biometric_error("BIOMETRIC_UNAVAILABLE"))?;
71 let input = Zeroizing::new(format!("{}\n", serde_json::to_string(request).unwrap_or_default()));
72 if let Some(mut stdin) = child.stdin.take() {
73 if stdin.write_all(input.as_bytes()).await.is_err() {
74 return Err(biometric_error("BIOMETRIC_UNAVAILABLE"));
75 }
76 }
77 drop(input);
78 let mut stdout = child.stdout.take().ok_or_else(|| biometric_error("BIOMETRIC_UNAVAILABLE"))?;
79 let timeout = if op == "status" { self.timeout.min(Duration::from_secs(8)) } else { self.timeout };
80 let work = async {
81 let mut buffer = Zeroizing::new(Vec::new());
82 let mut chunk = [0u8; 4096];
83 loop {
84 let n = stdout.read(&mut chunk).await.map_err(|_| biometric_error("BIOMETRIC_FAILED"))?;
85 if n == 0 {
86 break;
87 }
88 if buffer.len() + n > 16384 {
89 return Err(biometric_error("BIOMETRIC_FAILED"));
90 }
91 buffer.extend_from_slice(&chunk[..n]);
92 }
93 let status = child.wait().await.map_err(|_| biometric_error("BIOMETRIC_FAILED"))?;
94 let value: Value = serde_json::from_slice(&buffer).map_err(|_| biometric_error("BIOMETRIC_FAILED"))?;
95 if !status.success() || value.get("ok") != Some(&Value::Bool(true)) {
96 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");
97 return Err(biometric_error(code));
98 }
99 Ok(value)
100 };
101 tokio::select! {
102 result = tokio::time::timeout(timeout, work) => result.map_err(|_| biometric_error("BIOMETRIC_TIMEOUT"))?,
103 _ = cancel.notified() => Err(biometric_error("BIOMETRIC_CANCELLED")),
104 }
105 }
108pub struct BiometricService {
109 file: PathBuf,
110 vault: Arc<VaultStore>,
111 runner: NativeRunner,
112 info: ProviderInfo,
113 epoch: AtomicU64,
114 active: AtomicBool,
115 cancel: Arc<Notify>,
116 auto_armed: AtomicBool,
117 auto_pending: tokio::sync::Mutex<()>,
118 window_handle: Mutex<Option<String>>,
119 require_auth: Box<dyn Fn() -> bool + Send + Sync>,
120 reason: Box<dyn Fn() -> String + Send + Sync>,
123struct ActiveGuard<'a>(&'a AtomicBool);
124impl Drop for ActiveGuard<'_> {
125 fn drop(&mut self) {
126 self.0.store(false, Ordering::SeqCst);
127 }
130impl BiometricService {
131 pub fn new(
132 storage_root: &Path,
133 resources: &Path,
134 vault: Arc<VaultStore>,
135 require_auth: Box<dyn Fn() -> bool + Send + Sync>,
136 reason: Box<dyn Fn() -> String + Send + Sync>,
137 ) -> Self {
138 Self {
139 file: storage_root.join("security").join("biometrics.json"),
140 vault,
141 runner: NativeRunner::for_resources(resources),
142 info: provider_info(),
143 epoch: AtomicU64::new(0),
144 active: AtomicBool::new(false),
145 cancel: Arc::new(Notify::new()),
146 auto_armed: AtomicBool::new(true),
147 auto_pending: tokio::sync::Mutex::new(()),
148 window_handle: Mutex::new(None),
149 require_auth,
150 reason,
151 }
152 }
154 /// Windows Hello needs the owner HWND of the main window (decimal string).
155 pub fn set_window_handle(&self, hwnd: Option<String>) {
156 *self.window_handle.lock() = hwnd;
157 }
159 pub fn is_active(&self) -> bool {
160 self.active.load(Ordering::SeqCst)
161 }
163 pub fn arm_auto_unlock(&self) {
164 self.auto_armed.store(true, Ordering::SeqCst);
165 }
167 pub fn disarm_auto_unlock(&self) {
168 self.auto_armed.store(false, Ordering::SeqCst);
169 }
171 pub fn cancel(&self) {
172 self.epoch.fetch_add(1, Ordering::SeqCst);
173 self.cancel.notify_waiters();
174 }
176 fn metadata(&self) -> Option<Value> {
177 let value: Value = serde_json::from_str(&std::fs::read_to_string(&self.file).ok()?).ok()?;
178 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);
179 if value.get("schema").and_then(Value::as_u64) != Some(1) || !hex64("entryId") || !hex64("vaultId") {
180 return None;
181 }
182 if let Some(wrapped) = value.get("wrapped") {
183 if !wrapped.as_str().map(|w| w.len() <= 8192).unwrap_or(false) {
184 return None;
185 }
186 }
187 Some(value)
188 }
190 fn valid_metadata(&self) -> Option<Value> {
191 let value = self.metadata()?;
192 (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)
193 }
195 async fn call(&self, op: &str, entry_id: Option<&str>, key: Option<&[u8]>, wrapped: Option<&str>) -> CoreResult<Value> {
196 if self.info.provider == "none" {
197 return Err(biometric_error("BIOMETRIC_UNAVAILABLE"));
198 }
199 if let Some(id) = entry_id {
200 if id.len() != 64 || !id.chars().all(|c| c.is_ascii_hexdigit()) {
201 return Err(biometric_error("BIOMETRIC_FAILED"));
202 }
203 }
204 let mut hwnd = "0".to_string();
205 if cfg!(windows) && op != "status" && op != "remove" {
206 hwnd = self.window_handle.lock().clone().ok_or_else(|| biometric_error("BIOMETRIC_CANCELLED"))?;
207 }
208 let reason: String = (self.reason)().chars().take(200).collect();
209 let request = json!({
210 "op": op,
211 "entryId": entry_id,
212 "reason": reason,
213 "key": key.map(|k| B64.encode(k)),
214 "wrapped": wrapped,
215 "hwnd": hwnd,
216 "pid": std::process::id()
217 });
218 self.runner.run(&request, &self.cancel).await
219 }
221 pub async fn availability(&self) -> Value {
222 match self.call("status", None, None, None).await {
223 Ok(value) => {
224 let available = value.get("available") == Some(&Value::Bool(true));
225 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" })) })
226 }
227 Err(error) => json!({ "provider": self.info.provider, "protection": self.info.protection, "available": false, "code": error.code }),
228 }
229 }
231 pub async fn status(&self) -> Value {
232 let mut availability = self.availability().await;
233 let enrolled = self.metadata().is_some();
234 let valid = self.valid_metadata().is_some();
235 availability["enrolled"] = json!(enrolled);
236 availability["enabled"] = json!(valid);
237 if enrolled && !valid {
238 availability["code"] = json!("BIOMETRIC_ENROLLMENT_INVALIDATED");
239 }
240 availability
241 }
243 async fn run<T, F: std::future::Future<Output = CoreResult<T>>>(&self, work: impl FnOnce(u64) -> F) -> CoreResult<T> {
244 if self.active.swap(true, Ordering::SeqCst) {
245 return Err(biometric_error("BIOMETRIC_BUSY"));
246 }
247 let _guard = ActiveGuard(&self.active);
248 let epoch = self.epoch.load(Ordering::SeqCst);
249 match tokio::time::timeout(Duration::from_secs(70), work(epoch)).await {
250 Ok(result) => result,
251 Err(_) => {
252 self.cancel();
253 Err(biometric_error("BIOMETRIC_TIMEOUT"))
254 }
255 }
256 }
258 fn check(&self, epoch: u64) -> CoreResult<()> {
259 if self.epoch.load(Ordering::SeqCst) != epoch {
260 Err(biometric_error("BIOMETRIC_CANCELLED"))
261 } else {
262 Ok(())
263 }
264 }
266 pub async fn enable(&self) -> CoreResult<Value> {
267 self.vault.assert_unlocked()?;
268 self.run(|epoch| async move {
269 let availability = self.availability().await;
270 self.check(epoch)?;
271 if availability["available"] != true {
272 return Err(biometric_error(availability["code"].as_str().unwrap_or("BIOMETRIC_UNAVAILABLE")));
273 }
274 let vault_id = self.vault.instance_id().ok_or_else(|| biometric_error("BIOMETRIC_ENROLLMENT_INVALIDATED"))?;
275 let mut entry = [0u8; 32];
276 rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut entry);
277 let entry_id = hex::encode(entry);
278 let previous = self.metadata();
279 self.call("authenticate", Some(&entry_id), None, None).await?;
280 self.check(epoch)?;
281 let key = self.vault.unlock_key_copy()?;
282 let stored = self.call("store", Some(&entry_id), Some(key.as_ref()), None).await;
283 drop(key);
284 let result = (|| -> CoreResult<()> {
285 let stored = stored?;
286 self.check(epoch)?;
287 if Some(vault_id.as_str()) != self.vault.instance_id().as_deref() {
288 return Err(biometric_error("BIOMETRIC_ENROLLMENT_INVALIDATED"));
289 }
290 let mut record = json!({ "schema": 1, "vaultId": vault_id, "entryId": entry_id, "provider": self.info.provider });
291 if let Some(wrapped) = stored.get("wrapped") {
292 let ok = wrapped.as_str().map(|w| w.len() <= 8192 && !w.is_empty() && B64.decode(w).is_ok()).unwrap_or(false);
293 if !ok {
294 return Err(biometric_error("BIOMETRIC_CREDENTIAL_FAILED"));
295 }
296 record["wrapped"] = wrapped.clone();
297 }
298 atomic_write_json(&self.file, &record)
299 })();
300 if let Err(error) = result {
301 let _ = self.call("remove", Some(&entry_id), None, None).await;
302 return Err(error);
303 }
304 if let Some(previous) = previous {
305 if previous["entryId"].as_str() != Some(entry_id.as_str()) {
306 let _ = self.call("remove", previous["entryId"].as_str(), None, None).await;
307 }
308 }
309 Ok(self.status().await)
310 })
311 .await
312 }
314 pub async fn disable(&self, reset: bool) -> CoreResult<Value> {
315 if !reset {
316 self.vault.assert_unlocked()?;
317 }
318 self.cancel();
319 let previous = self.metadata();
320 match std::fs::remove_file(&self.file) {
321 Ok(()) => {}
322 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
323 Err(e) => return Err(e.into()),
324 }
325 if let Some(previous) = previous {
326 let _ = self.call("remove", previous["entryId"].as_str(), None, None).await;
327 }
328 Ok(json!({ "enabled": false, "enrolled": false }))
329 }
331 pub async fn unlock(&self) -> CoreResult<Value> {
332 if self.vault.is_unlocked() {
333 return Ok(serde_json::to_value(self.vault.status())?);
334 }
335 let generation = self.vault.generation();
336 let meta = self.valid_metadata().ok_or_else(|| biometric_error("BIOMETRIC_MASTER_PASSWORD_REQUIRED"))?;
337 let entry_id = meta["entryId"].as_str().unwrap_or("").to_string();
338 self.run(|epoch| async move {
339 let outcome = async {
340 let result = self.call("retrieve", Some(&entry_id), None, meta.get("wrapped").and_then(Value::as_str)).await?;
341 self.check(epoch)?;
342 let encoded = Zeroizing::new(result.get("key").and_then(Value::as_str).unwrap_or("").to_string());
343 if encoded.len() != 44 || !encoded.ends_with('=') {
344 return Err(biometric_error("BIOMETRIC_INVALID_KEY"));
345 }
346 let key = Zeroizing::new(B64.decode(encoded.as_bytes()).map_err(|_| biometric_error("BIOMETRIC_INVALID_KEY"))?);
347 if key.len() != 32 {
348 return Err(biometric_error("BIOMETRIC_INVALID_KEY"));
349 }
350 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()) {
351 return Err(biometric_error("BIOMETRIC_ENROLLMENT_INVALIDATED"));
352 }
353 let status = self.vault.unlock_with_key_at_generation(&key, generation)?;
354 self.disarm_auto_unlock();
355 Ok(serde_json::to_value(status)?)
356 }
357 .await;
358 if let Err(error) = &outcome {
359 if error.code == "BIOMETRIC_ENROLLMENT_INVALIDATED" || error.code == "BIOMETRIC_INVALID_KEY" {
360 let _ = std::fs::remove_file(&self.file);
361 let _ = self.call("remove", Some(&entry_id), None, None).await;
362 }
363 }
364 outcome
365 })
366 .await
367 }
369 /// Single-flight automatic prompt: armed at launch and after an automatic lock only.
370 pub async fn auto_unlock(&self) -> Value {
371 if self.vault.is_unlocked() {
372 return json!({ "attempted": false, "reason": "UNLOCKED", "status": self.vault.status() });
373 }
374 let Ok(_pending) = self.auto_pending.try_lock() else {
375 return json!({ "attempted": false, "reason": "PENDING" });
376 };
377 if !self.auto_armed.load(Ordering::SeqCst) {
378 return json!({ "attempted": false, "reason": "NOT_ARMED" });
379 }
380 if self.valid_metadata().is_none() {
381 return json!({ "attempted": false, "reason": "NOT_ENABLED" });
382 }
383 self.auto_armed.store(false, Ordering::SeqCst);
384 let availability = self.availability().await;
385 if availability["available"] != true {
386 return json!({ "attempted": false, "reason": availability["code"] });
387 }
388 match self.unlock().await {
389 Ok(status) => json!({ "attempted": true, "status": status }),
390 Err(error) => json!({ "attempted": true, "error": { "code": error.code } }),
391 }
392 }
394 pub async fn authenticate_sensitive_action(&self) -> CoreResult<Value> {
395 self.vault.assert_unlocked()?;
396 self.run(|epoch| async move {
397 self.call("authenticate", None, None, None).await?;
398 self.check(epoch)?;
399 Ok(json!({ "authenticated": true }))
400 })
401 .await
402 }
404 pub async fn require_sensitive_action(&self) -> CoreResult<()> {
405 if (self.require_auth)() {
406 self.authenticate_sensitive_action().await?;
407 }
408 Ok(())
409 }
412#[cfg(test)]
413#[path = "../../tests/support/biometric_safety.rs"]
414mod safety_tests;
SHA-256d088cb96ce65c4b98ea1af0e0d34deb5712b62ff2903434126616946225c01a8

CH-J Proprietary Software License 1.14 · Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.

Archive SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Files