diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..a915e20 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +LIBCLANG_PATH = { value = "/usr/lib", force = true } diff --git a/Cargo.lock b/Cargo.lock index 2d1a404..5c30ad8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,6 +231,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "caps" version = "0.5.6" @@ -366,6 +378,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -384,6 +402,53 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpp" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f6422bf20bc0654eac481a29a03e6d9121ad9f12345a03773b8a76a8701915" +dependencies = [ + "cpp_macros", +] + +[[package]] +name = "cpp_build" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f6fed3200ba0708c2adca5f6ed5ae202edd824bd4cbac7935a85edac9bcddce" +dependencies = [ + "cc", + "cpp_common", + "proc-macro2", + "regex", + "syn", + "unicode-xid", +] + +[[package]] +name = "cpp_common" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7280a73ef92e18d27d2ec3005b57fe0043b51d1b506be86b0bf66f588f9857b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "cpp_macros" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc7fd49a5b246251229ea4d4bf94c8d418689a1f9986ef96e00b64992922169" +dependencies = [ + "aho-corasick", + "byteorder", + "cpp_common", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -525,6 +590,18 @@ dependencies = [ "syn", ] +[[package]] +name = "dlib-face-recognition" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f139116fc86b731daf133c2864232fcb819f04a62829419e46415eef03bd1" +dependencies = [ + "cpp", + "cpp_build", + "image", + "sys-info", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -809,6 +886,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1509,6 +1598,16 @@ dependencies = [ "syn", ] +[[package]] +name = "sys-info" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1802,7 +1901,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vauth" -version = "0.1.6" +version = "0.1.0-alpha" dependencies = [ "aes-gcm", "anyhow", @@ -1812,6 +1911,7 @@ dependencies = [ "ciborium", "clap", "directories", + "dlib-face-recognition", "ecdsa", "fd-lock", "libc", diff --git a/Cargo.toml b/Cargo.toml index 9dbdb62..1ce531f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ path = "src/lib.rs" name = "vauth" path = "src/main.rs" + [dependencies] uhid-virt = "0.0.8" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } @@ -45,6 +46,8 @@ libc = "0.2" time = "0.3" rcgen = { version = "0.14", features = ["x509-parser"] } fd-lock = "4" +dlib-face-recognition = "0.3" [dev-dependencies] tempfile = "3" + diff --git a/src/bin/vauth_verify.rs b/src/bin/vauth_verify.rs new file mode 100644 index 0000000..0c206bc --- /dev/null +++ b/src/bin/vauth_verify.rs @@ -0,0 +1,7 @@ +//! vauth-verify — face verification overlay binary. +//! Full implementation in Milestone 3. + +fn main() { + eprintln!("vauth-verify: not yet implemented"); + std::process::exit(3); +} diff --git a/src/face/camera.rs b/src/face/camera.rs new file mode 100644 index 0000000..4dd0a51 --- /dev/null +++ b/src/face/camera.rs @@ -0,0 +1,2 @@ +//! Camera capture via nokhwa (V4L2 backend). +//! Full implementation in Milestone 2. diff --git a/src/face/mod.rs b/src/face/mod.rs new file mode 100644 index 0000000..3972f0f --- /dev/null +++ b/src/face/mod.rs @@ -0,0 +1,170 @@ +//! Native face recognition engine — replaces howdy dependency. +//! +//! Uses dlib (via dlib-face-recognition crate) for face detection and +//! 128-dimensional face encoding. Compatible with howdy's model format. + +pub mod models; +pub mod camera; +pub mod overlay; + +use dlib_face_recognition::{ + FaceDetector, FaceDetectorTrait, + FaceEncoderNetwork, FaceEncoderTrait, + LandmarkPredictor, LandmarkPredictorTrait, + ImageMatrix, +}; +use std::path::Path; + +/// Result of a face verification attempt. +#[derive(Debug, Clone)] +pub enum VerifyResult { + /// Face matched an enrolled model. + Match { + model_label: String, + distance: f64, + }, + /// Face detected but no match found. + NoMatch { + best_distance: f64, + }, + /// No face detected in the frame. + NoFace, +} + +/// The face recognition engine. Holds loaded dlib models. +/// +/// Expensive to construct (loads ~30MB of neural network weights). +/// Create once, reuse across verification attempts. +pub struct FaceEngine { + detector: FaceDetector, + predictor: LandmarkPredictor, + encoder: FaceEncoderNetwork, + threshold: f64, +} + +impl FaceEngine { + /// Load the face engine from dlib model files. + /// + /// `model_dir` must contain: + /// - `shape_predictor_5_face_landmarks.dat` + /// - `dlib_face_recognition_resnet_model_v1.dat` + /// + /// The FHOG face detector is built-in (no model file needed). + pub fn new(model_dir: &Path, threshold: f64) -> anyhow::Result { + let predictor_path = model_dir.join("shape_predictor_5_face_landmarks.dat"); + let encoder_path = model_dir.join("dlib_face_recognition_resnet_model_v1.dat"); + + if !predictor_path.exists() { + anyhow::bail!("Missing: {}", predictor_path.display()); + } + if !encoder_path.exists() { + anyhow::bail!("Missing: {}", encoder_path.display()); + } + + let detector = FaceDetector::default(); + let predictor = LandmarkPredictor::open( + predictor_path.to_str().ok_or_else(|| anyhow::anyhow!("invalid path"))?, + ).map_err(|_| anyhow::anyhow!("Failed to load landmark predictor"))?; + let encoder = FaceEncoderNetwork::open( + encoder_path.to_str().ok_or_else(|| anyhow::anyhow!("invalid path"))?, + ).map_err(|_| anyhow::anyhow!("Failed to load face encoder network"))?; + + Ok(Self { + detector, + predictor, + encoder, + threshold, + }) + } + + /// Detect faces in an image and compute 128-d encodings. + pub fn encode(&self, image: &ImageMatrix) -> Vec> { + let locations = self.detector.face_locations(image); + if locations.is_empty() { + return vec![]; + } + + let mut encodings = Vec::new(); + for rect in locations.iter() { + let landmarks = self.predictor.face_landmarks(image, &rect); + let face_encodings = self.encoder.get_face_encodings(image, &[landmarks], 0); + for enc in face_encodings.iter() { + let slice: &[f64] = enc.as_ref(); + encodings.push(slice.to_vec()); + } + } + encodings + } + + /// Verify a frame against enrolled face models. + pub fn verify_with_models( + &self, + image: &ImageMatrix, + face_models: &[models::FaceModel], + ) -> VerifyResult { + let detected = self.encode(image); + if detected.is_empty() { + return VerifyResult::NoFace; + } + + let mut best_distance = f64::MAX; + let mut best_label = String::new(); + + for det_enc in &detected { + for model in face_models { + for enrolled_enc in &model.data { + if enrolled_enc.len() != 128 { + continue; + } + let dist = euclidean_distance(det_enc, enrolled_enc); + if dist < best_distance { + best_distance = dist; + best_label = model.label.clone(); + } + } + } + } + + if best_distance <= self.threshold { + VerifyResult::Match { + model_label: best_label, + distance: best_distance, + } + } else { + VerifyResult::NoMatch { best_distance } + } + } +} + +/// Euclidean distance between two 128-d face encodings. +fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(x, y)| (x - y).powi(2)) + .sum::() + .sqrt() +} + +/// Default path to dlib model files (howdy's location). +pub fn default_model_dir() -> &'static Path { + Path::new("/lib/security/howdy/dlib-data") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_euclidean_distance_identical() { + let a = vec![0.0; 128]; + assert!((euclidean_distance(&a, &a) - 0.0).abs() < 1e-10); + } + + #[test] + fn test_euclidean_distance_known() { + let a = vec![1.0; 128]; + let b = vec![0.0; 128]; + let expected = (128.0_f64).sqrt(); + assert!((euclidean_distance(&a, &b) - expected).abs() < 1e-10); + } +} diff --git a/src/face/models.rs b/src/face/models.rs new file mode 100644 index 0000000..47d98c2 --- /dev/null +++ b/src/face/models.rs @@ -0,0 +1,88 @@ +//! Face model storage — howdy-compatible JSON format. +//! +//! Each user has a JSON file containing an array of face models. +//! Each model holds one or more 128-dimensional dlib face encodings. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// A single enrolled face model (matches howdy's schema exactly). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaceModel { + pub time: i64, + pub label: String, + pub id: u32, + /// Each entry is a 128-element face encoding from dlib's ResNet. + pub data: Vec>, +} + +/// Load all face models for a user from their model file. +pub fn load_models(path: &Path) -> anyhow::Result> { + let content = std::fs::read_to_string(path)?; + let models: Vec = serde_json::from_str(&content)?; + Ok(models) +} + +/// Save face models to disk. +pub fn save_models(path: &Path, models: &[FaceModel]) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(models)?; + std::fs::write(path, json)?; + Ok(()) +} + +/// Get the next available model ID from an existing set. +pub fn next_id(models: &[FaceModel]) -> u32 { + models.iter().map(|m| m.id).max().map(|m| m + 1).unwrap_or(0) +} + +/// Resolve the face models directory for vauth. +pub fn models_dir() -> anyhow::Result { + let data = crate::data_dir()?; + Ok(data.join("face-models")) +} + +/// Resolve the model file path for a given username. +pub fn model_path(username: &str) -> anyhow::Result { + Ok(models_dir()?.join(format!("{username}.json"))) +} + +/// Import howdy models for a user into vauth's model directory. +pub fn import_howdy(username: &str) -> anyhow::Result { + let howdy_path = PathBuf::from(format!("/lib/security/howdy/models/{username}.dat")); + if !howdy_path.exists() { + anyhow::bail!("No howdy models found for user '{username}' at {}", howdy_path.display()); + } + + let models = load_models(&howdy_path)?; + let count = models.len(); + + let dest = model_path(username)?; + if dest.exists() { + // Merge: load existing, append new with re-numbered IDs + let mut existing = load_models(&dest)?; + let base_id = next_id(&existing); + for (i, mut model) in models.into_iter().enumerate() { + model.id = base_id + i as u32; + model.label = format!("howdy-import: {}", model.label); + existing.push(model); + } + save_models(&dest, &existing)?; + } else { + save_models(&dest, &models)?; + } + + Ok(count) +} + +/// Collect all 128-d encodings from a set of models into a flat list. +pub fn all_encodings(models: &[FaceModel]) -> Vec<&[f64]> { + models + .iter() + .flat_map(|m| m.data.iter()) + .filter(|enc| enc.len() == 128) + .map(|enc| enc.as_slice()) + .collect() +} diff --git a/src/face/overlay.rs b/src/face/overlay.rs new file mode 100644 index 0000000..af779cc --- /dev/null +++ b/src/face/overlay.rs @@ -0,0 +1,2 @@ +//! GTK4 verification overlay UI. +//! Full implementation in Milestone 3. diff --git a/src/lib.rs b/src/lib.rs index 315388a..2f6ff6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod face; pub mod attestation_ca; pub mod audit; pub mod privsep; diff --git a/tests/face_engine.rs b/tests/face_engine.rs new file mode 100644 index 0000000..f4250b0 --- /dev/null +++ b/tests/face_engine.rs @@ -0,0 +1,86 @@ +//! Integration test: face engine loads models and can compare encodings. + +use vauth::face::models; +use std::path::Path; + +#[test] +fn test_load_howdy_models() { + let howdy_path = Path::new("/lib/security/howdy/models/meldrey.dat"); + if !howdy_path.exists() { + eprintln!("Skipping: no howdy models at {}", howdy_path.display()); + return; + } + + let face_models = models::load_models(howdy_path).expect("failed to load howdy models"); + assert!(!face_models.is_empty(), "should have at least one model"); + + // Verify structure + for model in &face_models { + assert!(!model.label.is_empty(), "model should have a label"); + for enc in &model.data { + assert_eq!(enc.len(), 128, "encoding should be 128-dimensional"); + } + } + + let all = models::all_encodings(&face_models); + assert!(!all.is_empty(), "should have at least one encoding"); + eprintln!("Loaded {} models with {} total encodings", face_models.len(), all.len()); +} + +#[test] +fn test_model_roundtrip() { + let dir = tempfile::tempdir().expect("tmpdir"); + let path = dir.path().join("test_user.json"); + + let models = vec![ + models::FaceModel { + time: 1700000000, + label: "test-face".to_string(), + id: 0, + data: vec![vec![0.1; 128]], + }, + ]; + + models::save_models(&path, &models).expect("save"); + let loaded = models::load_models(&path).expect("load"); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].label, "test-face"); + assert_eq!(loaded[0].data[0].len(), 128); +} + +#[test] +fn test_face_engine_loads() { + let model_dir = Path::new("/lib/security/howdy/dlib-data"); + if !model_dir.exists() { + eprintln!("Skipping: no dlib models at {}", model_dir.display()); + return; + } + + let engine = vauth::face::FaceEngine::new(model_dir, 0.35); + assert!(engine.is_ok(), "face engine should load: {:?}", engine.err()); + eprintln!("Face engine loaded successfully"); +} + +#[test] +fn test_self_distance_is_zero() { + // Two identical encodings should have distance 0 + let howdy_path = Path::new("/lib/security/howdy/models/meldrey.dat"); + if !howdy_path.exists() { + return; + } + + let face_models = models::load_models(howdy_path).unwrap(); + let encodings = models::all_encodings(&face_models); + if encodings.is_empty() { + return; + } + + // Distance of an encoding to itself should be 0 + let enc = encodings[0]; + let dist: f64 = enc.iter() + .zip(enc.iter()) + .map(|(a, b)| (a - b).powi(2)) + .sum::() + .sqrt(); + assert!(dist.abs() < 1e-10, "self-distance should be 0, got {dist}"); +}