From c723dfd83e86a94e5c5725cf1d8b3d5190dd1519 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Fri, 3 Jul 2026 13:45:44 -0700 Subject: [PATCH 1/8] feat(security): make require_ir load-bearing and passive anti-spoof honest (Plan 01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes auth-review findings H1 (IR heuristic bypass), H3 (CLAHE inflated the texture check), and the passive half of H2 (frame-variance too loose). Stays fully passive: no landmark/blink default flip, require_ir=false still supported, no fail-open path or lockout introduced. IR classification (facelock-camera/device.rs): - New IrSource::{Quirk,Format,Name,None} provenance. Quirks force_ir is authoritative (both directions); an "ir"/"infrared" name *token* (tokenized, not contains) classifies IR; a GREY/Y16 format counts only when corroborated by a name token. Mere enumeration of GREY no longer implies IR (H1). - auto_detect_device prefers a quirks-confirmed IR device, then heuristic IR, then the first device; never auto-picks an unknown cam for self-reporting GREY. - Display/auto-select surfaces (direct.rs, daemon handler, setup.rs) now consult the quirks DB so the shown [IR] tag matches the auth decision. IR texture (H3): check_ir_texture now runs on the RAW frame, never CLAHE (which inflated flat-photo std_dev and masked replays). Cutoff is configurable via security.ir_texture_min_stddev (default 10.0; raw bands: flat <5, real >15). Frame variance (H2 passive): check_frame_variance takes a configurable security.frame_variance_max_similarity (default 0.97, require >=0.03 drift). Documented as passive anti-photo only — it does not stop video replay; IR is the load-bearing defense. Config: two new keys under [security] with validation, defaults, and unit tests. Docs: security.md (§A/B/C rewritten for honesty + raw-frame calibration) and contracts.md (new keys, defaults, IR/texture/variance semantics). Tests: device corpus (RGB names + GREY-only + quirk force_ir), frame-variance boundary, texture-cutoff config, config validation. Container: added an anti-spoof refusal assertion to run-oneshot-tests.sh (moves the system quirks DB aside so the gate fires on any host); container-config.toml posture documented. Verified: cargo build/test/clippy/fmt clean; just test-arch-oneshot 12/12 and just test-arch-integration 7/7 on real hardware. Co-Authored-By: Claude Fable 5 --- config/facelock.toml | 13 ++ crates/facelock-camera/src/device.rs | 244 ++++++++++++++++------ crates/facelock-camera/src/lib.rs | 4 +- crates/facelock-camera/src/preprocess.rs | 40 +++- crates/facelock-camera/src/quirks.rs | 6 + crates/facelock-cli/src/commands/setup.rs | 19 +- crates/facelock-cli/src/direct.rs | 30 ++- crates/facelock-core/src/config.rs | 83 ++++++++ crates/facelock-core/src/types.rs | 77 ++++++- crates/facelock-daemon/src/auth.rs | 34 ++- crates/facelock-daemon/src/handler.rs | 7 +- docs/contracts.md | 11 +- docs/security.md | 109 ++++++---- test/container-config.toml | 16 ++ test/run-oneshot-tests.sh | 25 +++ 15 files changed, 560 insertions(+), 158 deletions(-) diff --git a/config/facelock.toml b/config/facelock.toml index 6558db8..8cae669 100644 --- a/config/facelock.toml +++ b/config/facelock.toml @@ -173,6 +173,19 @@ # Default: true # require_frame_variance = true +# Maximum cosine similarity allowed between consecutive matched frames. +# Consecutive frames must drift by at least (1 - this) to pass, so a lower value +# is stricter. Passive anti-photo only — it does NOT stop a video replay. +# If genuine faces are being rejected, raise this toward 0.99. +# Default: 0.97 +# frame_variance_max_similarity = 0.97 + +# Minimum per-face standard deviation (measured on the RAW grayscale frame) for +# the IR texture check. Flat photos/screens score < 5 in IR; real skin > 15. +# Only applied on IR cameras. Lower to reduce false rejects, raise to be stricter. +# Default: 10.0 +# ir_texture_min_stddev = 10.0 + # Require landmark movement between frames to pass liveness check. # Tracks facial landmark positions across frames to detect static images. # Experimental; off by default. diff --git a/crates/facelock-camera/src/device.rs b/crates/facelock-camera/src/device.rs index ffb980e..565d706 100644 --- a/crates/facelock-camera/src/device.rs +++ b/crates/facelock-camera/src/device.rs @@ -49,45 +49,114 @@ pub fn validate_device(path: &str) -> Result { query_device(path) } +/// Provenance of an IR classification decision, for logging and honesty. +/// +/// Ordered by authoritativeness: a `Quirk` hit is definitive; `Format` means a +/// native IR capture format (GREY/Y16) corroborated by an IR name token; `Name` +/// means an IR name token alone; `None` means not classified as IR. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IrSource { + /// Hardware quirks DB `force_ir = true` — authoritative. + Quirk, + /// Native IR format (GREY/Y16) corroborated by an IR name token. + Format, + /// IR name token ("ir"/"infrared") present, no IR capture format. + Name, + /// Not classified as IR. + None, +} + +/// True if the device name contains a whole `ir` or `infrared` token. +/// +/// Tokenizes on non-alphanumeric boundaries so that substrings like the "ir" in +/// "Sirius" or "AIR-Cam" do NOT falsely match — only a standalone token counts. +fn has_ir_name_token(name: &str) -> bool { + name.split(|c: char| !c.is_ascii_alphanumeric()) + .any(|tok| tok.eq_ignore_ascii_case("ir") || tok.eq_ignore_ascii_case("infrared")) +} + /// Heuristic: is this likely an IR camera? -/// Checks device name for "ir"/"infrared" or format list for GREY/Y16. -/// Also checks the hardware quirks database for `force_ir` overrides. +/// +/// See [`ir_source_with_quirks`] for the decision rules; this is the boolean form. pub fn is_ir_camera(device: &DeviceInfo) -> bool { - is_ir_camera_with_quirks(device, None) + ir_source(device) != IrSource::None } -/// Like `is_ir_camera` but accepts a quirks database for device-specific overrides. +/// Like [`is_ir_camera`] but accepts a quirks database for device-specific overrides. pub fn is_ir_camera_with_quirks( device: &DeviceInfo, quirks: Option<&crate::quirks::QuirksDb>, ) -> bool { - // Check quirks database first (most authoritative) + ir_source_with_quirks(device, quirks) != IrSource::None +} + +/// Classify a device's IR provenance without a quirks database. +pub fn ir_source(device: &DeviceInfo) -> IrSource { + ir_source_with_quirks(device, None) +} + +/// Classify a device's IR provenance, honoring the quirks DB as authoritative. +/// +/// Decision rules (H1 fix — mere *availability* of GREY/Y16 is NOT proof of IR): +/// 1. A quirks DB `force_ir` value is authoritative in both directions. +/// 2. A native IR capture format (GREY/Y16) counts only when corroborated by an +/// IR name token → [`IrSource::Format`]. +/// 3. An IR name token alone → [`IrSource::Name`]. +/// 4. Otherwise → [`IrSource::None`] (a plain RGB webcam that merely enumerates +/// GREY is not treated as IR). +pub fn ir_source_with_quirks( + device: &DeviceInfo, + quirks: Option<&crate::quirks::QuirksDb>, +) -> IrSource { + // 1. Quirks database is authoritative (both true and false). if let Some(db) = quirks { if let Some(quirk) = db.find_match(device) { if let Some(force_ir) = quirk.force_ir { - return force_ir; + return if force_ir { + IrSource::Quirk + } else { + IrSource::None + }; } } } - // Fall back to heuristic detection - let name_lower = device.name.to_lowercase(); - let has_ir_name = name_lower.contains("ir") || name_lower.contains("infrared"); + let has_ir_name = has_ir_name_token(&device.name); let has_ir_format = device .formats .iter() .any(|f| matches!(f.fourcc.as_str(), "GREY" | "Y16 ")); - has_ir_name || has_ir_format + + match (has_ir_name, has_ir_format) { + // Native IR format corroborated by the name token — strongest heuristic. + (true, true) => IrSource::Format, + // Name token alone is sufficient (e.g. "Infrared Camera"). + (true, false) => IrSource::Name, + // Format alone (or nothing) is NOT sufficient — this is the H1 bypass fix. + (false, _) => IrSource::None, + } } /// Auto-detect the best available video capture device. -/// Prefers IR cameras, falls back to the first available device. +/// +/// Prefers a quirks-confirmed IR device, then a heuristically-IR device (name +/// token), then falls back to the first enumerated device. It never auto-selects +/// an unknown camera *just because* it self-reports a GREY/Y16 format (H1). +/// +/// NOTE (seam for Plan 02): device selection here is by capability/heuristic, not +/// by stable device identity. Plan 02 will pin the enrolled camera by identity. pub fn auto_detect_device() -> Result { let devices = list_devices()?; + let quirks = crate::quirks::QuirksDb::load(); devices .iter() - .find(|d| is_ir_camera(d)) - .or(devices.first()) + .find(|d| ir_source_with_quirks(d, Some(&quirks)) == IrSource::Quirk) + .or_else(|| { + devices + .iter() + .find(|d| ir_source_with_quirks(d, Some(&quirks)) != IrSource::None) + }) + .or_else(|| devices.first()) .cloned() .ok_or_else(|| FacelockError::Camera("no video devices found".into())) } @@ -153,68 +222,125 @@ fn query_device(path: &str) -> Result { mod tests { use super::*; - #[test] - fn is_ir_camera_grey_format() { - let device = DeviceInfo { - path: "/dev/video0".into(), - name: "USB Camera".into(), + fn device_with(name: &str, fourccs: &[&str]) -> DeviceInfo { + DeviceInfo { + path: "/dev/nonexistent_test_video".into(), + name: name.into(), driver: "uvcvideo".into(), capabilities: vec![], - formats: vec![FormatInfo { - fourcc: "GREY".into(), - description: "Greyscale".into(), - sizes: vec![(640, 480)], - }], - }; - assert!(is_ir_camera(&device)); + formats: fourccs + .iter() + .map(|f| FormatInfo { + fourcc: (*f).into(), + description: "test".into(), + sizes: vec![(640, 480)], + }) + .collect(), + } + } + + #[test] + fn is_ir_camera_grey_format_alone_is_not_ir() { + // H1 fix: merely enumerating GREY is NOT proof of IR. An RGB webcam that + // advertises a GREY format with no IR name token / quirk is not-IR. + let device = device_with("USB Camera", &["GREY"]); + assert!(!is_ir_camera(&device)); + assert_eq!(ir_source(&device), IrSource::None); + } + + #[test] + fn ir_classification_corpus() { + // Real RGB camera name strings must classify not-IR, even the ones + // whose names contain the substring "ir" but not the token "ir". + for name in [ + "Integrated Webcam", + "USB2.0 HD UVC WebCam", + "AIR-Cam", + "Sirius", + "Chicony USB2.0 Camera", + ] { + let dev = device_with(name, &["YUYV", "MJPG"]); + assert!(!is_ir_camera(&dev), "{name} should be not-IR"); + } + // A GREY-only RGB cam is still not-IR without corroboration. + assert!(!is_ir_camera(&device_with("Generic Cam", &["GREY"]))); + // A name IR token classifies IR. + assert_eq!( + ir_source(&device_with("Integrated IR Camera", &["YUYV"])), + IrSource::Name + ); + assert_eq!( + ir_source(&device_with("Infrared Camera", &["MJPG"])), + IrSource::Name + ); } #[test] fn is_ir_camera_mjpg_only() { - let device = DeviceInfo { - path: "/dev/video0".into(), - name: "USB Camera".into(), - driver: "uvcvideo".into(), - capabilities: vec![], - formats: vec![FormatInfo { - fourcc: "MJPG".into(), - description: "Motion JPEG".into(), - sizes: vec![(640, 480)], - }], - }; + let device = device_with("USB Camera", &["MJPG"]); assert!(!is_ir_camera(&device)); } #[test] fn is_ir_camera_infrared_name() { - let device = DeviceInfo { - path: "/dev/video0".into(), - name: "Infrared Camera".into(), - driver: "uvcvideo".into(), - capabilities: vec![], - formats: vec![FormatInfo { - fourcc: "MJPG".into(), - description: "Motion JPEG".into(), - sizes: vec![(640, 480)], - }], - }; + // Name token "infrared" is sufficient on its own. + let device = device_with("Infrared Camera", &["MJPG"]); assert!(is_ir_camera(&device)); + assert_eq!(ir_source(&device), IrSource::Name); } #[test] - fn is_ir_camera_y16_format() { - let device = DeviceInfo { - path: "/dev/video0".into(), - name: "Depth Camera".into(), - driver: "uvcvideo".into(), - capabilities: vec![], - formats: vec![FormatInfo { - fourcc: "Y16 ".into(), - description: "16-bit Greyscale".into(), - sizes: vec![(640, 480)], - }], - }; + fn is_ir_camera_y16_name_token_corroborated_is_format() { + // Y16 native format corroborated by an IR name token → Format provenance. + let device = device_with("Integrated IR Camera", &["Y16 "]); assert!(is_ir_camera(&device)); + assert_eq!(ir_source(&device), IrSource::Format); + } + + #[test] + fn is_ir_camera_y16_without_name_is_not_ir() { + // Y16 alone (no IR name token, no quirk) is no longer proof of IR. + let device = device_with("Depth Camera", &["Y16 "]); + assert!(!is_ir_camera(&device)); + } + + #[test] + fn quirk_force_ir_is_authoritative() { + let mut db = crate::quirks::QuirksDb::default(); + db.push_quirk_for_test(crate::quirks::Quirk { + vendor_id: None, + product_id: None, + name_pattern: Some("(?i)generic".into()), + force_ir: Some(true), + emitter_xu_guid: None, + emitter_xu_selector: None, + warmup_frames: None, + format_preference: None, + rotation: None, + notes: Some("test force_ir".into()), + }); + // No IR name token, no IR format — quirk alone makes it IR. + let device = device_with("Generic Camera", &["YUYV"]); + assert!(is_ir_camera_with_quirks(&device, Some(&db))); + assert_eq!(ir_source_with_quirks(&device, Some(&db)), IrSource::Quirk); + + // A quirk with force_ir = false is authoritative "not IR" even if the + // name has an IR token. + let mut db_off = crate::quirks::QuirksDb::default(); + db_off.push_quirk_for_test(crate::quirks::Quirk { + vendor_id: None, + product_id: None, + name_pattern: Some("(?i)ir".into()), + force_ir: Some(false), + emitter_xu_guid: None, + emitter_xu_selector: None, + warmup_frames: None, + format_preference: None, + rotation: None, + notes: None, + }); + let ir_named = device_with("Integrated IR Camera", &["GREY"]); + assert!(!is_ir_camera_with_quirks(&ir_named, Some(&db_off))); } #[test] diff --git a/crates/facelock-camera/src/lib.rs b/crates/facelock-camera/src/lib.rs index d9a3730..db1b6f0 100644 --- a/crates/facelock-camera/src/lib.rs +++ b/crates/facelock-camera/src/lib.rs @@ -6,8 +6,8 @@ pub mod quirks; pub use capture::{Camera, is_dark_with_config}; pub use device::{ - DeviceInfo, FormatInfo, auto_detect_device, is_ir_camera, is_ir_camera_with_quirks, - list_devices, validate_device, + DeviceInfo, FormatInfo, IrSource, auto_detect_device, ir_source, ir_source_with_quirks, + is_ir_camera, is_ir_camera_with_quirks, list_devices, validate_device, }; pub use ir_emitter::EmitterXuInfo; pub use preprocess::{check_ir_texture, clahe, extract_bbox_region, rgb_to_gray, yuyv_to_rgb}; diff --git a/crates/facelock-camera/src/preprocess.rs b/crates/facelock-camera/src/preprocess.rs index 32b270e..3ba492f 100644 --- a/crates/facelock-camera/src/preprocess.rs +++ b/crates/facelock-camera/src/preprocess.rs @@ -188,7 +188,12 @@ pub fn extract_bbox_region(gray: &[u8], bbox: &BoundingBox, width: u32) -> Vec bool { +/// +/// `min_stddev` is the rejection cutoff for the per-face standard deviation of +/// pixel intensity. It MUST be measured on the RAW grayscale frame — running this +/// on a CLAHE-equalized frame inflates the std_dev of flat surfaces and masks +/// photo/screen replays. Configurable via `security.ir_texture_min_stddev`. +pub fn check_ir_texture(gray: &[u8], bbox: &BoundingBox, width: u32, min_stddev: f32) -> bool { let face_pixels = extract_bbox_region(gray, bbox, width); if face_pixels.is_empty() { return false; @@ -200,7 +205,7 @@ pub fn check_ir_texture(gray: &[u8], bbox: &BoundingBox, width: u32) -> bool { .sum::() / face_pixels.len() as f32; let std_dev = variance.sqrt(); - std_dev > 10.0 + std_dev > min_stddev } #[cfg(test)] @@ -267,6 +272,8 @@ mod tests { ); } + const TEST_MIN_STDDEV: f32 = 10.0; + #[test] fn check_ir_texture_uniform_region_false() { let w = 64u32; @@ -278,7 +285,7 @@ mod tests { width: 20.0, height: 20.0, }; - assert!(!check_ir_texture(&gray, &bbox, w)); + assert!(!check_ir_texture(&gray, &bbox, w, TEST_MIN_STDDEV)); } #[test] @@ -298,6 +305,31 @@ mod tests { width: 20.0, height: 20.0, }; - assert!(check_ir_texture(&gray, &bbox, w)); + assert!(check_ir_texture(&gray, &bbox, w, TEST_MIN_STDDEV)); + } + + #[test] + fn check_ir_texture_threshold_is_a_configurable_cutoff() { + // A region with std_dev ~8 sits below the default 10.0 cutoff (flat photo + // territory) but a caller can lower the cutoff to accept it. + let w = 64u32; + let h = 64u32; + let mut gray = vec![100u8; (w * h) as usize]; + // Alternate two values 8 apart => std_dev ~= 8. + for row in 10..30 { + for col in 10..30 { + gray[row * w as usize + col] = if (row + col) % 2 == 0 { 92 } else { 108 }; + } + } + let bbox = BoundingBox { + x: 10.0, + y: 10.0, + width: 20.0, + height: 20.0, + }; + // std_dev is 8, so it fails at the default cutoff (photo-like)... + assert!(!check_ir_texture(&gray, &bbox, w, 10.0)); + // ...but passes if the operator lowers the cutoff below the measured value. + assert!(check_ir_texture(&gray, &bbox, w, 5.0)); } } diff --git a/crates/facelock-camera/src/quirks.rs b/crates/facelock-camera/src/quirks.rs index fe1a2e2..75f2353 100644 --- a/crates/facelock-camera/src/quirks.rs +++ b/crates/facelock-camera/src/quirks.rs @@ -150,6 +150,12 @@ impl QuirksDb { pub fn all(&self) -> &[Quirk] { &self.quirks } + + /// Push a quirk directly (test-only helper for cross-module tests). + #[cfg(test)] + pub fn push_quirk_for_test(&mut self, quirk: Quirk) { + self.quirks.push(quirk); + } } /// Read USB vendor:product IDs from sysfs for a video device. diff --git a/crates/facelock-cli/src/commands/setup.rs b/crates/facelock-cli/src/commands/setup.rs index 1ed3f83..522326c 100644 --- a/crates/facelock-cli/src/commands/setup.rs +++ b/crates/facelock-cli/src/commands/setup.rs @@ -291,10 +291,13 @@ fn wizard_camera_selection(theme: &ColorfulTheme, config: &mut Config) -> anyhow return Ok(()); } - let ir_devices: Vec<_> = devices - .iter() - .filter(|d| facelock_camera::is_ir_camera(d)) - .collect(); + // Consult the quirks DB so IR classification here matches the auth path. + let quirks = facelock_camera::QuirksDb::load(); + let is_ir = |d: &facelock_camera::DeviceInfo| { + facelock_camera::is_ir_camera_with_quirks(d, Some(&quirks)) + }; + + let ir_devices: Vec<_> = devices.iter().filter(|d| is_ir(d)).collect(); // If exactly one IR camera, auto-select it if ir_devices.len() == 1 { @@ -308,11 +311,7 @@ fn wizard_camera_selection(theme: &ColorfulTheme, config: &mut Config) -> anyhow let display_items: Vec = devices .iter() .map(|d| { - let ir_tag = if facelock_camera::is_ir_camera(d) { - " [IR]" - } else { - "" - }; + let ir_tag = if is_ir(d) { " [IR]" } else { "" }; format!("{}{} - {}", d.path, ir_tag, d.name) }) .collect(); @@ -323,7 +322,7 @@ fn wizard_camera_selection(theme: &ColorfulTheme, config: &mut Config) -> anyhow .position(|d| config.device.path.as_ref().is_some_and(|p| d.path == *p)) .or_else(|| { // Default to first IR camera if available - devices.iter().position(facelock_camera::is_ir_camera) + devices.iter().position(&is_ir) }) .unwrap_or(0); diff --git a/crates/facelock-cli/src/direct.rs b/crates/facelock-cli/src/direct.rs index fce366b..6f50360 100644 --- a/crates/facelock-cli/src/direct.rs +++ b/crates/facelock-cli/src/direct.rs @@ -8,8 +8,7 @@ use std::path::Path; use anyhow::{Context, bail}; use facelock_camera::quirks::QuirksDb; use facelock_camera::{ - Camera, DeviceInfo, auto_detect_device, is_ir_camera, is_ir_camera_with_quirks, list_devices, - validate_device, + Camera, DeviceInfo, auto_detect_device, is_ir_camera_with_quirks, list_devices, validate_device, }; use facelock_core::config::DeviceConfig; use facelock_core::config::{Config, EncryptionMethod}; @@ -261,9 +260,16 @@ pub fn list_devices_direct() -> anyhow::Result<()> { return Ok(()); } + // Consult the quirks DB so the displayed [IR] tag matches the authoritative + // decision the auth path makes (e.g. a quirks `force_ir` camera). + let quirks = facelock_camera::QuirksDb::load(); println!("Available video devices:\n"); for dev in &devices { - let ir_tag = if is_ir_camera(dev) { " [IR]" } else { "" }; + let ir_tag = if is_ir_camera_with_quirks(dev, Some(&quirks)) { + " [IR]" + } else { + "" + }; println!(" {}{ir_tag}", dev.path); println!(" Name: {}", dev.name); println!(" Driver: {}", dev.driver); @@ -387,11 +393,12 @@ mod facelock_daemon_auth { landmark_tracker.push(det.landmarks); } - // IR texture check: skip frames where all faces have flat texture + // IR texture check on the RAW frame: skip frames where all faces are flat. + let ir_texture_min = config.security.ir_texture_min_stddev; if device_is_ir { - let all_flat = faces - .iter() - .all(|(det, _)| !check_ir_texture(&frame.gray, &det.bbox, frame.width)); + let all_flat = faces.iter().all(|(det, _)| { + !check_ir_texture(&frame.gray, &det.bbox, frame.width, ir_texture_min) + }); if all_flat { debug!( frame = frame_count, @@ -404,7 +411,9 @@ mod facelock_daemon_auth { let mut frame_matched = false; for (det, embedding) in &faces { // Skip individual faces that fail IR texture check - if device_is_ir && !check_ir_texture(&frame.gray, &det.bbox, frame.width) { + if device_is_ir + && !check_ir_texture(&frame.gray, &det.bbox, frame.width, ir_texture_min) + { debug!( frame = frame_count, "IR texture check failed for face, skipping" @@ -425,7 +434,10 @@ mod facelock_daemon_auth { // Frame variance check + landmark liveness check if config.security.require_frame_variance { if matched_frame_embeddings.len() >= config.security.min_auth_frames as usize - && check_frame_variance(&matched_frame_embeddings) + && check_frame_variance( + &matched_frame_embeddings, + config.security.frame_variance_max_similarity, + ) { // If landmark liveness is required, check it too if config.security.require_landmark_liveness diff --git a/crates/facelock-core/src/config.rs b/crates/facelock-core/src/config.rs index 659b4fd..36bd330 100644 --- a/crates/facelock-core/src/config.rs +++ b/crates/facelock-core/src/config.rs @@ -200,6 +200,17 @@ pub struct SecurityConfig { pub landmark_min_moving: u32, #[serde(default = "default_min_auth_frames")] pub min_auth_frames: u32, + /// Minimum per-face standard deviation (on the RAW grayscale frame) required + /// to pass the IR texture check. Flat photos/screens score low in IR; real + /// skin has micro-texture. Only applied on IR devices. Default 10.0 + /// (docs calibration: flat < 5, real > 15 on raw frames). + #[serde(default = "default_ir_texture_min_stddev")] + pub ir_texture_min_stddev: f32, + /// Maximum consecutive matched-frame cosine similarity allowed by the passive + /// frame-variance check. Higher = more permissive. Default 0.97 (require ≥0.03 + /// drift). Passive anti-photo only; does not defeat video replay. + #[serde(default = "default_frame_variance_max_similarity")] + pub frame_variance_max_similarity: f32, #[serde(default)] pub rate_limit: RateLimitConfig, } @@ -234,6 +245,8 @@ impl Default for SecurityConfig { landmark_displacement_px: default_landmark_displacement_px(), landmark_min_moving: default_landmark_min_moving(), min_auth_frames: default_min_auth_frames(), + ir_texture_min_stddev: default_ir_texture_min_stddev(), + frame_variance_max_similarity: default_frame_variance_max_similarity(), rate_limit: RateLimitConfig::default(), } } @@ -451,6 +464,12 @@ fn default_landmark_displacement_px() -> f32 { fn default_landmark_min_moving() -> u32 { 3 } +fn default_ir_texture_min_stddev() -> f32 { + 10.0 +} +fn default_frame_variance_max_similarity() -> f32 { + crate::types::DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY +} fn default_true() -> bool { true } @@ -575,6 +594,18 @@ impl Config { "recognition.timeout_secs must be > 0".into(), )); } + if !(0.0..=1.0).contains(&self.security.frame_variance_max_similarity) { + return Err(ConfigError::Validation(format!( + "security.frame_variance_max_similarity must be between 0.0 and 1.0, got {}", + self.security.frame_variance_max_similarity + ))); + } + if self.security.ir_texture_min_stddev < 0.0 { + return Err(ConfigError::Validation(format!( + "security.ir_texture_min_stddev must be >= 0.0, got {}", + self.security.ir_texture_min_stddev + ))); + } if let Some(ref sha256) = self.recognition.detector_sha256 && !is_sha256_hex(sha256) { @@ -994,6 +1025,58 @@ key_path = "/etc/facelock/my.key" assert_eq!(config.encryption.key_path, "/etc/facelock/my.key"); } + #[test] + fn antispoof_thresholds_default() { + let toml = r#" +[device] +path = "/dev/video0" +"#; + let config = Config::parse(toml).unwrap(); + assert_eq!(config.security.ir_texture_min_stddev, 10.0); + assert_eq!( + config.security.frame_variance_max_similarity, + crate::types::DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY + ); + } + + #[test] + fn antispoof_thresholds_custom() { + let toml = r#" +[device] +path = "/dev/video0" +[security] +ir_texture_min_stddev = 15.0 +frame_variance_max_similarity = 0.95 +"#; + let config = Config::parse(toml).unwrap(); + assert_eq!(config.security.ir_texture_min_stddev, 15.0); + assert_eq!(config.security.frame_variance_max_similarity, 0.95); + } + + #[test] + fn reject_out_of_range_frame_variance_max_similarity() { + let toml = r#" +[device] +path = "/dev/video0" +[security] +frame_variance_max_similarity = 1.5 +"#; + let err = Config::parse(toml).unwrap_err(); + assert!(matches!(err, ConfigError::Validation(_))); + } + + #[test] + fn reject_negative_ir_texture_min_stddev() { + let toml = r#" +[device] +path = "/dev/video0" +[security] +ir_texture_min_stddev = -1.0 +"#; + let err = Config::parse(toml).unwrap_err(); + assert!(matches!(err, ConfigError::Validation(_))); + } + #[test] fn warmup_frames_zero() { let toml = r#" diff --git a/crates/facelock-core/src/types.rs b/crates/facelock-core/src/types.rs index 42c30d3..bcfb0a9 100644 --- a/crates/facelock-core/src/types.rs +++ b/crates/facelock-core/src/types.rs @@ -87,22 +87,31 @@ pub fn cosine_similarity(a: &FaceEmbedding, b: &FaceEmbedding) -> f32 { a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() } -/// Threshold below which consecutive embeddings are considered "varied enough" -/// to rule out a static photo attack. -pub const FRAME_VARIANCE_THRESHOLD: f32 = 0.998; +/// Default maximum consecutive-frame cosine similarity for the passive +/// frame-variance check. Consecutive matched frames must drift by at least +/// `1 - this` (≈0.03) to be accepted, aligning with the documented 0.02–0.10 +/// live-face drift range. Configurable via `security.frame_variance_max_similarity`. +/// +/// NOTE: frame-variance is a *passive* anti-photo heuristic only. It raises the +/// bar for a *static* image but does NOT defeat a video replay (which contains +/// real inter-frame motion). IR enforcement remains the load-bearing defense. +pub const DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY: f32 = 0.97; /// Check that matched embeddings show sufficient variance (anti-photo-attack). /// Compares all consecutive pairs — every pair must differ enough to rule out /// a static image. Real faces produce micro-movements between frames. -pub fn check_frame_variance(embeddings: &[FaceEmbedding]) -> bool { +/// +/// `max_similarity` is the rejection cutoff: any consecutive pair with cosine +/// similarity strictly greater than `max_similarity` fails the check (too static). +pub fn check_frame_variance(embeddings: &[FaceEmbedding], max_similarity: f32) -> bool { if embeddings.len() < 2 { return false; } - // Every consecutive pair must show movement (similarity below threshold). - // A static photo produces near-identical consecutive embeddings (>0.998). + // Every consecutive pair must show movement (similarity at or below the cutoff). + // A static photo produces near-identical consecutive embeddings. for window in embeddings.windows(2) { let sim = cosine_similarity(&window[0], &window[1]); - if sim >= FRAME_VARIANCE_THRESHOLD { + if sim > max_similarity { return false; } } @@ -392,6 +401,60 @@ mod tests { ); } + /// Build a unit embedding pointing mostly along `axis` with a small tilt so + /// consecutive frames can be made to drift by a controlled amount. + fn tilted_unit(primary: f32, secondary: f32) -> FaceEmbedding { + let mut e: FaceEmbedding = [0.0; 512]; + let norm = (primary * primary + secondary * secondary).sqrt(); + e[0] = primary / norm; + e[1] = secondary / norm; + e + } + + #[test] + fn frame_variance_rejects_near_static_sequence() { + // Near-identical consecutive embeddings (drift < 0.03, sim > 0.97) must fail. + let a = tilted_unit(1.0, 0.02); + let b = tilted_unit(1.0, 0.03); + let c = tilted_unit(1.0, 0.04); + let seq = [a, b, c]; + // Sanity: consecutive similarities are all above 0.97. + assert!(cosine_similarity(&seq[0], &seq[1]) > 0.97); + assert!( + !check_frame_variance(&seq, DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), + "near-static sequence must be rejected" + ); + } + + #[test] + fn frame_variance_accepts_live_like_sequence() { + // Live-like drift (>= 0.03 per step, sim < 0.97) must pass. + let a = tilted_unit(1.0, 0.0); + let b = tilted_unit(1.0, 0.30); + let c = tilted_unit(1.0, 0.60); + let seq = [a, b, c]; + assert!(cosine_similarity(&seq[0], &seq[1]) < 0.97); + assert!(cosine_similarity(&seq[1], &seq[2]) < 0.97); + assert!( + check_frame_variance(&seq, DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), + "live-like sequence must pass" + ); + } + + #[test] + fn frame_variance_threshold_is_configurable() { + // A sequence that passes at a strict (low) max_similarity still passes, + // and one that only just drifts can be tuned by the caller. + let a = tilted_unit(1.0, 0.10); + let b = tilted_unit(1.0, 0.30); + let seq = [a, b]; + let sim = cosine_similarity(&seq[0], &seq[1]); + // With a max just below the actual similarity, it is rejected... + assert!(!check_frame_variance(&seq, sim - 0.001)); + // ...and with a max just above, it is accepted. + assert!(check_frame_variance(&seq, sim + 0.001)); + } + #[test] fn cosine_similarity_opposite() { let mut a = [0.0f32; 512]; diff --git a/crates/facelock-daemon/src/auth.rs b/crates/facelock-daemon/src/auth.rs index d5898e4..821854f 100644 --- a/crates/facelock-daemon/src/auth.rs +++ b/crates/facelock-daemon/src/auth.rs @@ -269,26 +269,19 @@ fn authenticate_inner( landmark_tracker.push(det.landmarks); } - // Compute CLAHE-enhanced grayscale only for IR texture checks - let clahe_gray = if device_is_ir { - Some(facelock_camera::preprocess::clahe( - &frame.gray, - frame.width, - frame.height, - )) - } else { - None - }; - // IR texture check: when using an IR camera, verify each detected face // has real skin texture (not a flat photo/screen replay attack). // Only applied to IR frames — RGB texture varies too much and would // cause false positives. + // + // Runs on the RAW grayscale frame. CLAHE is deliberately NOT applied here: + // equalizing the frame inflates the std_dev of flat surfaces and masks the + // very photo/screen replays this check exists to catch (H3). + let ir_texture_min = config.security.ir_texture_min_stddev; if device_is_ir { - let gray = clahe_gray.as_deref().unwrap_or(&frame.gray); - let all_flat = faces - .iter() - .all(|(det, _)| !check_ir_texture(gray, &det.bbox, frame.width)); + let all_flat = faces.iter().all(|(det, _)| { + !check_ir_texture(&frame.gray, &det.bbox, frame.width, ir_texture_min) + }); if all_flat { debug!( frame = frame_count, @@ -302,11 +295,7 @@ fn authenticate_inner( for (det, embedding) in &faces { // Skip individual faces that fail IR texture check if device_is_ir - && !check_ir_texture( - clahe_gray.as_deref().unwrap_or(&frame.gray), - &det.bbox, - frame.width, - ) + && !check_ir_texture(&frame.gray, &det.bbox, frame.width, ir_texture_min) { debug!( frame = frame_count, @@ -337,7 +326,10 @@ fn authenticate_inner( // Frame variance check + landmark liveness check if config.security.require_frame_variance { if matched_frame_embeddings.len() >= config.security.min_auth_frames as usize - && check_frame_variance(&matched_frame_embeddings) + && check_frame_variance( + &matched_frame_embeddings, + config.security.frame_variance_max_similarity, + ) { // If landmark liveness is required, check it too if config.security.require_landmark_liveness && !landmark_tracker.check_liveness() { diff --git a/crates/facelock-daemon/src/handler.rs b/crates/facelock-daemon/src/handler.rs index 2d6783f..a03ffd3 100644 --- a/crates/facelock-daemon/src/handler.rs +++ b/crates/facelock-daemon/src/handler.rs @@ -401,7 +401,10 @@ impl Handler { }, DaemonRequest::ListDevices => { - use facelock_camera::{is_ir_camera, list_devices}; + use facelock_camera::{QuirksDb, is_ir_camera_with_quirks, list_devices}; + // Consult the quirks DB so the reported is_ir matches the + // authoritative decision the auth path makes. + let quirks = QuirksDb::load(); match list_devices() { Ok(devices) => DaemonResponse::Devices( devices @@ -410,7 +413,7 @@ impl Handler { path: d.path.clone(), name: d.name.clone(), driver: d.driver.clone(), - is_ir: is_ir_camera(d), + is_ir: is_ir_camera_with_quirks(d, Some(&quirks)), formats: d .formats .iter() diff --git a/docs/contracts.md b/docs/contracts.md index 4839e43..59b7434 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -80,7 +80,7 @@ TOML format. All keys optional — camera auto-detected, sensible defaults for e | `[recognition]` | `threshold`, `timeout_secs`, `detector_model`, `detector_sha256`, `embedder_model`, `embedder_sha256`, `threads`, `execution_provider` | | `[daemon]` | `mode` (DaemonMode enum), `model_dir`, `idle_timeout_secs` | | `[storage]` | `db_path` | -| `[security]` | `disabled`, `suppress_unknown`, `require_landmark_liveness`, `require_ir`, `require_frame_variance`, `min_auth_frames`, `abort_if_ssh`, `abort_if_lid_closed`, `pam_policy`, `rate_limit` | +| `[security]` | `disabled`, `suppress_unknown`, `require_landmark_liveness`, `require_ir`, `require_frame_variance`, `frame_variance_max_similarity`, `ir_texture_min_stddev`, `min_auth_frames`, `abort_if_ssh`, `abort_if_lid_closed`, `pam_policy`, `rate_limit` | | `[notification]` | `mode` (off/terminal/desktop/both), `notify_prompt`, `notify_on_success`, `notify_on_failure` | | `[snapshots]` | `mode` (off/all/failure/success), `dir` | | `[encryption]` | `method` (none/keyfile/tpm), `key_path`, `sealed_key_path` | @@ -168,11 +168,16 @@ pam_facelock(): for user |---------|--------|---------| | IR camera enforcement | `security.require_ir` | **true** | | Frame variance check | `security.require_frame_variance` | **true** | +| Frame variance cutoff | `security.frame_variance_max_similarity` | 0.97 | +| IR texture cutoff (raw frame) | `security.ir_texture_min_stddev` | 10.0 | | Landmark liveness | `security.require_landmark_liveness` | **false** | | Minimum auth frames | `security.min_auth_frames` | 3 | -| Variance threshold | `FRAME_VARIANCE_THRESHOLD` | 0.998 | +| Frame variance default const | `DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY` | 0.97 | -These defaults must not be weakened without security review. +IR classification requires a whole `ir`/`infrared` name token or a quirks `force_ir` +entry; a GREY/Y16 format alone is not treated as IR. Frame variance is passive +anti-photo only (does not stop video replay); IR texture is measured on the raw frame, +never CLAHE. These defaults must not be weakened without security review. ## Models diff --git a/docs/security.md b/docs/security.md index 1fcd844..8ebd3db 100644 --- a/docs/security.md +++ b/docs/security.md @@ -38,21 +38,24 @@ Add `security.require_ir` config flag, **default true**: require_ir = true # Refuse to authenticate on RGB-only cameras ``` -Implementation: -```rust -// In camera capture, check if the negotiated format indicates IR -fn is_ir_camera(device: &DeviceInfo) -> bool { - // IR cameras typically support GREY (8-bit grayscale) or Y16 (16-bit) - // as their native format. RGB-only cameras are not IR. - device.formats.iter().any(|f| { - matches!(f.fourcc.as_str(), "GREY" | "Y16 " | "YUYV") - && device.name.to_lowercase().contains("ir") - || device.name.to_lowercase().contains("infrared") - }) -} +Implementation (`facelock-camera/src/device.rs`, `ir_source_with_quirks`): -// In daemon auth flow, before attempting recognition: -if config.security.require_ir && !is_ir_camera(&device_info) { +```rust +// IR classification is honest about its evidence, surfaced as IrSource: +// Quirk – hardware quirks DB force_ir = true (authoritative, both directions) +// Format – native IR format (GREY/Y16) CORROBORATED by an IR name token +// Name – an "ir"/"infrared" name *token* (tokenized, not substring) +// None – not IR +// +// Precedence: +// 1. quirks DB force_ir is authoritative; +// 2. a native GREY/Y16 format counts ONLY when corroborated by a name token; +// 3. a name token alone is sufficient; +// 4. otherwise not-IR. +pub fn ir_source_with_quirks(device, quirks) -> IrSource { ... } + +// In the auth flow (daemon pre_check and oneshot), before recognition: +if config.security.require_ir && !device_is_ir { return DaemonResponse::Error { message: "IR camera required for authentication. Set security.require_ir = false to override (NOT RECOMMENDED).".into() }; @@ -61,37 +64,50 @@ if config.security.require_ir && !is_ir_camera(&device_info) { **Rationale**: Phone screens and printed photos do not emit infrared light correctly. An IR camera sees a flat, textureless surface where a real face would have depth and skin texture in IR. This single check eliminates the vast majority of spoofing attacks. -**Limitation**: IR camera detection by format/name is heuristic. Some cameras report YUYV but are actually IR. The `facelock devices` command should display whether each camera is detected as IR. +**Why mere GREY/Y16 availability is not enough (H1)**: many ordinary RGB UVC webcams *enumerate* a GREY format alongside YUYV/MJPG. The previous heuristic (`contains("ir")` OR any GREY/Y16 format) misclassified those as IR, silently defeating `require_ir = true`. It also matched the substring "ir" inside unrelated names ("Sirius", "AIR-Cam"). The classifier now requires a whole `ir`/`infrared` **token** or a **quirks `force_ir`** entry, and treats a GREY/Y16 format as IR **only when corroborated** by one of those. This is why `require_ir` is now load-bearing rather than trivially bypassable. + +**Limitation**: classification is still heuristic without a hardware allow-list. Some genuine IR cameras report neither an IR name token nor a known quirk; add a quirks `force_ir` entry (`/etc/facelock/quirks.d/`) for such hardware. The `facelock devices` command displays whether each camera is detected as IR. Device *identity* pinning (rather than capability heuristics) is the successor fix (Plan 02). #### B. Frame Variance Check (Required) -Require minimum variance across consecutive frames during authentication: +Require minimum variance across consecutive matched frames during authentication. +Every consecutive pair of matched-frame embeddings must drift by at least +`1 - frame_variance_max_similarity` (default 0.03), i.e. cosine similarity must be +at or below the cutoff. The cutoff is configurable +(`security.frame_variance_max_similarity`, default **0.97**, in +`facelock-core/src/types.rs` as `DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY`): ```rust -/// Check that frames have sufficient variance (not a static image) -fn check_frame_variance(embeddings: &[(Detection, FaceEmbedding)], min_frames: usize) -> bool { - if embeddings.len() < min_frames { - return false; - } - // Compute pairwise similarity between consecutive embeddings - // Real faces have micro-movements causing slight embedding variation - // A static photo produces near-identical embeddings (similarity > 0.99) - let mut max_similarity = 0.0f32; +/// Reject when any consecutive pair is too similar (static image). +pub fn check_frame_variance(embeddings: &[FaceEmbedding], max_similarity: f32) -> bool { + if embeddings.len() < 2 { return false; } for window in embeddings.windows(2) { - let sim = cosine_similarity(&window[0].1, &window[1].1); - max_similarity = max_similarity.max(sim); + // Real faces vary by 0.02-0.10 between frames; a static photo is near-identical. + if cosine_similarity(&window[0], &window[1]) > max_similarity { return false; } } - // If ALL consecutive frames are too similar, likely a static image - // Real faces typically vary by 0.02-0.10 between frames - max_similarity < 0.998 // FRAME_VARIANCE_THRESHOLD in facelock-core/types.rs + true } ``` +**Honest scope — this does NOT stop video replay.** Frame-variance only rules out a +*static* image (printed photo, single frozen frame). A recorded video of the enrolled +user contains genuine inter-frame motion and will pass this check. Frame-variance is a +cheap passive filter; **IR enforcement (§A) is the load-bearing anti-spoof defense**, and +active liveness (opt-in landmark/blink) is the answer to video replay. The default was +tightened from 0.998 (which accepted almost any drift) to 0.97 so the check actually +demands the documented 0.02-0.10 live-face drift. + +**False-reject tradeoff**: 0.97 is the low end of the live-drift band. Genuinely still +users under steady lighting can occasionally dip below 0.03 drift; if false rejects rise, +raise `frame_variance_max_similarity` (toward 0.99) — it is the tuning knob. It stays +purely passive either way. + Config: ```toml [security] -require_frame_variance = true # Reject static images (photo attack defense) -min_auth_frames = 3 # Minimum frames before accepting match +require_frame_variance = true # Reject static images (photo attack defense) +frame_variance_max_similarity = 0.97 # Max consecutive-frame similarity (require >=0.03 drift) +min_auth_frames = 3 # Minimum matched frames before accepting ``` #### C. Dark Frame / IR Texture Validation (Recommended) @@ -103,18 +119,27 @@ In IR mode, verify that the face region has expected IR texture characteristics: - Reject faces with abnormally low texture variance ```rust -fn check_ir_texture(gray: &[u8], bbox: &BoundingBox, width: u32) -> bool { - // Extract face region pixels - let face_pixels = extract_region(gray, bbox, width); - // Compute standard deviation +pub fn check_ir_texture(gray: &[u8], bbox: &BoundingBox, width: u32, min_stddev: f32) -> bool { + let face_pixels = extract_bbox_region(gray, bbox, width); + if face_pixels.is_empty() { return false; } let mean: f32 = face_pixels.iter().map(|&p| p as f32).sum::() / face_pixels.len() as f32; let variance: f32 = face_pixels.iter().map(|&p| (p as f32 - mean).powi(2)).sum::() / face_pixels.len() as f32; - let std_dev = variance.sqrt(); - // Real IR faces have std_dev > ~15; flat surfaces are < 5 - std_dev > 10.0 + variance.sqrt() > min_stddev } ``` +**Run on the RAW frame, not CLAHE (H3)**: this check MUST see the raw grayscale frame. +The auth loop previously fed a **CLAHE**-equalized frame into `check_ir_texture`. CLAHE +(Contrast-Limited Adaptive Histogram Equalization) stretches local contrast, which +*inflates* the std_dev of an otherwise flat photo/screen and pushes it above the cutoff — +i.e. CLAHE was masking exactly the spoof this check exists to catch. CLAHE now belongs +only to the recognition/embedding path; texture measurement uses `frame.gray` directly. + +**Raw-frame calibration**: on the raw frame, flat surfaces (photos/screens in IR) score +std_dev **< 5**, real IR skin scores **> 15**. The cutoff `security.ir_texture_min_stddev` +defaults to **10.0** (between the two bands). Lower it if real faces are being rejected; +raise it toward 15 to be stricter. Applied on IR devices only (RGB texture is too variable). + ### 2. Model Tampering **Attack**: Replace ONNX model files with adversarial models that always match (or match specific attackers). @@ -298,8 +323,10 @@ systemd-analyze security facelock-daemon.service disabled = false abort_if_ssh = true # Refuse face auth over SSH abort_if_lid_closed = true # Refuse if laptop lid closed -require_ir = true # CRITICAL: refuse RGB-only cameras (anti-spoof) -require_frame_variance = true # Reject static images (photo defense) +require_ir = true # CRITICAL: refuse non-IR cameras (anti-spoof, load-bearing) +require_frame_variance = true # Reject static images (photo defense; NOT video replay) +frame_variance_max_similarity = 0.97 # Max consecutive-frame similarity (require >=0.03 drift) +ir_texture_min_stddev = 10.0 # Min raw-frame face std_dev for IR texture (flat < 5, real > 15) require_landmark_liveness = false # Require landmark movement between frames (off by default) min_auth_frames = 3 # Minimum frames before accepting (variance check) suppress_unknown = false # Log unknown faces (true = suppress unknown-face log entries) diff --git a/test/container-config.toml b/test/container-config.toml index 2440151..8a1c90c 100644 --- a/test/container-config.toml +++ b/test/container-config.toml @@ -1,3 +1,19 @@ +# Container E2E test posture. +# +# The camera mounted into the container (v4l2loopback / a UVC RGB webcam) is an +# ordinary RGB device: it has no IR name token and no quirks force_ir entry, so +# under the (correct, stricter) IR classifier it is NOT an IR camera. +# +# require_ir is therefore set to false HERE ON PURPOSE so the live enroll/auth +# camera suites exercise the recognition path instead of short-circuiting on the +# IR gate. This is a deliberate test-harness choice, not the product default +# (which is require_ir = true). The dedicated refusal assertion in +# run-oneshot-tests.sh flips require_ir = true against this same RGB camera to +# prove the gate still fires. +# +# require_frame_variance is likewise disabled for the container: the synthetic / +# looped test camera does not produce the natural inter-frame micro-motion of a +# live face, so the passive frame-variance drift check would false-reject it. [device] max_height = 480 dark_threshold = 1.0 diff --git a/test/run-oneshot-tests.sh b/test/run-oneshot-tests.sh index a80d041..057676d 100755 --- a/test/run-oneshot-tests.sh +++ b/test/run-oneshot-tests.sh @@ -96,6 +96,31 @@ run_test_contains "facelock test (oneshot)" \ run_test "facelock auth authenticates (oneshot)" \ "timeout --foreground $LIVE_TIMEOUT facelock auth --user testuser --config /etc/facelock/config.toml" +# Anti-spoof (Plan 01, H1 fix): with require_ir = true, a camera that is NOT a +# confirmed IR device MUST be refused (exit 2). To make this deterministic on any +# host — including hosts whose test camera is a genuine IR device with a quirks +# force_ir entry — we temporarily move the system quirks DB aside so the camera is +# classified by the heuristic alone. Under the fixed heuristic, a camera that only +# enumerates GREY/YUYV with no "ir"/"infrared" name token is NOT IR (previously it +# was misclassified as IR by the mere presence of a GREY format), so require_ir +# refuses. Restores the quirks DB afterward. +# CAMERA-REQUIRED: only meaningful on a host with /dev/video*; skipped headless. +QUIRKS_SYS="/usr/share/facelock/quirks.d" +QUIRKS_BAK="/tmp/facelock-quirks.bak" +rm -rf "$QUIRKS_BAK" +[ -d "$QUIRKS_SYS" ] && mv "$QUIRKS_SYS" "$QUIRKS_BAK" +cp /etc/facelock/config.toml /tmp/facelock-requireir.toml +if grep -q '^require_ir' /tmp/facelock-requireir.toml; then + sed -i 's|^require_ir.*|require_ir = true|' /tmp/facelock-requireir.toml +else + sed -i '/^\[security\]/a require_ir = true' /tmp/facelock-requireir.toml +fi +run_test "facelock auth refuses non-IR camera when require_ir=true (anti-spoof, H1)" \ + "facelock auth --user testuser --config /tmp/facelock-requireir.toml" \ + 2 +# Restore the system quirks DB. +[ -d "$QUIRKS_BAK" ] && rm -rf "$QUIRKS_SYS" && mv "$QUIRKS_BAK" "$QUIRKS_SYS" + # PAM authentication (the real deal — no daemon) run_test "pamtester authenticates (oneshot, no daemon)" \ "timeout --foreground $LIVE_TIMEOUT pamtester facelock-test testuser authenticate" From 383e4ab6debb673063cac80b7b7093b8a882a4aa Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Fri, 3 Jul 2026 18:38:55 -0700 Subject: [PATCH 2/8] fix(camera): node-level IR disambiguation for multi-node force_ir quirk devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardware-verified regression (Logitech BRIO 046d:085e): making the quirks DB authoritative classified EVERY V4L2 capture node of a quirk-matched USB device as IR. The BRIO exposes /dev/video0 (RGB, YUYV/MJPG) and /dev/video2 (IR, native GREY) under one VID:PID, so both were tagged [IR]: setup lost its auto-select, and auto-detect captured from the RGB sensor (white LED) instead of the IR sensor. force_ir now means "this USB device has an IR sensor", not "every capture node of it is IR": when multiple nodes share one quirk-matched USB identity AND at least one exposes an IR-like format (GREY/Y16, or the quirk's format_preference), only the format-bearing node(s) classify IR; siblings fall back to the quirk-free heuristic. If no node has an IR-like format, force_ir is trusted for all nodes (some quirks exist precisely because the camera advertises no IR-like format). IrSource semantics are preserved. - device.rs: classify_ir_sources (list, sibling-aware), ir_source_resolved / is_ir_camera_resolved (single device, enumerates siblings), auto-detect now prefers the format-corroborated IR node; sysfs USB-ID reads kept at the call boundary for testability (classify_ir_sources_with_ids) - quirks.rs: find_match_with_ids (injectable USB IDs) - callers gating require_ir / displaying [IR] use the sibling-aware forms (oneshot auth, daemon build_handler, direct, D-Bus ListDevices, setup wizard) - capture.rs: log the negotiated capture format at camera open - quirks defaults: BRIO entry gains format_preference = "GREY" - docs: security.md §A and contracts.md describe node-level disambiguation - test: BRIO-topology corpus tests; oneshot container script asserts exactly one [IR] node, GREY-native, auto-detect picks it, negotiated format GREY Co-Authored-By: Claude Fable 5 --- config/quirks.d/00-defaults.toml | 5 + crates/facelock-camera/src/capture.rs | 7 + crates/facelock-camera/src/device.rs | 400 +++++++++++++++++++-- crates/facelock-camera/src/lib.rs | 5 +- crates/facelock-camera/src/quirks.rs | 17 +- crates/facelock-cli/src/commands/auth.rs | 6 +- crates/facelock-cli/src/commands/daemon.rs | 8 +- crates/facelock-cli/src/commands/setup.rs | 26 +- crates/facelock-cli/src/direct.rs | 14 +- crates/facelock-daemon/src/handler.rs | 49 +-- docs/contracts.md | 15 +- docs/security.md | 19 +- test/run-oneshot-tests.sh | 38 ++ 13 files changed, 537 insertions(+), 72 deletions(-) diff --git a/config/quirks.d/00-defaults.toml b/config/quirks.d/00-defaults.toml index 45a66f8..908db20 100644 --- a/config/quirks.d/00-defaults.toml +++ b/config/quirks.d/00-defaults.toml @@ -51,11 +51,16 @@ notes = "ThinkPad integrated IR camera (various models)" # --- Logitech BRIO --- +# The BRIO exposes multiple V4L2 capture nodes under one VID:PID: an RGB node +# (YUYV/MJPG) and an IR node (native GREY). force_ir means "this USB device has +# an IR sensor"; format_preference = "GREY" marks the IR sensor node so +# classification and auto-detection pick the GREY-native node, not the RGB one. [[quirk]] vendor_id = "046d" product_id = "085e" force_ir = true warmup_frames = 1 +format_preference = "GREY" notes = "Logitech BRIO 4K with IR sensor" # --- Microsoft Surface cameras --- diff --git a/crates/facelock-camera/src/capture.rs b/crates/facelock-camera/src/capture.rs index 22d03f5..356b905 100644 --- a/crates/facelock-camera/src/capture.rs +++ b/crates/facelock-camera/src/capture.rs @@ -111,6 +111,13 @@ impl<'a> Camera<'a> { let width = fmt.width; let height = fmt.height; let format_str = fmt.fourcc.to_string(); + tracing::info!( + device = %device_path, + format = %format_str.trim(), + width, + height, + "camera format negotiated" + ); // Create MMAP stream with 4 buffers and a capture timeout let mut stream = Stream::with_buffers(&dev, Type::VideoCapture, 4) diff --git a/crates/facelock-camera/src/device.rs b/crates/facelock-camera/src/device.rs index 565d706..a1458d3 100644 --- a/crates/facelock-camera/src/device.rs +++ b/crates/facelock-camera/src/device.rs @@ -95,6 +95,29 @@ pub fn ir_source(device: &DeviceInfo) -> IrSource { ir_source_with_quirks(device, None) } +/// True if the device natively exposes an IR-like capture format (GREY/Y16). +fn has_native_ir_format(device: &DeviceInfo) -> bool { + device + .formats + .iter() + .any(|f| matches!(f.fourcc.as_str(), "GREY" | "Y16 ")) +} + +/// The quirk-free heuristic classification (name token / format corroboration). +fn heuristic_ir_source(device: &DeviceInfo) -> IrSource { + match ( + has_ir_name_token(&device.name), + has_native_ir_format(device), + ) { + // Native IR format corroborated by the name token — strongest heuristic. + (true, true) => IrSource::Format, + // Name token alone is sufficient (e.g. "Infrared Camera"). + (true, false) => IrSource::Name, + // Format alone (or nothing) is NOT sufficient — this is the H1 bypass fix. + (false, _) => IrSource::None, + } +} + /// Classify a device's IR provenance, honoring the quirks DB as authoritative. /// /// Decision rules (H1 fix — mere *availability* of GREY/Y16 is NOT proof of IR): @@ -104,13 +127,33 @@ pub fn ir_source(device: &DeviceInfo) -> IrSource { /// 3. An IR name token alone → [`IrSource::Name`]. /// 4. Otherwise → [`IrSource::None`] (a plain RGB webcam that merely enumerates /// GREY is not treated as IR). +/// +/// CAVEAT (multi-node USB devices): one physical USB camera can expose several +/// V4L2 capture nodes sharing the same VID:PID (e.g. the Logitech BRIO's RGB +/// node and IR node). Per-node this function classifies ALL of them by the +/// quirk. Use [`classify_ir_sources`] (list) or [`ir_source_resolved`] (single +/// device, enumerates siblings) to disambiguate the actual IR sensor node. pub fn ir_source_with_quirks( device: &DeviceInfo, quirks: Option<&crate::quirks::QuirksDb>, +) -> IrSource { + ir_source_with_quirks_and_ids( + device, + quirks, + crate::quirks::read_usb_ids(&device.path).as_ref(), + ) +} + +/// Per-node classification with the USB IDs supplied by the caller (keeps the +/// sysfs read at the call boundary for testability). +fn ir_source_with_quirks_and_ids( + device: &DeviceInfo, + quirks: Option<&crate::quirks::QuirksDb>, + usb_ids: Option<&(String, String)>, ) -> IrSource { // 1. Quirks database is authoritative (both true and false). if let Some(db) = quirks { - if let Some(quirk) = db.find_match(device) { + if let Some(quirk) = db.find_match_with_ids(device, usb_ids) { if let Some(force_ir) = quirk.force_ir { return if force_ir { IrSource::Quirk @@ -120,47 +163,163 @@ pub fn ir_source_with_quirks( } } } + heuristic_ir_source(device) +} - let has_ir_name = has_ir_name_token(&device.name); - let has_ir_format = device - .formats +/// Classify IR provenance for a whole set of enumerated capture nodes, +/// disambiguating multi-node USB devices. +/// +/// A quirks `force_ir` entry means "this USB **device** has an IR sensor", not +/// "every capture node of it is IR". One physical camera can expose several +/// V4L2 nodes sharing the same VID:PID — e.g. the Logitech BRIO (046d:085e) has +/// an RGB node (YUYV/MJPG) *and* an IR node (native GREY). When multiple nodes +/// share one quirk-matched USB identity AND at least one of them exposes an +/// IR-like format (GREY/Y16, or the quirk's `format_preference`), only the +/// node(s) with that format are IR; siblings without it fall back to the +/// quirk-free heuristic. If NO node has an IR-like format there is no evidence +/// to disambiguate with, so `force_ir` is trusted for all nodes (some quirk +/// entries exist precisely because the camera advertises no IR-like format). +pub fn classify_ir_sources( + devices: &[DeviceInfo], + quirks: Option<&crate::quirks::QuirksDb>, +) -> Vec { + let usb_ids: Vec> = devices .iter() - .any(|f| matches!(f.fourcc.as_str(), "GREY" | "Y16 ")); + .map(|d| crate::quirks::read_usb_ids(&d.path)) + .collect(); + classify_ir_sources_with_ids(devices, quirks, &usb_ids) +} - match (has_ir_name, has_ir_format) { - // Native IR format corroborated by the name token — strongest heuristic. - (true, true) => IrSource::Format, - // Name token alone is sufficient (e.g. "Infrared Camera"). - (true, false) => IrSource::Name, - // Format alone (or nothing) is NOT sufficient — this is the H1 bypass fix. - (false, _) => IrSource::None, +fn classify_ir_sources_with_ids( + devices: &[DeviceInfo], + quirks: Option<&crate::quirks::QuirksDb>, + usb_ids: &[Option<(String, String)>], +) -> Vec { + let mut sources: Vec = devices + .iter() + .zip(usb_ids) + .map(|(d, ids)| ir_source_with_quirks_and_ids(d, quirks, ids.as_ref())) + .collect(); + + // Node-level disambiguation for multi-node USB devices. + let mut seen: Vec<&(String, String)> = Vec::new(); + for i in 0..devices.len() { + if sources[i] != IrSource::Quirk { + continue; + } + // Sibling grouping requires a readable USB identity. + let Some(ids) = usb_ids[i].as_ref() else { + continue; + }; + if seen.contains(&ids) { + continue; + } + seen.push(ids); + + let group: Vec = (0..devices.len()) + .filter(|&j| sources[j] == IrSource::Quirk && usb_ids[j].as_ref() == Some(ids)) + .collect(); + if group.len() < 2 { + continue; + } + + // IR-like formats: GREY/Y16 plus the quirk's format_preference, if any. + let pref = quirks + .and_then(|db| db.find_match_with_ids(&devices[i], Some(ids))) + .and_then(|q| q.format_preference.clone()); + let node_has_ir_format = |j: usize| { + has_native_ir_format(&devices[j]) + || pref.as_deref().is_some_and(|p| { + devices[j] + .formats + .iter() + .any(|f| f.fourcc.trim() == p.trim()) + }) + }; + + // Only demote when format evidence exists within the group; otherwise + // trust force_ir for every node. + if group.iter().any(|&j| node_has_ir_format(j)) { + for &j in &group { + if !node_has_ir_format(j) { + let demoted = heuristic_ir_source(&devices[j]); + tracing::debug!( + device = %devices[j].path, + vid = %ids.0, + pid = %ids.1, + reclassified = ?demoted, + "multi-node quirk device: node lacks IR-like format, \ + sibling node has it — not the IR sensor node" + ); + sources[j] = demoted; + } + } + } } + + sources +} + +/// Sibling-aware IR classification for a single device. +/// +/// Enumerates the host's other V4L2 nodes so that multi-node USB devices are +/// disambiguated exactly as in [`classify_ir_sources`]. Use this instead of +/// [`ir_source_with_quirks`] whenever the answer gates `require_ir`. +pub fn ir_source_resolved( + device: &DeviceInfo, + quirks: Option<&crate::quirks::QuirksDb>, +) -> IrSource { + // Siblings only add context; the caller's DeviceInfo is authoritative for + // its own path (replace any enumerated entry at the same path with it). + let mut devices = list_devices().unwrap_or_default(); + devices.retain(|d| d.path != device.path); + devices.push(device.clone()); + let sources = classify_ir_sources(&devices, quirks); + // The device was appended last above. + sources.last().copied().unwrap_or(IrSource::None) +} + +/// Boolean form of [`ir_source_resolved`]. +pub fn is_ir_camera_resolved( + device: &DeviceInfo, + quirks: Option<&crate::quirks::QuirksDb>, +) -> bool { + ir_source_resolved(device, quirks) != IrSource::None } /// Auto-detect the best available video capture device. /// -/// Prefers a quirks-confirmed IR device, then a heuristically-IR device (name -/// token), then falls back to the first enumerated device. It never auto-selects -/// an unknown camera *just because* it self-reports a GREY/Y16 format (H1). +/// Classifies all nodes with [`classify_ir_sources`] (so multi-node USB devices +/// resolve to their actual IR sensor node), then prefers: a quirks-confirmed IR +/// node with a native IR format, then any quirks-confirmed IR node, then a +/// heuristically-IR node (name token), then the first enumerated device. It +/// never auto-selects an unknown camera *just because* it self-reports a +/// GREY/Y16 format (H1). /// /// NOTE (seam for Plan 02): device selection here is by capability/heuristic, not /// by stable device identity. Plan 02 will pin the enrolled camera by identity. pub fn auto_detect_device() -> Result { let devices = list_devices()?; let quirks = crate::quirks::QuirksDb::load(); - devices - .iter() - .find(|d| ir_source_with_quirks(d, Some(&quirks)) == IrSource::Quirk) - .or_else(|| { - devices - .iter() - .find(|d| ir_source_with_quirks(d, Some(&quirks)) != IrSource::None) - }) - .or_else(|| devices.first()) + let sources = classify_ir_sources(&devices, Some(&quirks)); + pick_auto_device(&devices, &sources) .cloned() .ok_or_else(|| FacelockError::Camera("no video devices found".into())) } +/// Selection order for auto-detection, over pre-classified nodes. +/// Prefers the format-corroborated IR node so a multi-node camera's RGB +/// sibling is never picked over its IR sensor. +fn pick_auto_device<'a>(devices: &'a [DeviceInfo], sources: &[IrSource]) -> Option<&'a DeviceInfo> { + let nodes = || devices.iter().zip(sources); + nodes() + .find(|(d, s)| **s == IrSource::Quirk && has_native_ir_format(d)) + .or_else(|| nodes().find(|(_, s)| **s == IrSource::Quirk)) + .or_else(|| nodes().find(|(_, s)| **s != IrSource::None)) + .map(|(d, _)| d) + .or_else(|| devices.first()) +} + fn query_device(path: &str) -> Result { let dev = Device::with_path(path).map_err(|e| FacelockError::Camera(format!("{path}: {e}")))?; @@ -343,6 +502,199 @@ mod tests { assert!(!is_ir_camera_with_quirks(&ir_named, Some(&db_off))); } + fn device_at(path: &str, name: &str, fourccs: &[&str]) -> DeviceInfo { + DeviceInfo { + path: path.into(), + ..device_with(name, fourccs) + } + } + + fn brio_quirk(format_preference: Option<&str>) -> crate::quirks::Quirk { + crate::quirks::Quirk { + vendor_id: Some("046d".into()), + product_id: Some("085e".into()), + name_pattern: None, + force_ir: Some(true), + emitter_xu_guid: None, + emitter_xu_selector: None, + warmup_frames: Some(1), + format_preference: format_preference.map(Into::into), + rotation: None, + notes: Some("Logitech BRIO 4K with IR sensor".into()), + } + } + + fn brio_ids() -> Option<(String, String)> { + Some(("046d".into(), "085e".into())) + } + + #[test] + fn brio_multi_node_only_grey_node_classifies_ir() { + // Regression (hardware-verified, Logitech BRIO 046d:085e): one physical + // USB camera exposes TWO capture nodes sharing the same VID:PID — + // /dev/video0 (RGB sensor, YUYV/MJPG) and /dev/video2 (IR sensor, native + // GREY). A force_ir quirk means "this USB device has an IR sensor", NOT + // "every capture node of it is IR": only the GREY-native node is IR. + let mut db = crate::quirks::QuirksDb::default(); + db.push_quirk_for_test(brio_quirk(Some("GREY"))); + + let rgb = device_at("/dev/video0", "Logitech BRIO", &["YUYV", "MJPG"]); + let ir = device_at("/dev/video2", "Logitech BRIO", &["GREY"]); + let devices = [rgb, ir]; + let ids = vec![brio_ids(), brio_ids()]; + + let sources = classify_ir_sources_with_ids(&devices, Some(&db), &ids); + assert_eq!( + sources[0], + IrSource::None, + "RGB sibling node must NOT classify IR" + ); + assert_eq!( + sources[1], + IrSource::Quirk, + "GREY-native node keeps quirk-IR classification" + ); + + // Auto-detect-equivalent selection must pick the IR (GREY) node, not + // the first enumerated node (the RGB sensor with the white LED). + let picked = pick_auto_device(&devices, &sources).expect("a device is picked"); + assert_eq!(picked.path, "/dev/video2"); + } + + #[test] + fn brio_multi_node_disambiguates_without_format_preference() { + // Even without format_preference on the quirk, the native GREY format + // alone disambiguates the sibling nodes. + let mut db = crate::quirks::QuirksDb::default(); + db.push_quirk_for_test(brio_quirk(None)); + + let devices = [ + device_at("/dev/video0", "Logitech BRIO", &["YUYV", "MJPG"]), + device_at("/dev/video2", "Logitech BRIO", &["GREY"]), + ]; + let ids = vec![brio_ids(), brio_ids()]; + + let sources = classify_ir_sources_with_ids(&devices, Some(&db), &ids); + assert_eq!(sources[0], IrSource::None); + assert_eq!(sources[1], IrSource::Quirk); + } + + #[test] + fn quirk_multi_node_without_any_ir_format_trusts_force_ir_for_all() { + // Edge case: some force_ir quirks exist precisely BECAUSE the camera + // does not advertise an IR-like format. If no sibling node has one, + // there is no format evidence to disambiguate — trust force_ir for all. + let mut db = crate::quirks::QuirksDb::default(); + db.push_quirk_for_test(brio_quirk(None)); + + let devices = [ + device_at("/dev/video0", "Some IR Module", &["YUYV"]), + device_at("/dev/video2", "Some IR Module", &["MJPG"]), + ]; + let ids = vec![brio_ids(), brio_ids()]; + + let sources = classify_ir_sources_with_ids(&devices, Some(&db), &ids); + assert_eq!(sources[0], IrSource::Quirk); + assert_eq!(sources[1], IrSource::Quirk); + // With no format evidence, selection preserves enumeration order. + let picked = pick_auto_device(&devices, &sources).expect("a device is picked"); + assert_eq!(picked.path, "/dev/video0"); + } + + #[test] + fn quirk_single_node_without_ir_format_stays_ir() { + // A single quirk-matched node with no IR-like format is the whole point + // of force_ir — it must remain IR. + let mut db = crate::quirks::QuirksDb::default(); + db.push_quirk_for_test(brio_quirk(None)); + + let devices = [device_at("/dev/video0", "Oddball IR Module", &["YUYV"])]; + let ids = vec![brio_ids()]; + + let sources = classify_ir_sources_with_ids(&devices, Some(&db), &ids); + assert_eq!(sources[0], IrSource::Quirk); + } + + #[test] + fn multi_node_demoted_sibling_keeps_name_heuristic() { + // A demoted sibling falls back to the (quirk-free) heuristic: an IR + // name token still classifies it, honestly, as Name. + let mut db = crate::quirks::QuirksDb::default(); + db.push_quirk_for_test(brio_quirk(None)); + + let devices = [ + device_at("/dev/video0", "Vendor IR Camera", &["YUYV"]), + device_at("/dev/video2", "Vendor IR Camera", &["GREY"]), + ]; + let ids = vec![brio_ids(), brio_ids()]; + + let sources = classify_ir_sources_with_ids(&devices, Some(&db), &ids); + assert_eq!(sources[0], IrSource::Name); + assert_eq!(sources[1], IrSource::Quirk); + // Selection still prefers the format-corroborated quirk node. + let picked = pick_auto_device(&devices, &sources).expect("a device is picked"); + assert_eq!(picked.path, "/dev/video2"); + } + + #[test] + fn classify_without_usb_ids_leaves_quirk_nodes_alone() { + // Nodes whose USB identity is unreadable cannot be grouped as siblings; + // a name-pattern quirk match stays authoritative (current behavior). + let mut db = crate::quirks::QuirksDb::default(); + db.push_quirk_for_test(crate::quirks::Quirk { + vendor_id: None, + product_id: None, + name_pattern: Some("(?i)generic".into()), + force_ir: Some(true), + emitter_xu_guid: None, + emitter_xu_selector: None, + warmup_frames: None, + format_preference: None, + rotation: None, + notes: None, + }); + + let devices = [ + device_at("/dev/video0", "Generic Camera", &["YUYV"]), + device_at("/dev/video2", "Generic Camera", &["GREY"]), + ]; + let ids = vec![None, None]; + + let sources = classify_ir_sources_with_ids(&devices, Some(&db), &ids); + assert_eq!(sources[0], IrSource::Quirk); + assert_eq!(sources[1], IrSource::Quirk); + } + + #[test] + fn classify_mixed_identities_only_groups_same_usb_device() { + // Two DIFFERENT USB cameras (different VID:PID) both quirk-matched: + // no cross-device demotion may happen. + let mut db = crate::quirks::QuirksDb::default(); + db.push_quirk_for_test(brio_quirk(None)); + db.push_quirk_for_test(crate::quirks::Quirk { + vendor_id: Some("8086".into()), + product_id: Some("0b07".into()), + name_pattern: None, + force_ir: Some(true), + emitter_xu_guid: None, + emitter_xu_selector: None, + warmup_frames: None, + format_preference: None, + rotation: None, + notes: None, + }); + let devices = [ + device_at("/dev/video0", "RealSense", &["YUYV"]), + device_at("/dev/video2", "Logitech BRIO", &["GREY"]), + ]; + let ids = vec![Some(("8086".into(), "0b07".into())), brio_ids()]; + + let sources = classify_ir_sources_with_ids(&devices, Some(&db), &ids); + // Different physical devices — both keep their quirk classification. + assert_eq!(sources[0], IrSource::Quirk); + assert_eq!(sources[1], IrSource::Quirk); + } + #[test] fn list_devices_does_not_crash() { // Should return Ok even if no devices exist diff --git a/crates/facelock-camera/src/lib.rs b/crates/facelock-camera/src/lib.rs index db1b6f0..a61a68b 100644 --- a/crates/facelock-camera/src/lib.rs +++ b/crates/facelock-camera/src/lib.rs @@ -6,8 +6,9 @@ pub mod quirks; pub use capture::{Camera, is_dark_with_config}; pub use device::{ - DeviceInfo, FormatInfo, IrSource, auto_detect_device, ir_source, ir_source_with_quirks, - is_ir_camera, is_ir_camera_with_quirks, list_devices, validate_device, + DeviceInfo, FormatInfo, IrSource, auto_detect_device, classify_ir_sources, ir_source, + ir_source_resolved, ir_source_with_quirks, is_ir_camera, is_ir_camera_resolved, + is_ir_camera_with_quirks, list_devices, validate_device, }; pub use ir_emitter::EmitterXuInfo; pub use preprocess::{check_ir_texture, clahe, extract_bbox_region, rgb_to_gray, yuyv_to_rgb}; diff --git a/crates/facelock-camera/src/quirks.rs b/crates/facelock-camera/src/quirks.rs index 75f2353..7a72ba8 100644 --- a/crates/facelock-camera/src/quirks.rs +++ b/crates/facelock-camera/src/quirks.rs @@ -106,10 +106,21 @@ impl QuirksDb { /// Find a matching quirk for the given device. /// Matches by USB vendor:product ID first, then by name pattern. pub fn find_match(&self, device: &DeviceInfo) -> Option<&Quirk> { - let usb_ids = read_usb_ids(&device.path); + self.find_match_with_ids(device, read_usb_ids(&device.path).as_ref()) + } + /// Like [`find_match`](Self::find_match) but with the device's USB + /// vendor:product IDs supplied by the caller instead of read from sysfs. + /// This keeps the sysfs read at the call boundary so classification is + /// testable and so callers that already resolved the identity (e.g. + /// multi-node sibling grouping) don't re-read sysfs per lookup. + pub fn find_match_with_ids( + &self, + device: &DeviceInfo, + usb_ids: Option<&(String, String)>, + ) -> Option<&Quirk> { // First pass: match by USB vendor:product ID (most specific) - if let Some((vendor, product)) = &usb_ids { + if let Some((vendor, product)) = usb_ids { for quirk in &self.quirks { if let (Some(qv), Some(qp)) = (&quirk.vendor_id, &quirk.product_id) { if qv.eq_ignore_ascii_case(vendor) && qp.eq_ignore_ascii_case(product) { @@ -160,7 +171,7 @@ impl QuirksDb { /// Read USB vendor:product IDs from sysfs for a video device. /// Returns (vendor_id, product_id) as hex strings, or None if unavailable. -fn read_usb_ids(device_path: &str) -> Option<(String, String)> { +pub(crate) fn read_usb_ids(device_path: &str) -> Option<(String, String)> { // /dev/video0 -> /sys/class/video4linux/video0/device/ let dev_name = device_path.strip_prefix("/dev/")?; let sysfs_base = format!("/sys/class/video4linux/{dev_name}/device"); diff --git a/crates/facelock-cli/src/commands/auth.rs b/crates/facelock-cli/src/commands/auth.rs index 4294b14..b3e7cc6 100644 --- a/crates/facelock-cli/src/commands/auth.rs +++ b/crates/facelock-cli/src/commands/auth.rs @@ -5,7 +5,7 @@ use std::path::Path; use facelock_camera::quirks::QuirksDb; -use facelock_camera::{Camera, auto_detect_device, is_ir_camera_with_quirks, validate_device}; +use facelock_camera::{Camera, auto_detect_device, is_ir_camera_resolved, validate_device}; use facelock_core::config::Config; use facelock_core::ipc::DaemonResponse; use facelock_core::types::MatchResult; @@ -118,9 +118,11 @@ pub fn run(user: String, config_path: Option) -> i32 { let device_path = config.device.path.clone().unwrap(); let quirks = QuirksDb::load(); let device_info = validate_device(&device_path); + // Sibling-aware classification: on multi-node USB cameras (e.g. BRIO) only + // the actual IR sensor node counts as IR, not every node of the device. let device_is_ir = device_info .as_ref() - .map(|dev| is_ir_camera_with_quirks(dev, Some(&quirks))) + .map(|dev| is_ir_camera_resolved(dev, Some(&quirks))) .unwrap_or(false); if config.security.require_ir && !device_is_ir { diff --git a/crates/facelock-cli/src/commands/daemon.rs b/crates/facelock-cli/src/commands/daemon.rs index 9ce3de1..b4a5343 100644 --- a/crates/facelock-cli/src/commands/daemon.rs +++ b/crates/facelock-cli/src/commands/daemon.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex, MutexGuard, TryLockError}; use std::time::{Duration, Instant}; use facelock_camera::quirks::QuirksDb; -use facelock_camera::{Camera, auto_detect_device, is_ir_camera_with_quirks, validate_device}; +use facelock_camera::{Camera, auto_detect_device, is_ir_camera_resolved, validate_device}; use facelock_core::config::Config; use facelock_core::dbus_interface::{ AuthResult, BUS_NAME, DeviceInfo, ModelInfo, OBJECT_PATH, PreviewFaceInfo, @@ -721,7 +721,7 @@ fn build_handler(config_path: Option<&str>) -> Result<(ProductionHandler, u64), if config.device.path.is_none() { let info = auto_detect_device() .map_err(|e| format!("no camera device specified and auto-detection failed: {e}"))?; - let is_ir = is_ir_camera_with_quirks(&info, Some(&quirks)); + let is_ir = is_ir_camera_resolved(&info, Some(&quirks)); info!(device = %info.path, name = %info.name, ir = is_ir, "auto-detected camera device"); config.device.path = Some(info.path); } @@ -730,7 +730,9 @@ fn build_handler(config_path: Option<&str>) -> Result<(ProductionHandler, u64), let device_is_ir = match validate_device(&device_path) { Ok(info) => { - let is_ir = is_ir_camera_with_quirks(&info, Some(&quirks)); + // Sibling-aware: on multi-node USB cameras only the IR sensor + // node counts as IR, not every node sharing the quirk's VID:PID. + let is_ir = is_ir_camera_resolved(&info, Some(&quirks)); info!(device = %device_path, ir = is_ir, name = %info.name, "camera device"); is_ir } diff --git a/crates/facelock-cli/src/commands/setup.rs b/crates/facelock-cli/src/commands/setup.rs index 522326c..475afda 100644 --- a/crates/facelock-cli/src/commands/setup.rs +++ b/crates/facelock-cli/src/commands/setup.rs @@ -292,12 +292,25 @@ fn wizard_camera_selection(theme: &ColorfulTheme, config: &mut Config) -> anyhow } // Consult the quirks DB so IR classification here matches the auth path. + // classify_ir_sources disambiguates multi-node USB cameras (e.g. the + // Logitech BRIO's RGB + IR nodes share one VID:PID quirk): only the actual + // IR sensor node is tagged [IR], so auto-select picks the right node. let quirks = facelock_camera::QuirksDb::load(); - let is_ir = |d: &facelock_camera::DeviceInfo| { - facelock_camera::is_ir_camera_with_quirks(d, Some(&quirks)) + let sources = facelock_camera::classify_ir_sources(&devices, Some(&quirks)); + let is_ir_at = |idx: usize| { + sources + .get(idx) + .copied() + .unwrap_or(facelock_camera::IrSource::None) + != facelock_camera::IrSource::None }; - let ir_devices: Vec<_> = devices.iter().filter(|d| is_ir(d)).collect(); + let ir_devices: Vec<_> = devices + .iter() + .enumerate() + .filter(|(i, _)| is_ir_at(*i)) + .map(|(_, d)| d) + .collect(); // If exactly one IR camera, auto-select it if ir_devices.len() == 1 { @@ -310,8 +323,9 @@ fn wizard_camera_selection(theme: &ColorfulTheme, config: &mut Config) -> anyhow // Build display list let display_items: Vec = devices .iter() - .map(|d| { - let ir_tag = if is_ir(d) { " [IR]" } else { "" }; + .enumerate() + .map(|(i, d)| { + let ir_tag = if is_ir_at(i) { " [IR]" } else { "" }; format!("{}{} - {}", d.path, ir_tag, d.name) }) .collect(); @@ -322,7 +336,7 @@ fn wizard_camera_selection(theme: &ColorfulTheme, config: &mut Config) -> anyhow .position(|d| config.device.path.as_ref().is_some_and(|p| d.path == *p)) .or_else(|| { // Default to first IR camera if available - devices.iter().position(&is_ir) + (0..devices.len()).find(|&i| is_ir_at(i)) }) .unwrap_or(0); diff --git a/crates/facelock-cli/src/direct.rs b/crates/facelock-cli/src/direct.rs index 6f50360..3384eca 100644 --- a/crates/facelock-cli/src/direct.rs +++ b/crates/facelock-cli/src/direct.rs @@ -8,7 +8,7 @@ use std::path::Path; use anyhow::{Context, bail}; use facelock_camera::quirks::QuirksDb; use facelock_camera::{ - Camera, DeviceInfo, auto_detect_device, is_ir_camera_with_quirks, list_devices, validate_device, + Camera, DeviceInfo, auto_detect_device, is_ir_camera_resolved, list_devices, validate_device, }; use facelock_core::config::DeviceConfig; use facelock_core::config::{Config, EncryptionMethod}; @@ -45,7 +45,9 @@ fn build_resolved_camera_device( ResolvedCameraDevice { device, device_quirk: quirks.find_match(&device_info).cloned(), - device_is_ir: is_ir_camera_with_quirks(&device_info, Some(quirks)), + // Sibling-aware: on multi-node USB cameras only the IR sensor node + // counts as IR, not every node sharing the quirk's VID:PID. + device_is_ir: is_ir_camera_resolved(&device_info, Some(quirks)), } } @@ -261,11 +263,13 @@ pub fn list_devices_direct() -> anyhow::Result<()> { } // Consult the quirks DB so the displayed [IR] tag matches the authoritative - // decision the auth path makes (e.g. a quirks `force_ir` camera). + // decision the auth path makes (e.g. a quirks `force_ir` camera), with + // node-level disambiguation for multi-node USB devices. let quirks = facelock_camera::QuirksDb::load(); + let sources = facelock_camera::classify_ir_sources(&devices, Some(&quirks)); println!("Available video devices:\n"); - for dev in &devices { - let ir_tag = if is_ir_camera_with_quirks(dev, Some(&quirks)) { + for (dev, source) in devices.iter().zip(&sources) { + let ir_tag = if *source != facelock_camera::IrSource::None { " [IR]" } else { "" diff --git a/crates/facelock-daemon/src/handler.rs b/crates/facelock-daemon/src/handler.rs index a03ffd3..8b9e36b 100644 --- a/crates/facelock-daemon/src/handler.rs +++ b/crates/facelock-daemon/src/handler.rs @@ -401,31 +401,36 @@ impl Handler { }, DaemonRequest::ListDevices => { - use facelock_camera::{QuirksDb, is_ir_camera_with_quirks, list_devices}; + use facelock_camera::{IrSource, QuirksDb, classify_ir_sources, list_devices}; // Consult the quirks DB so the reported is_ir matches the - // authoritative decision the auth path makes. + // authoritative decision the auth path makes, with node-level + // disambiguation for multi-node USB devices. let quirks = QuirksDb::load(); match list_devices() { - Ok(devices) => DaemonResponse::Devices( - devices - .iter() - .map(|d| facelock_core::ipc::IpcDeviceInfo { - path: d.path.clone(), - name: d.name.clone(), - driver: d.driver.clone(), - is_ir: is_ir_camera_with_quirks(d, Some(&quirks)), - formats: d - .formats - .iter() - .map(|f| facelock_core::ipc::IpcFormatInfo { - fourcc: f.fourcc.clone(), - description: f.description.clone(), - sizes: f.sizes.clone(), - }) - .collect(), - }) - .collect(), - ), + Ok(devices) => { + let sources = classify_ir_sources(&devices, Some(&quirks)); + DaemonResponse::Devices( + devices + .iter() + .zip(&sources) + .map(|(d, source)| facelock_core::ipc::IpcDeviceInfo { + path: d.path.clone(), + name: d.name.clone(), + driver: d.driver.clone(), + is_ir: *source != IrSource::None, + formats: d + .formats + .iter() + .map(|f| facelock_core::ipc::IpcFormatInfo { + fourcc: f.fourcc.clone(), + description: f.description.clone(), + sizes: f.sizes.clone(), + }) + .collect(), + }) + .collect(), + ) + } Err(e) => DaemonResponse::Error { message: format!("device enumeration failed: {e}"), }, diff --git a/docs/contracts.md b/docs/contracts.md index 59b7434..e3c2fd7 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -92,8 +92,14 @@ TOML format. All keys optional — camera auto-detected, sensible defaults for e When `device.path` is omitted: 1. Enumerate `/dev/video0` through `/dev/video63` 2. Filter to VIDEO_CAPTURE devices -3. Prefer IR cameras (name contains "ir"/"infrared", or supports GREY/Y16 format) -4. Fall back to first available device +3. Classify every node's IR provenance (quirks `force_ir` authoritative; name + token / format-corroboration heuristic otherwise), with node-level + disambiguation for multi-node USB devices: when several nodes share one + quirk-matched VID:PID and at least one has an IR-like format (GREY/Y16 or + the quirk's `format_preference`), only the format-bearing node(s) are IR +4. Prefer a quirks-confirmed IR node with a native IR format, then any + quirks-confirmed IR node, then a name-token IR node +5. Fall back to first available device ## Database Schema @@ -175,7 +181,10 @@ pam_facelock(): for user | Frame variance default const | `DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY` | 0.97 | IR classification requires a whole `ir`/`infrared` name token or a quirks `force_ir` -entry; a GREY/Y16 format alone is not treated as IR. Frame variance is passive +entry; a GREY/Y16 format alone is not treated as IR. A `force_ir` quirk is +device-level ("this USB device has an IR sensor"): when the device exposes multiple +capture nodes and at least one has an IR-like format, only the format-bearing +node(s) classify IR (see `docs/security.md` §A). Frame variance is passive anti-photo only (does not stop video replay); IR texture is measured on the raw frame, never CLAHE. These defaults must not be weakened without security review. diff --git a/docs/security.md b/docs/security.md index 8ebd3db..3ef3694 100644 --- a/docs/security.md +++ b/docs/security.md @@ -47,13 +47,26 @@ Implementation (`facelock-camera/src/device.rs`, `ir_source_with_quirks`): // Name – an "ir"/"infrared" name *token* (tokenized, not substring) // None – not IR // -// Precedence: +// Per-node precedence: // 1. quirks DB force_ir is authoritative; // 2. a native GREY/Y16 format counts ONLY when corroborated by a name token; // 3. a name token alone is sufficient; // 4. otherwise not-IR. pub fn ir_source_with_quirks(device, quirks) -> IrSource { ... } +// Node-level disambiguation for multi-node USB devices: force_ir means "this +// USB device HAS an IR sensor", not "every capture node of it is IR". One +// physical camera can expose several V4L2 nodes under one VID:PID (Logitech +// BRIO 046d:085e: /dev/video0 = RGB YUYV/MJPG, /dev/video2 = IR native GREY). +// When multiple nodes share a quirk-matched USB identity AND at least one has +// an IR-like format (GREY/Y16, or the quirk's format_preference), only the +// node(s) with that format classify IR; siblings fall back to the quirk-free +// heuristic. If NO node has an IR-like format, force_ir is trusted for all +// (some quirk entries exist precisely because the camera advertises no +// IR-like format). Anything gating require_ir uses these sibling-aware forms: +pub fn classify_ir_sources(devices, quirks) -> Vec { ... } +pub fn ir_source_resolved(device, quirks) -> IrSource { ... } // enumerates siblings + // In the auth flow (daemon pre_check and oneshot), before recognition: if config.security.require_ir && !device_is_ir { return DaemonResponse::Error { @@ -66,7 +79,9 @@ if config.security.require_ir && !device_is_ir { **Why mere GREY/Y16 availability is not enough (H1)**: many ordinary RGB UVC webcams *enumerate* a GREY format alongside YUYV/MJPG. The previous heuristic (`contains("ir")` OR any GREY/Y16 format) misclassified those as IR, silently defeating `require_ir = true`. It also matched the substring "ir" inside unrelated names ("Sirius", "AIR-Cam"). The classifier now requires a whole `ir`/`infrared` **token** or a **quirks `force_ir`** entry, and treats a GREY/Y16 format as IR **only when corroborated** by one of those. This is why `require_ir` is now load-bearing rather than trivially bypassable. -**Limitation**: classification is still heuristic without a hardware allow-list. Some genuine IR cameras report neither an IR name token nor a known quirk; add a quirks `force_ir` entry (`/etc/facelock/quirks.d/`) for such hardware. The `facelock devices` command displays whether each camera is detected as IR. Device *identity* pinning (rather than capability heuristics) is the successor fix (Plan 02). +**Why `force_ir` is device-level, not node-level (hardware-verified regression)**: on a real Logitech BRIO, treating every quirk-matched node as IR made *both* `/dev/video0` (the RGB sensor) and `/dev/video2` (the IR sensor) classify IR — so setup stopped auto-selecting and auto-detect captured from the RGB sensor (white LED) instead of the IR sensor. The sibling-format disambiguation above restores per-node honesty: exactly one BRIO node is `[IR]`, and auto-detection prefers the format-corroborated IR node. + +**Limitation**: classification is still heuristic without a hardware allow-list. Some genuine IR cameras report neither an IR name token nor a known quirk; add a quirks `force_ir` entry (`/etc/facelock/quirks.d/`) for such hardware (and set `format_preference` to the IR node's native format, e.g. `"GREY"`, when the camera exposes multiple capture nodes). The `facelock devices` command displays whether each camera is detected as IR. Device *identity* pinning (rather than capability heuristics) is the successor fix (Plan 02). #### B. Frame Variance Check (Required) diff --git a/test/run-oneshot-tests.sh b/test/run-oneshot-tests.sh index 057676d..2418ee9 100755 --- a/test/run-oneshot-tests.sh +++ b/test/run-oneshot-tests.sh @@ -77,6 +77,44 @@ run_test_contains "facelock devices (oneshot)" \ "facelock devices" \ "/dev/video" || exit 1 +# --- Multi-node IR classification regression gate (BRIO fix) --- +# A force_ir quirk means "this USB device HAS an IR sensor", not "every capture +# node of it is IR". The Logitech BRIO (046d:085e) exposes an RGB node +# (YUYV/MJPG) and an IR node (native GREY) under one VID:PID; previously BOTH +# classified [IR], breaking setup auto-select and making auto-detect capture +# from the RGB sensor. These assertions prove node-level disambiguation. +# CAMERA-REQUIRED: BRIO-conditional; skipped when no BRIO is mounted. +DEVICES_OUT="$(facelock devices)" +if echo "$DEVICES_OUT" | grep -qi "BRIO"; then + echo "BRIO detected — running multi-node IR classification assertions" + + run_test "exactly one [IR] node for multi-node quirk camera (BRIO)" \ + "test \"\$(facelock devices | grep -c '\[IR\]')\" -eq 1" + + # The single [IR] node must be the GREY-native sensor node. + run_test "the [IR] node is the GREY-native node" \ + "facelock devices | awk '/\[IR\]/{f=1} f && NF==0{f=0} f' | grep -q 'GREY'" + + IR_NODE="$(echo "$DEVICES_OUT" | awk '/\[IR\]/{print $1}')" + echo "IR node: $IR_NODE" + + # Auto-detection must select the IR (GREY) node, not the RGB sibling. + # `facelock auth --user nobody` logs the auto-detected device before it + # exits (2) on the unknown user — no live face needed. + run_test "auto-detect selects the IR (GREY) node" \ + "facelock auth --user nobody --config /etc/facelock/config.toml 2>&1 | grep 'auto-detected camera' | grep -q -- \"$IR_NODE\"" + + # The negotiated capture format on the auto-detected node must be GREY. + # A timed-out enroll still opens the camera and logs the negotiated format; + # no live face is needed (exit code intentionally ignored). + run_test "negotiated capture format is GREY on auto-detected node" \ + "RUST_LOG=info timeout --foreground 10 facelock enroll --user formatprobe --label probe --skip-setup-check > /tmp/format-probe.log 2>&1 || true; grep 'camera format negotiated' /tmp/format-probe.log | grep -q 'format=GREY'" + facelock clear --user formatprobe --yes > /dev/null 2>&1 || true +else + echo "SKIP: no BRIO present — multi-node IR classification assertions skipped" +fi +# --- End multi-node IR regression gate --- + # Enrollment (direct, no daemon) run_test_contains "facelock enroll (oneshot)" \ "timeout --foreground $LIVE_TIMEOUT facelock enroll --user testuser --label test-face --skip-setup-check" \ From 8e2f5e59c0a598ca1896058b425808452f304306 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Fri, 3 Jul 2026 18:49:40 -0700 Subject: [PATCH 3/8] test(container): set RUST_LOG for the auto-detect assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit facelock auth's default tracing filter names the package (facelock_cli) rather than the bin crate (facelock), so it has never emitted logs without RUST_LOG — the assertion needs the env var to observe the auto-detected device. Verified against the real BRIO: with RUST_LOG the log shows auto-detected camera device=/dev/video2 and the RGB sibling demotion. Co-Authored-By: Claude Fable 5 --- test/run-oneshot-tests.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/run-oneshot-tests.sh b/test/run-oneshot-tests.sh index 2418ee9..05fc3f2 100755 --- a/test/run-oneshot-tests.sh +++ b/test/run-oneshot-tests.sh @@ -100,9 +100,11 @@ if echo "$DEVICES_OUT" | grep -qi "BRIO"; then # Auto-detection must select the IR (GREY) node, not the RGB sibling. # `facelock auth --user nobody` logs the auto-detected device before it - # exits (2) on the unknown user — no live face needed. + # exits (2) on the unknown user — no live face needed. RUST_LOG is set + # explicitly: the auth subcommand's default filter has never emitted logs + # (it names the package, facelock_cli, not the bin crate, facelock). run_test "auto-detect selects the IR (GREY) node" \ - "facelock auth --user nobody --config /etc/facelock/config.toml 2>&1 | grep 'auto-detected camera' | grep -q -- \"$IR_NODE\"" + "RUST_LOG=info facelock auth --user nobody --config /etc/facelock/config.toml 2>&1 | grep 'auto-detected camera' | grep -q -- \"$IR_NODE\"" # The negotiated capture format on the auto-detected node must be GREY. # A timed-out enroll still opens the camera and logs the negotiated format; From c232b0d86b6e7d90fe7ce984de94a4738440f141 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Fri, 3 Jul 2026 18:54:58 -0700 Subject: [PATCH 4/8] test(container): match ANSI-styled format log in the GREY assertion tracing's fmt layer emits ANSI styling between field names and values even when redirected to a file, so 'format=GREY' never matches; match the log message and the bare GREY value instead. Co-Authored-By: Claude Fable 5 --- test/run-oneshot-tests.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/run-oneshot-tests.sh b/test/run-oneshot-tests.sh index 05fc3f2..7da06fa 100755 --- a/test/run-oneshot-tests.sh +++ b/test/run-oneshot-tests.sh @@ -108,9 +108,11 @@ if echo "$DEVICES_OUT" | grep -qi "BRIO"; then # The negotiated capture format on the auto-detected node must be GREY. # A timed-out enroll still opens the camera and logs the negotiated format; - # no live face is needed (exit code intentionally ignored). + # no live face is needed (exit code intentionally ignored). The log line + # contains ANSI styling between field names and values, so match the + # message and the bare format value rather than 'format=GREY'. run_test "negotiated capture format is GREY on auto-detected node" \ - "RUST_LOG=info timeout --foreground 10 facelock enroll --user formatprobe --label probe --skip-setup-check > /tmp/format-probe.log 2>&1 || true; grep 'camera format negotiated' /tmp/format-probe.log | grep -q 'format=GREY'" + "RUST_LOG=info timeout --foreground 10 facelock enroll --user formatprobe --label probe --skip-setup-check > /tmp/format-probe.log 2>&1 || true; grep 'camera format negotiated' /tmp/format-probe.log | grep -q 'GREY'" facelock clear --user formatprobe --yes > /dev/null 2>&1 || true else echo "SKIP: no BRIO present — multi-node IR classification assertions skipped" From e2d184ff2b132c47f92f056d2b99c081ee3716f9 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Sat, 4 Jul 2026 12:07:39 -0700 Subject: [PATCH 5/8] fix(security): sliding-window frame variance + honest failure reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardware-verified field data (Logitech BRIO IR, require_ir=true) showed two bugs in the Plan 01 frame-variance gate: Bug 1 (poisoned history): matched-frame embeddings accumulated append-only for the whole session and EVERY consecutive pair had to drift, so one too-still pair made success permanently unreachable — a user who started still and then moved could never authenticate. The gate now evaluates a sliding window (FrameVarianceWindow, size = min_auth_frames) of the most recent matched frames; old frames are forgotten and evicted embeddings are zeroized at eviction (ring-buffer overwrite, zeroize-before-overwrite, plus zeroize on drop). A truly static input keeps every pair above the cutoff in every window, so it still can never pass — proven by unit tests. Bug 2 (wrong default + misleading UX): the 0.97 default assumed 0.02-0.10 live drift, which is empirically wrong — a frozen live human sits at 0.98-0.995 while truly static input sits >= ~0.999. Default raised to 0.995 (top of the frozen-human band): still users pass, static input never does. When the timeout expires with frames matching above the recognition threshold but the variance gate unsatisfied, the outcome now carries an internal AuthFailureReason so 'facelock test' says 'Face matched (best: X) but the liveness variance check was not satisfied' instead of a misleading 'No match'. D-Bus AuthResult contract and PAM fall-through semantics unchanged; the daemon path derives the same message client-side from matched=false + similarity >= threshold. Per-window min/max pair similarities are logged at debug level (values only) for field tuning. The inline oneshot copy in facelock-cli direct.rs is fixed identically. docs/security.md §B and docs/contracts.md updated with the sliding-window semantics and field-measured ranges (the wrong 0.02-0.10 claim removed). Co-Authored-By: Claude Fable 5 --- config/facelock.toml | 20 +- crates/facelock-cli/src/commands/auth.rs | 10 +- crates/facelock-cli/src/commands/test_cmd.rs | 69 ++++- crates/facelock-cli/src/direct.rs | 79 +++-- crates/facelock-cli/src/ipc_client.rs | 3 + crates/facelock-core/src/config.rs | 6 +- crates/facelock-core/src/types.rs | 288 +++++++++++++++++- crates/facelock-daemon/src/auth.rs | 67 ++-- .../tests/daemon_integration.rs | 96 ++++++ docs/contracts.md | 13 +- docs/security.md | 74 +++-- 11 files changed, 612 insertions(+), 113 deletions(-) diff --git a/config/facelock.toml b/config/facelock.toml index 8cae669..cee47b2 100644 --- a/config/facelock.toml +++ b/config/facelock.toml @@ -173,12 +173,16 @@ # Default: true # require_frame_variance = true -# Maximum cosine similarity allowed between consecutive matched frames. -# Consecutive frames must drift by at least (1 - this) to pass, so a lower value -# is stricter. Passive anti-photo only — it does NOT stop a video replay. -# If genuine faces are being rejected, raise this toward 0.99. -# Default: 0.97 -# frame_variance_max_similarity = 0.97 +# Maximum cosine similarity allowed between consecutive matched frames, +# evaluated over a sliding window of the most recent min_auth_frames matches. +# Lower is stricter. Passive anti-photo only — it does NOT stop a video replay. +# Field-measured: truly static input (photo, paused replay) sits at >= ~0.999; +# a frozen, non-blinking live human sits at 0.98–0.995. The default sits at the +# top of the frozen-human band so a still user at a login prompt always passes +# while perfectly static input never does. Lowering below 0.995 risks locking +# out users who hold naturally still. +# Default: 0.995 +# frame_variance_max_similarity = 0.995 # Minimum per-face standard deviation (measured on the RAW grayscale frame) for # the IR texture check. Flat photos/screens score < 5 in IR; real skin > 15. @@ -204,7 +208,9 @@ # Default: false # suppress_unknown = false -# Minimum number of matching frames required before accepting. +# Minimum number of matching frames required before accepting. Also the size +# of the sliding window the frame-variance check evaluates: the most recent +# min_auth_frames matched frames must all drift pairwise. # Only applies when require_frame_variance is true. # Default: 3 # min_auth_frames = 3 diff --git a/crates/facelock-cli/src/commands/auth.rs b/crates/facelock-cli/src/commands/auth.rs index b3e7cc6..5f969f4 100644 --- a/crates/facelock-cli/src/commands/auth.rs +++ b/crates/facelock-cli/src/commands/auth.rs @@ -195,9 +195,17 @@ pub fn run(user: String, config_path: Option) -> i32 { DaemonResponse::AuthResult(MatchResult { matched: false, similarity, + failure_reason, .. }) => { - info!(user = %user, similarity = format!("{similarity:.4}"), "no match"); + // Exit code stays 1 (PAM falls through to password); the reason is + // diagnostic only. + info!( + user = %user, + similarity = format!("{similarity:.4}"), + variance_blocked = failure_reason.is_some(), + "no match" + ); 1 } DaemonResponse::Error { message } if message.contains("all frames dark") => { diff --git a/crates/facelock-cli/src/commands/test_cmd.rs b/crates/facelock-cli/src/commands/test_cmd.rs index 21caa2a..2ce4147 100644 --- a/crates/facelock-cli/src/commands/test_cmd.rs +++ b/crates/facelock-cli/src/commands/test_cmd.rs @@ -85,26 +85,52 @@ pub fn run(user: Option) -> anyhow::Result<()> { ipc_client::require_root("sudo facelock test")?; let start = Instant::now(); match crate::direct::authenticate(&config, &user) { - Ok(true) => { + Ok(result) if result.matched => { let elapsed = start.elapsed(); - println!("Matched in {:.2}s", elapsed.as_secs_f64()); + println!( + "Matched (similarity: {:.2}) in {:.2}s", + result.similarity, + elapsed.as_secs_f64() + ); notify_if_enabled( notif_config, &NotifyEvent::Success { - label: None, - similarity: 0.0, + label: result.label.clone(), + similarity: result.similarity, }, ); } - Ok(false) => { + Ok(result) => { let elapsed = start.elapsed(); - println!("No match after {:.1}s", elapsed.as_secs_f64()); - notify_if_enabled( - notif_config, - &NotifyEvent::Failure { - reason: "no match".to_string(), - }, - ); + if result.failure_reason + == Some(facelock_core::types::AuthFailureReason::VarianceNotSatisfied) + { + println!( + "Face matched (best: {:.2}) but the liveness variance check was not \ + satisfied after {:.1}s — try moving slightly, or tune \ + security.frame_variance_max_similarity", + result.similarity, + elapsed.as_secs_f64() + ); + notify_if_enabled( + notif_config, + &NotifyEvent::Failure { + reason: "face matched but liveness variance not satisfied".to_string(), + }, + ); + } else { + println!( + "No match (best: {:.2}) after {:.1}s", + result.similarity, + elapsed.as_secs_f64() + ); + notify_if_enabled( + notif_config, + &NotifyEvent::Failure { + reason: format!("no match (best similarity: {:.2})", result.similarity), + }, + ); + } } Err(e) => { notify_if_enabled( @@ -142,6 +168,25 @@ pub fn run(user: Option) -> anyhow::Result<()> { similarity: result.similarity, }, ); + } else if config.security.require_frame_variance + && result.similarity >= config.recognition.threshold + { + // The D-Bus AuthResult contract carries no failure reason, but + // matched=false with similarity above the recognition threshold + // means a liveness gate (frame variance) blocked the attempt. + println!( + "Face matched (best: {:.2}) but the liveness variance check was not \ + satisfied after {:.1}s — try moving slightly, or tune \ + security.frame_variance_max_similarity", + result.similarity, + elapsed.as_secs_f64() + ); + notify_if_enabled( + notif_config, + &NotifyEvent::Failure { + reason: "face matched but liveness variance not satisfied".to_string(), + }, + ); } else { println!( "No match (best: {:.2}) after {:.1}s", diff --git a/crates/facelock-cli/src/direct.rs b/crates/facelock-cli/src/direct.rs index 3384eca..df2c4b0 100644 --- a/crates/facelock-cli/src/direct.rs +++ b/crates/facelock-cli/src/direct.rs @@ -98,12 +98,19 @@ pub fn load_engine(config: &Config) -> anyhow::Result { .context("failed to load face engine") } -/// Direct authentication — returns true if matched. -pub fn authenticate(config: &Config, user: &str) -> anyhow::Result { +/// Direct authentication — returns the full match result (including an +/// internal failure reason when frames matched but a liveness gate blocked). +pub fn authenticate(config: &Config, user: &str) -> anyhow::Result { let store = open_store(config)?; if !store.has_models(user).context("storage error")? { - return Ok(false); + return Ok(MatchResult { + matched: false, + model_id: None, + label: None, + similarity: 0.0, + failure_reason: None, + }); } let OpenedCamera { @@ -127,7 +134,7 @@ pub fn authenticate(config: &Config, user: &str) -> anyhow::Result { ); match response { - DaemonResponse::AuthResult(MatchResult { matched, .. }) => Ok(matched), + DaemonResponse::AuthResult(result) => Ok(result), DaemonResponse::Error { message } => bail!("{message}"), _ => bail!("unexpected auth response"), } @@ -321,8 +328,8 @@ mod facelock_daemon_auth { use facelock_core::ipc::DaemonResponse; use facelock_core::traits::{CameraSource, FaceProcessor}; use facelock_core::types::{ - FaceEmbedding, FaceModelInfo, MatchResult, best_match, check_frame_variance, - zeroize_embedding, zeroize_stored_embeddings, + AuthFailureReason, FaceEmbedding, FaceModelInfo, FrameVarianceWindow, MatchResult, + best_match, zeroize_stored_embeddings, }; use facelock_daemon::liveness::LandmarkTracker; use std::time::Instant; @@ -350,8 +357,13 @@ mod facelock_daemon_auth { Instant::now() + std::time::Duration::from_secs(config.recognition.timeout_secs as u64); let threshold = config.recognition.threshold; let mut best_similarity: f32 = 0.0; - let mut matched_frame_embeddings: Vec = - Vec::with_capacity(config.security.min_auth_frames as usize); + // Sliding window over the most recent matched-frame embeddings (see + // facelock-daemon auth.rs — this inline copy mirrors it): an early + // too-still moment is forgotten once the user moves, while a static + // input still never passes. + let mut variance_window = FrameVarianceWindow::new(config.security.min_auth_frames); + let mut matched_frames_total: u32 = 0; + let mut variance_ever_passed = false; let mut dark_count: u32 = 0; let mut frame_count: u32 = 0; let mut best_model_id: Option = None; @@ -430,19 +442,27 @@ mod facelock_daemon_auth { best_model_id = frame_best_id; } if frame_best_sim >= threshold && !frame_matched { - matched_frame_embeddings.push(*embedding); + variance_window.push(*embedding); + matched_frames_total += 1; + // Log drift values (never embeddings) for field tuning of + // frame_variance_max_similarity. + if let Some((min_sim, max_sim)) = variance_window.min_max_pair_similarity() { + debug!( + frame = frame_count, + min_pair_similarity = format!("{min_sim:.4}"), + max_pair_similarity = format!("{max_sim:.4}"), + window = variance_window.len(), + "frame variance window" + ); + } frame_matched = true; } } // Frame variance check + landmark liveness check if config.security.require_frame_variance { - if matched_frame_embeddings.len() >= config.security.min_auth_frames as usize - && check_frame_variance( - &matched_frame_embeddings, - config.security.frame_variance_max_similarity, - ) - { + if variance_window.passes(config.security.frame_variance_max_similarity) { + variance_ever_passed = true; // If landmark liveness is required, check it too if config.security.require_landmark_liveness && !landmark_tracker.check_liveness() @@ -468,12 +488,11 @@ mod facelock_daemon_auth { model_id: best_model_id, label: best_model_id.and_then(&label_for), similarity: best_similarity, + failure_reason: None, }); // Zeroize sensitive embedding data before returning zeroize_stored_embeddings(&mut stored); - for emb in &mut matched_frame_embeddings { - zeroize_embedding(emb); - } + variance_window.zeroize_all(); return response; } } else if best_similarity >= threshold { @@ -500,21 +519,30 @@ mod facelock_daemon_auth { model_id: best_model_id, label: best_model_id.and_then(&label_for), similarity: best_similarity, + failure_reason: None, }); // Zeroize sensitive embedding data before returning zeroize_stored_embeddings(&mut stored); - for emb in &mut matched_frame_embeddings { - zeroize_embedding(emb); - } + variance_window.zeroize_all(); return response; } } + // Timeout expired. If frames DID match above the recognition threshold + // but the variance gate never passed, report that instead of a + // misleading plain "no match". + let failure_reason = if config.security.require_frame_variance + && variance_window.is_full() + && !variance_ever_passed + { + Some(AuthFailureReason::VarianceNotSatisfied) + } else { + None + }; + // Zeroize sensitive embedding data before returning on failure/timeout path zeroize_stored_embeddings(&mut stored); - for emb in &mut matched_frame_embeddings { - zeroize_embedding(emb); - } + variance_window.zeroize_all(); let duration = start.elapsed(); if dark_count == frame_count && frame_count > 0 { @@ -528,7 +556,9 @@ mod facelock_daemon_auth { user, similarity = format!("{best_similarity:.4}"), frames = frame_count, + matched = matched_frames_total, duration_ms = duration.as_millis() as u64, + variance_blocked = failure_reason.is_some(), "authentication failed" ); DaemonResponse::AuthResult(MatchResult { @@ -536,6 +566,7 @@ mod facelock_daemon_auth { model_id: None, label: None, similarity: best_similarity, + failure_reason, }) } } diff --git a/crates/facelock-cli/src/ipc_client.rs b/crates/facelock-cli/src/ipc_client.rs index a361ba7..3ae5298 100644 --- a/crates/facelock-cli/src/ipc_client.rs +++ b/crates/facelock-cli/src/ipc_client.rs @@ -105,6 +105,9 @@ pub fn send_request(request: &DaemonRequest) -> anyhow::Result { Some(result.label) }, similarity: result.similarity as f32, + // Not part of the D-Bus AuthResult contract; derived client-side + // where needed (see test_cmd). + failure_reason: None, })) } DaemonRequest::Enroll { user, label } => { diff --git a/crates/facelock-core/src/config.rs b/crates/facelock-core/src/config.rs index 36bd330..fcf4796 100644 --- a/crates/facelock-core/src/config.rs +++ b/crates/facelock-core/src/config.rs @@ -207,8 +207,10 @@ pub struct SecurityConfig { #[serde(default = "default_ir_texture_min_stddev")] pub ir_texture_min_stddev: f32, /// Maximum consecutive matched-frame cosine similarity allowed by the passive - /// frame-variance check. Higher = more permissive. Default 0.97 (require ≥0.03 - /// drift). Passive anti-photo only; does not defeat video replay. + /// frame-variance check, evaluated over a sliding window of the most recent + /// `min_auth_frames` matches. Higher = more permissive. Default 0.995: truly + /// static input sits ≳0.999, a frozen live human at 0.98–0.995. Passive + /// anti-photo only; does not defeat video replay. #[serde(default = "default_frame_variance_max_similarity")] pub frame_variance_max_similarity: f32, #[serde(default)] diff --git a/crates/facelock-core/src/types.rs b/crates/facelock-core/src/types.rs index bcfb0a9..5aef232 100644 --- a/crates/facelock-core/src/types.rs +++ b/crates/facelock-core/src/types.rs @@ -73,6 +73,16 @@ pub struct FaceModelInfo { pub embedder_model: String, } +/// Why an authentication attempt that saw matching frames still failed. +/// Internal plumbing only — never crosses the D-Bus `AuthResult` contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AuthFailureReason { + /// Frames matched the enrolled face above the recognition threshold, but + /// the passive frame-variance liveness gate was never satisfied before + /// the timeout (input too static). + VarianceNotSatisfied, +} + /// Result of a face match attempt #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MatchResult { @@ -80,6 +90,10 @@ pub struct MatchResult { pub model_id: Option, pub label: Option, pub similarity: f32, + /// Why the attempt failed despite matching frames (internal diagnostics; + /// not part of the D-Bus wire contract). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure_reason: Option, } /// Cosine similarity between two L2-normalized embeddings (= dot product) @@ -88,14 +102,23 @@ pub fn cosine_similarity(a: &FaceEmbedding, b: &FaceEmbedding) -> f32 { } /// Default maximum consecutive-frame cosine similarity for the passive -/// frame-variance check. Consecutive matched frames must drift by at least -/// `1 - this` (≈0.03) to be accepted, aligning with the documented 0.02–0.10 -/// live-face drift range. Configurable via `security.frame_variance_max_similarity`. +/// frame-variance check. Configurable via `security.frame_variance_max_similarity`. +/// +/// Field-measured ranges (Logitech BRIO IR node, real user at a login prompt): +/// - truly static input (photo on a stand, paused replay): pair similarity ≳ 0.999 +/// - a frozen, non-blinking live human: (0.98, 0.995] +/// - a naturally moving live human: well below 0.98 +/// +/// The check's honest job is rejecting *perfectly static* input as +/// defense-in-depth, so the default sits at the top of the frozen-human band: +/// a live user always passes, a static image never does. (The earlier 0.97 +/// default assumed 0.02–0.10 live drift, which is empirically wrong for a +/// still user and caused hard false-reject lockups.) /// /// NOTE: frame-variance is a *passive* anti-photo heuristic only. It raises the /// bar for a *static* image but does NOT defeat a video replay (which contains /// real inter-frame motion). IR enforcement remains the load-bearing defense. -pub const DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY: f32 = 0.97; +pub const DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY: f32 = 0.995; /// Check that matched embeddings show sufficient variance (anti-photo-attack). /// Compares all consecutive pairs — every pair must differ enough to rule out @@ -118,6 +141,118 @@ pub fn check_frame_variance(embeddings: &[FaceEmbedding], max_similarity: f32) - true } +/// Sliding window of the most recent matched-frame embeddings for the passive +/// frame-variance gate. +/// +/// The gate passes only when the window is *full* AND every consecutive pair +/// inside it drifts (similarity at or below the cutoff). Because the window +/// slides, one too-still moment early in the session is forgotten once enough +/// moving frames arrive — a user who starts still can always recover. A truly +/// static input (photo, paused replay) keeps every pair above the cutoff in +/// every window, so it can never pass regardless of session length. +/// +/// Implemented as a ring buffer: eviction overwrites the oldest slot in place +/// (zeroized first), so evicted embeddings never linger in memory. All slots +/// are zeroized on drop as well. +pub struct FrameVarianceWindow { + slots: Vec, + capacity: usize, + /// Ring index where the next push goes (== oldest slot once full). + next: usize, +} + +impl FrameVarianceWindow { + /// Create a window sized by `security.min_auth_frames` (the number of + /// matched frames that constitute enough evidence to authenticate). + /// Clamped to at least 2, since variance needs a pair to compare. + pub fn new(min_auth_frames: u32) -> Self { + let capacity = (min_auth_frames as usize).max(2); + Self { + slots: Vec::with_capacity(capacity), + capacity, + next: 0, + } + } + + /// Add a matched-frame embedding, evicting (and zeroizing) the oldest + /// when the window is full. + pub fn push(&mut self, embedding: FaceEmbedding) { + if self.slots.len() < self.capacity { + self.slots.push(embedding); + self.next = self.slots.len() % self.capacity; + } else { + // Zeroize the evicted embedding before overwriting its slot so it + // never lingers, then take its place in the ring. + zeroize_embedding(&mut self.slots[self.next]); + self.slots[self.next] = embedding; + self.next = (self.next + 1) % self.capacity; + } + } + + pub fn len(&self) -> usize { + self.slots.len() + } + + pub fn is_empty(&self) -> bool { + self.slots.is_empty() + } + + pub fn is_full(&self) -> bool { + self.slots.len() == self.capacity + } + + /// Slot index of the `chrono`-th oldest embedding. + fn index_at(&self, chrono: usize) -> usize { + if self.slots.len() < self.capacity { + chrono + } else { + (self.next + chrono) % self.capacity + } + } + + /// Cosine similarities of consecutive (chronological) pairs in the window. + fn pair_similarities(&self) -> impl Iterator + '_ { + (1..self.slots.len()).map(move |i| { + cosine_similarity( + &self.slots[self.index_at(i - 1)], + &self.slots[self.index_at(i)], + ) + }) + } + + /// Min and max consecutive-pair similarity currently in the window. + /// For diagnostics/tuning logs only — exposes similarity values, never + /// embedding contents. `None` until the window holds at least two frames. + pub fn min_max_pair_similarity(&self) -> Option<(f32, f32)> { + let mut it = self.pair_similarities(); + let first = it.next()?; + Some(it.fold((first, first), |(mn, mx), s| (mn.min(s), mx.max(s)))) + } + + /// The variance gate: full window AND every consecutive pair drifting. + pub fn passes(&self, max_similarity: f32) -> bool { + self.is_full() && self.pair_similarities().all(|s| s <= max_similarity) + } + + /// Zeroize all held embeddings and empty the window. + /// Call at security boundaries (auth success/failure exit paths). + pub fn zeroize_all(&mut self) { + for emb in &mut self.slots { + zeroize_embedding(emb); + } + self.slots.clear(); + self.next = 0; + } +} + +impl Drop for FrameVarianceWindow { + fn drop(&mut self) { + for emb in &mut self.slots { + zeroize_embedding(emb); + } + } +} + /// Convert f32 bits to ordered u32 for constant-time comparison. /// Positive floats: flip sign bit. Negative floats: flip all bits. /// Done branchlessly using the sign bit as a mask so that u32 ordering @@ -413,13 +548,13 @@ mod tests { #[test] fn frame_variance_rejects_near_static_sequence() { - // Near-identical consecutive embeddings (drift < 0.03, sim > 0.97) must fail. + // Near-identical consecutive embeddings (sim > 0.995, static-like) must fail. let a = tilted_unit(1.0, 0.02); let b = tilted_unit(1.0, 0.03); let c = tilted_unit(1.0, 0.04); let seq = [a, b, c]; - // Sanity: consecutive similarities are all above 0.97. - assert!(cosine_similarity(&seq[0], &seq[1]) > 0.97); + // Sanity: consecutive similarities are all above the 0.995 default. + assert!(cosine_similarity(&seq[0], &seq[1]) > 0.995); assert!( !check_frame_variance(&seq, DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), "near-static sequence must be rejected" @@ -428,13 +563,13 @@ mod tests { #[test] fn frame_variance_accepts_live_like_sequence() { - // Live-like drift (>= 0.03 per step, sim < 0.97) must pass. + // Live-like drift (sim well below the 0.995 default) must pass. let a = tilted_unit(1.0, 0.0); let b = tilted_unit(1.0, 0.30); let c = tilted_unit(1.0, 0.60); let seq = [a, b, c]; - assert!(cosine_similarity(&seq[0], &seq[1]) < 0.97); - assert!(cosine_similarity(&seq[1], &seq[2]) < 0.97); + assert!(cosine_similarity(&seq[0], &seq[1]) < 0.995); + assert!(cosine_similarity(&seq[1], &seq[2]) < 0.995); assert!( check_frame_variance(&seq, DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), "live-like sequence must pass" @@ -455,6 +590,139 @@ mod tests { assert!(check_frame_variance(&seq, sim + 0.001)); } + /// Unit embedding at a planar angle: cosine similarity between two of these + /// is exactly cos(theta_a - theta_b), giving precise control over drift. + fn unit_at_angle(theta: f32) -> FaceEmbedding { + let mut e: FaceEmbedding = [0.0; 512]; + e[0] = theta.cos(); + e[1] = theta.sin(); + e + } + + #[test] + fn variance_window_still_then_moving_recovers() { + // Field bug #1: a user who starts perfectly still must be able to recover + // once they move. With an append-only history one still pair poisoned the + // whole session; a sliding window forgets it. + let mut w = FrameVarianceWindow::new(3); + let still = unit_at_angle(0.0); + for _ in 0..6 { + w.push(still); + assert!( + !w.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), + "still frames must never satisfy the variance gate" + ); + } + // Now the user moves: consecutive drift cos(0.15) ~= 0.9888 <= 0.995. + for (i, theta) in [0.15f32, 0.30, 0.45].iter().enumerate() { + w.push(unit_at_angle(*theta)); + if i >= 2 { + assert!( + w.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), + "window filled with moving frames must pass (recovery)" + ); + } + } + // And it must have passed by the time the window is all moving frames. + assert!(w.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY)); + } + + #[test] + fn variance_window_static_never_passes() { + // A truly static input (photo on a stand) is identical frame-to-frame. + // No matter how long it runs, no window may ever pass. + let mut w = FrameVarianceWindow::new(3); + let photo = unit_at_angle(0.7); + for _ in 0..50 { + w.push(photo); + assert!( + !w.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), + "a fully static sequence must never pass, regardless of length" + ); + } + } + + #[test] + fn variance_window_near_static_replay_never_passes() { + // A paused replay / photo with sensor noise sits at pair similarity + // >= ~0.999 — still above the 0.995 default, so it must never pass. + let mut w = FrameVarianceWindow::new(3); + for i in 0..50 { + // steps of 0.02 rad: consecutive similarity cos(0.02) ~= 0.9998 + w.push(unit_at_angle(i as f32 * 0.02)); + assert!( + !w.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), + "near-static (sim ~0.9998) must never pass at the default cutoff" + ); + } + } + + #[test] + fn variance_window_boundary_at_default() { + assert_eq!(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY, 0.995); + + // Just above the default (pair sim ~0.9965 > 0.995): rejected. + let mut too_still = FrameVarianceWindow::new(2); + too_still.push(unit_at_angle(0.0)); + too_still.push(unit_at_angle(0.0837)); // cos ~= 0.9965 + let (mn, _) = too_still.min_max_pair_similarity().unwrap(); + assert!( + mn > 0.995, + "sanity: pair must sit above the cutoff, got {mn}" + ); + assert!(!too_still.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY)); + + // Frozen-but-live human range (field-measured (0.98, 0.995]): accepted. + let mut frozen_human = FrameVarianceWindow::new(2); + frozen_human.push(unit_at_angle(0.0)); + frozen_human.push(unit_at_angle(0.1415)); // cos ~= 0.9900 + let (mn, mx) = frozen_human.min_max_pair_similarity().unwrap(); + assert!( + mn > 0.98 && mx <= 0.995, + "sanity: pair in frozen-human band, got {mn}..{mx}" + ); + assert!(frozen_human.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY)); + } + + #[test] + fn variance_window_requires_full_window() { + // The gate must not fire before min_auth_frames matched frames are seen. + let mut w = FrameVarianceWindow::new(3); + w.push(unit_at_angle(0.0)); + w.push(unit_at_angle(0.2)); + assert!(!w.is_full()); + assert!( + !w.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), + "partial window must not pass even with good drift" + ); + } + + #[test] + fn variance_window_evicts_oldest_embedding() { + // Capacity 2: pushing a third embedding must evict (and not retain) the first. + let mut w = FrameVarianceWindow::new(2); + let first = unit_at_angle(0.0); + w.push(first); + w.push(unit_at_angle(0.5)); + w.push(unit_at_angle(1.0)); + assert_eq!(w.len(), 2, "window must stay at capacity"); + assert!( + w.slots.iter().all(|s| *s != first), + "evicted embedding must not be retained in the window" + ); + } + + #[test] + fn variance_window_zeroize_all_clears() { + let mut w = FrameVarianceWindow::new(2); + w.push(unit_at_angle(0.3)); + w.push(unit_at_angle(0.6)); + w.zeroize_all(); + assert_eq!(w.len(), 0); + assert!(w.slots.is_empty()); + assert!(!w.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY)); + } + #[test] fn cosine_similarity_opposite() { let mut a = [0.0f32; 512]; diff --git a/crates/facelock-daemon/src/auth.rs b/crates/facelock-daemon/src/auth.rs index 821854f..8cb831f 100644 --- a/crates/facelock-daemon/src/auth.rs +++ b/crates/facelock-daemon/src/auth.rs @@ -8,7 +8,7 @@ use facelock_core::fs_security::{ensure_dir, ensure_private_dir, write_file}; use facelock_core::ipc::DaemonResponse; use facelock_core::traits::{CameraSource, FaceProcessor}; use facelock_core::types::{ - FaceEmbedding, Frame, MatchResult, best_match, check_frame_variance, zeroize_embedding, + AuthFailureReason, FaceEmbedding, Frame, FrameVarianceWindow, MatchResult, best_match, zeroize_stored_embeddings, }; use facelock_store::FaceStore; @@ -71,6 +71,7 @@ pub fn pre_check( model_id: None, label: None, similarity: 0.0, + failure_reason: None, })); } @@ -214,8 +215,13 @@ fn authenticate_inner( Instant::now() + std::time::Duration::from_secs(config.recognition.timeout_secs as u64); let threshold = config.recognition.threshold; let mut best_similarity: f32 = 0.0; - let mut matched_frame_embeddings: Vec = - Vec::with_capacity(config.security.min_auth_frames as usize); + // Sliding window over the most recent matched-frame embeddings. The gate + // evaluates only this window, so an early too-still moment is forgotten + // once the user moves (a static input still never passes: every window + // of a static sequence stays above the cutoff). + let mut variance_window = FrameVarianceWindow::new(config.security.min_auth_frames); + let mut matched_frames_total: u32 = 0; + let mut variance_ever_passed = false; let mut dark_count: u32 = 0; let mut frame_count: u32 = 0; let mut best_model_id: Option = None; @@ -311,26 +317,34 @@ fn authenticate_inner( } if frame_best_sim >= threshold && !frame_matched { - matched_frame_embeddings.push(*embedding); + variance_window.push(*embedding); + matched_frames_total += 1; + // Log drift values (never embeddings) so field tuning of + // frame_variance_max_similarity has real data to work with. + if let Some((min_sim, max_sim)) = variance_window.min_max_pair_similarity() { + debug!( + frame = frame_count, + min_pair_similarity = format!("{min_sim:.4}"), + max_pair_similarity = format!("{max_sim:.4}"), + window = variance_window.len(), + "frame variance window" + ); + } frame_matched = true; } debug!( frame = frame_count, similarity = format!("{frame_best_sim:.4}"), - matched_frames = matched_frame_embeddings.len(), + matched_frames = matched_frames_total, "face comparison" ); } // Frame variance check + landmark liveness check if config.security.require_frame_variance { - if matched_frame_embeddings.len() >= config.security.min_auth_frames as usize - && check_frame_variance( - &matched_frame_embeddings, - config.security.frame_variance_max_similarity, - ) - { + if variance_window.passes(config.security.frame_variance_max_similarity) { + variance_ever_passed = true; // If landmark liveness is required, check it too if config.security.require_landmark_liveness && !landmark_tracker.check_liveness() { debug!( @@ -346,7 +360,7 @@ fn authenticate_inner( user, similarity = format!("{best_similarity:.4}"), frames = frame_count, - matched = matched_frame_embeddings.len(), + matched = matched_frames_total, duration_ms = duration.as_millis() as u64, "authentication succeeded" ); @@ -374,12 +388,11 @@ fn authenticate_inner( model_id: best_model_id, label: best_model_id.and_then(&label_for), similarity: best_similarity, + failure_reason: None, }); // Zero sensitive data before returning zeroize_stored_embeddings(stored); - for emb in &mut matched_frame_embeddings { - zeroize_embedding(emb); - } + variance_window.zeroize_all(); return response; } } else if best_similarity >= threshold { @@ -425,22 +438,30 @@ fn authenticate_inner( model_id: best_model_id, label: best_model_id.and_then(&label_for), similarity: best_similarity, + failure_reason: None, }); zeroize_stored_embeddings(stored); - for emb in &mut matched_frame_embeddings { - zeroize_embedding(emb); - } + variance_window.zeroize_all(); return response; } } let duration = start.elapsed(); + // Timeout expired. If frames DID match above the recognition threshold but + // the variance gate never passed, say so — "no match" would be misleading. + let failure_reason = if config.security.require_frame_variance + && variance_window.is_full() + && !variance_ever_passed + { + Some(AuthFailureReason::VarianceNotSatisfied) + } else { + None + }; + // Zero sensitive data before returning zeroize_stored_embeddings(stored); - for emb in &mut matched_frame_embeddings { - zeroize_embedding(emb); - } + variance_window.zeroize_all(); if dark_count == frame_count && frame_count > 0 { warn!( @@ -473,8 +494,9 @@ fn authenticate_inner( user, similarity = format!("{best_similarity:.4}"), frames = frame_count, - matched = matched_frame_embeddings.len(), + matched = matched_frames_total, duration_ms = duration.as_millis() as u64, + variance_blocked = failure_reason.is_some(), "authentication failed" ); @@ -504,6 +526,7 @@ fn authenticate_inner( model_id: None, label: None, similarity: best_similarity, + failure_reason, }) } diff --git a/crates/facelock-daemon/tests/daemon_integration.rs b/crates/facelock-daemon/tests/daemon_integration.rs index cdab2b0..0a39371 100644 --- a/crates/facelock-daemon/tests/daemon_integration.rs +++ b/crates/facelock-daemon/tests/daemon_integration.rs @@ -240,6 +240,102 @@ fn warmup_frames_zero_skips_discard() { ); } +/// Unit embedding at a planar angle: cosine similarity between two of these +/// is exactly cos(theta_a - theta_b). +fn unit_at_angle(theta: f32) -> facelock_core::types::FaceEmbedding { + let mut e: facelock_core::types::FaceEmbedding = [0.0; 512]; + e[0] = theta.cos(); + e[1] = theta.sin(); + e +} + +#[test] +fn static_matching_frames_report_variance_reason() { + use facelock_core::types::AuthFailureReason; + + let mut config = test_config(); + config.recognition.timeout_secs = 1; + + // Static input: the exact same embedding every frame (photo-like), which + // matches the enrolled template perfectly but never drifts. + let emb = unit_at_angle(0.0); + let mut camera = MockCamera::bright(64, 64, 4); + let mut engine = MockFaceEngine::one_face(emb); + let stored = vec![(1u32, emb)]; + + let resp = facelock_daemon::auth::authenticate_with_embeddings( + &mut camera, + &mut engine, + &stored, + &[], + &config, + "testuser", + false, + ); + + match resp { + DaemonResponse::AuthResult(r) => { + assert!(!r.matched, "static input must not authenticate"); + assert!( + r.similarity >= config.recognition.threshold, + "sanity: frames matched above the recognition threshold" + ); + assert_eq!( + r.failure_reason, + Some(AuthFailureReason::VarianceNotSatisfied), + "outcome must say the variance gate was the blocker" + ); + } + other => panic!("unexpected response: {other:?}"), + } +} + +#[test] +fn still_then_moving_frames_recover_and_authenticate() { + let mut config = test_config(); + config.recognition.timeout_secs = 2; + + // A user who holds still for the first frames, then moves. With the old + // append-only history the early still pair poisoned the session forever; + // the sliding window must recover and authenticate. + let still = unit_at_angle(0.0); + let frames = vec![ + still, + still, + still, + still, + still, + unit_at_angle(0.15), // pair drift cos(0.15) ~= 0.9888 <= 0.995 + unit_at_angle(0.30), + unit_at_angle(0.45), + ]; + let mut camera = MockCamera::bright(64, 64, 16); + let mut engine = MockFaceEngine::cycling(frames); + let stored = vec![(1u32, still)]; + + let resp = facelock_daemon::auth::authenticate_with_embeddings( + &mut camera, + &mut engine, + &stored, + &[], + &config, + "testuser", + false, + ); + + match resp { + DaemonResponse::AuthResult(r) => { + assert!( + r.matched, + "still-then-moving user must authenticate once the window fills \ + with moving frames, got similarity {} reason {:?}", + r.similarity, r.failure_reason + ); + } + other => panic!("unexpected response: {other:?}"), + } +} + #[test] fn failed_auth_rate_limit_persists_across_handler_restart() { use facelock_daemon::handler::Handler; diff --git a/docs/contracts.md b/docs/contracts.md index e3c2fd7..82579a7 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -174,19 +174,22 @@ pam_facelock(): for user |---------|--------|---------| | IR camera enforcement | `security.require_ir` | **true** | | Frame variance check | `security.require_frame_variance` | **true** | -| Frame variance cutoff | `security.frame_variance_max_similarity` | 0.97 | +| Frame variance cutoff | `security.frame_variance_max_similarity` | 0.995 | | IR texture cutoff (raw frame) | `security.ir_texture_min_stddev` | 10.0 | | Landmark liveness | `security.require_landmark_liveness` | **false** | -| Minimum auth frames | `security.min_auth_frames` | 3 | -| Frame variance default const | `DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY` | 0.97 | +| Minimum auth frames (= variance window size) | `security.min_auth_frames` | 3 | +| Frame variance default const | `DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY` | 0.995 | IR classification requires a whole `ir`/`infrared` name token or a quirks `force_ir` entry; a GREY/Y16 format alone is not treated as IR. A `force_ir` quirk is device-level ("this USB device has an IR sensor"): when the device exposes multiple capture nodes and at least one has an IR-like format, only the format-bearing node(s) classify IR (see `docs/security.md` §A). Frame variance is passive -anti-photo only (does not stop video replay); IR texture is measured on the raw frame, -never CLAHE. These defaults must not be weakened without security review. +anti-photo only (does not stop video replay); it is evaluated over a sliding window +of the most recent `min_auth_frames` matched frames (see `docs/security.md` §B), with +a 0.995 cutoff separating truly static input (≳0.999) from a frozen live human +(0.98–0.995). IR texture is measured on the raw frame, never CLAHE. These defaults +must not be weakened without security review. ## Models diff --git a/docs/security.md b/docs/security.md index 3ef3694..c6ac3fc 100644 --- a/docs/security.md +++ b/docs/security.md @@ -86,43 +86,57 @@ if config.security.require_ir && !device_is_ir { #### B. Frame Variance Check (Required) Require minimum variance across consecutive matched frames during authentication. -Every consecutive pair of matched-frame embeddings must drift by at least -`1 - frame_variance_max_similarity` (default 0.03), i.e. cosine similarity must be -at or below the cutoff. The cutoff is configurable -(`security.frame_variance_max_similarity`, default **0.97**, in -`facelock-core/src/types.rs` as `DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY`): - -```rust -/// Reject when any consecutive pair is too similar (static image). -pub fn check_frame_variance(embeddings: &[FaceEmbedding], max_similarity: f32) -> bool { - if embeddings.len() < 2 { return false; } - for window in embeddings.windows(2) { - // Real faces vary by 0.02-0.10 between frames; a static photo is near-identical. - if cosine_similarity(&window[0], &window[1]) > max_similarity { return false; } - } - true -} -``` +The check is evaluated over a **sliding window** of the most recent +`min_auth_frames` matched-frame embeddings (`FrameVarianceWindow` in +`facelock-core/src/types.rs`): the gate passes only when the window is full AND +every consecutive pair inside it has cosine similarity at or below the cutoff +(`security.frame_variance_max_similarity`, default **0.995**, +`DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY`). + +**Why a sliding window**: an earlier version accumulated every matched frame for +the whole session and required *all* consecutive pairs to drift. One too-still +pair at any moment then made success permanently unreachable — a user who +started still and then moved could never recover (hardware-verified lockout). +The window forgets old frames: once it fills with moving frames the gate passes. +The anti-photo property is preserved because a truly static input keeps *every* +pair above the cutoff in *every* window, so no window ever passes, regardless of +session length. Embeddings evicted from the window are zeroized at eviction. + +**Field-measured consecutive-pair similarity** (Logitech BRIO IR node, real user): + +| Input | Consecutive-pair cosine similarity | +|-------|-----------------------------------| +| Truly static (photo on a stand, paused replay) | ≳ 0.999 | +| Frozen, non-blinking live human | 0.98 – 0.995 | +| Naturally moving live human | well below 0.98 | + +The default cutoff (0.995) sits at the top of the frozen-human band: a live user +holding naturally still at a login prompt passes, perfectly static input never +does. (An earlier default of 0.97 assumed a 0.02–0.10 live-drift range that is +empirically wrong for a still user — it caused hard false-reject lockups where +auth reported "no match" despite 0.91–0.98 recognition similarity.) **Honest scope — this does NOT stop video replay.** Frame-variance only rules out a *static* image (printed photo, single frozen frame). A recorded video of the enrolled user contains genuine inter-frame motion and will pass this check. Frame-variance is a -cheap passive filter; **IR enforcement (§A) is the load-bearing anti-spoof defense**, and -active liveness (opt-in landmark/blink) is the answer to video replay. The default was -tightened from 0.998 (which accepted almost any drift) to 0.97 so the check actually -demands the documented 0.02-0.10 live-face drift. - -**False-reject tradeoff**: 0.97 is the low end of the live-drift band. Genuinely still -users under steady lighting can occasionally dip below 0.03 drift; if false rejects rise, -raise `frame_variance_max_similarity` (toward 0.99) — it is the tuning knob. It stays -purely passive either way. +cheap passive defense-in-depth filter whose honest job is rejecting perfectly-static +input; **IR enforcement plus the raw-frame texture check (§A, §C) are the load-bearing +anti-spoof defenses**, and active liveness (opt-in landmark/blink) is the answer to +video replay. + +**False-reject tradeoff**: lowering the cutoff below 0.995 rejects users who hold +naturally still; raising it above ~0.998 starts admitting sensor-noise-level drift. +`frame_variance_max_similarity` is the tuning knob and stays purely passive either +way. When the timeout expires with matching frames but an unsatisfied variance gate, +`facelock test` says so explicitly, and per-window min/max pair similarities are +logged at debug level (values only, never embeddings) for tuning. Config: ```toml [security] -require_frame_variance = true # Reject static images (photo attack defense) -frame_variance_max_similarity = 0.97 # Max consecutive-frame similarity (require >=0.03 drift) -min_auth_frames = 3 # Minimum matched frames before accepting +require_frame_variance = true # Reject static images (photo attack defense) +frame_variance_max_similarity = 0.995 # Max consecutive-frame similarity in the window +min_auth_frames = 3 # Matched frames required = variance window size ``` #### C. Dark Frame / IR Texture Validation (Recommended) @@ -340,7 +354,7 @@ abort_if_ssh = true # Refuse face auth over SSH abort_if_lid_closed = true # Refuse if laptop lid closed require_ir = true # CRITICAL: refuse non-IR cameras (anti-spoof, load-bearing) require_frame_variance = true # Reject static images (photo defense; NOT video replay) -frame_variance_max_similarity = 0.97 # Max consecutive-frame similarity (require >=0.03 drift) +frame_variance_max_similarity = 0.995 # Max pair similarity in the sliding window (static >= ~0.999) ir_texture_min_stddev = 10.0 # Min raw-frame face std_dev for IR texture (flat < 5, real > 15) require_landmark_liveness = false # Require landmark movement between frames (off by default) min_auth_frames = 3 # Minimum frames before accepting (variance check) From b71d9fbe316975b2e8bc77adf3e48247b73a11de Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Sat, 4 Jul 2026 12:16:50 -0700 Subject: [PATCH 6/8] =?UTF-8?q?test(container):=20match=20the=20new=20'Mat?= =?UTF-8?q?ched=20(similarity:=20=E2=80=A6)'=20success=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oneshot success message now includes the similarity score; update the container assertion to the stable prefix. The live auth itself passed (Matched (similarity: 0.81) in 0.80s) — only the grep pattern was stale. Co-Authored-By: Claude Fable 5 --- test/run-oneshot-tests.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/run-oneshot-tests.sh b/test/run-oneshot-tests.sh index 7da06fa..ad16488 100755 --- a/test/run-oneshot-tests.sh +++ b/test/run-oneshot-tests.sh @@ -129,10 +129,11 @@ run_test_contains "facelock list (oneshot)" \ "facelock list --user testuser" \ "test-face" -# Test auth via CLI (direct) +# Test auth via CLI (direct). The success line is +# "Matched (similarity: X.XX) in Y.YYs" — match the stable prefix. run_test_contains "facelock test (oneshot)" \ "timeout --foreground $LIVE_TIMEOUT facelock test --user testuser" \ - "Matched in" + "Matched (similarity:" # facelock auth binary (used by PAM module) run_test "facelock auth authenticates (oneshot)" \ From dd8961b5d1bfdcf80b90b3a8ab571af1f8890117 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Sat, 4 Jul 2026 12:59:54 -0700 Subject: [PATCH 7/8] Tighten default frame_variance_max_similarity to 0.985 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stricter than the previous 0.995 (top of the field-measured frozen-human band) to add margin against static replays with sensor-noise drift. A fully frozen user may not pass at 0.985, but the sliding-window gate recovers as soon as they move slightly, so the worst case is a brief delay — never a lockout — and password fallback always remains. frame_variance_max_similarity stays the user-tunable knob (loosen toward 0.995, tighten toward 0.97). Boundary tests updated to exercise the new default: rejected just above (cos 0.15 ~ 0.9888) and accepted just below (cos 0.19 ~ 0.9820); recovery tests now drift at cos(0.20) ~ 0.9801 to stay under the cutoff. Co-Authored-By: Claude Fable 5 --- config/facelock.toml | 13 ++-- crates/facelock-core/src/config.rs | 8 ++- crates/facelock-core/src/types.rs | 60 ++++++++++--------- .../tests/daemon_integration.rs | 6 +- docs/contracts.md | 10 ++-- docs/security.md | 33 ++++++---- 6 files changed, 75 insertions(+), 55 deletions(-) diff --git a/config/facelock.toml b/config/facelock.toml index cee47b2..606338d 100644 --- a/config/facelock.toml +++ b/config/facelock.toml @@ -177,12 +177,13 @@ # evaluated over a sliding window of the most recent min_auth_frames matches. # Lower is stricter. Passive anti-photo only — it does NOT stop a video replay. # Field-measured: truly static input (photo, paused replay) sits at >= ~0.999; -# a frozen, non-blinking live human sits at 0.98–0.995. The default sits at the -# top of the frozen-human band so a still user at a login prompt always passes -# while perfectly static input never does. Lowering below 0.995 risks locking -# out users who hold naturally still. -# Default: 0.995 -# frame_variance_max_similarity = 0.995 +# a frozen, non-blinking live human sits at 0.98–0.995. The default (0.985) sits +# inside the frozen-human band for extra margin against static replays: a fully +# frozen user may be briefly delayed, but the sliding window recovers as soon as +# they move slightly (and password fallback always remains). Loosen toward 0.995 +# if false rejects annoy you; tighten toward 0.97 for paranoia. +# Default: 0.985 +# frame_variance_max_similarity = 0.985 # Minimum per-face standard deviation (measured on the RAW grayscale frame) for # the IR texture check. Flat photos/screens score < 5 in IR; real skin > 15. diff --git a/crates/facelock-core/src/config.rs b/crates/facelock-core/src/config.rs index fcf4796..1dcec55 100644 --- a/crates/facelock-core/src/config.rs +++ b/crates/facelock-core/src/config.rs @@ -208,9 +208,11 @@ pub struct SecurityConfig { pub ir_texture_min_stddev: f32, /// Maximum consecutive matched-frame cosine similarity allowed by the passive /// frame-variance check, evaluated over a sliding window of the most recent - /// `min_auth_frames` matches. Higher = more permissive. Default 0.995: truly - /// static input sits ≳0.999, a frozen live human at 0.98–0.995. Passive - /// anti-photo only; does not defeat video replay. + /// `min_auth_frames` matches. Higher = more permissive. Default 0.985: truly + /// static input sits ≳0.999, a frozen live human at 0.98–0.995; the default + /// sits inside the frozen-human band for margin against static replays (a + /// fully frozen user recovers via the sliding window as soon as they move). + /// Passive anti-photo only; does not defeat video replay. #[serde(default = "default_frame_variance_max_similarity")] pub frame_variance_max_similarity: f32, #[serde(default)] diff --git a/crates/facelock-core/src/types.rs b/crates/facelock-core/src/types.rs index 5aef232..0cdb685 100644 --- a/crates/facelock-core/src/types.rs +++ b/crates/facelock-core/src/types.rs @@ -109,16 +109,19 @@ pub fn cosine_similarity(a: &FaceEmbedding, b: &FaceEmbedding) -> f32 { /// - a frozen, non-blinking live human: (0.98, 0.995] /// - a naturally moving live human: well below 0.98 /// -/// The check's honest job is rejecting *perfectly static* input as -/// defense-in-depth, so the default sits at the top of the frozen-human band: -/// a live user always passes, a static image never does. (The earlier 0.97 -/// default assumed 0.02–0.10 live drift, which is empirically wrong for a -/// still user and caused hard false-reject lockups.) +/// The default (0.985) sits inside the frozen-human band, stricter than the +/// top of the band (0.995) for extra margin against static replays. A fully +/// frozen user may not pass at 0.985, but the sliding-window gate recovers as +/// soon as they move slightly — the worst case is a brief delay, never a +/// lockout, and password fallback always remains. Loosen the knob toward +/// 0.995 if false rejects annoy; tighten toward 0.97 for paranoia. (The +/// earlier 0.97 default assumed 0.02–0.10 live drift, which is empirically +/// wrong for a still user and caused hard false-reject lockups.) /// /// NOTE: frame-variance is a *passive* anti-photo heuristic only. It raises the /// bar for a *static* image but does NOT defeat a video replay (which contains /// real inter-frame motion). IR enforcement remains the load-bearing defense. -pub const DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY: f32 = 0.995; +pub const DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY: f32 = 0.985; /// Check that matched embeddings show sufficient variance (anti-photo-attack). /// Compares all consecutive pairs — every pair must differ enough to rule out @@ -548,13 +551,13 @@ mod tests { #[test] fn frame_variance_rejects_near_static_sequence() { - // Near-identical consecutive embeddings (sim > 0.995, static-like) must fail. + // Near-identical consecutive embeddings (sim > 0.985, static-like) must fail. let a = tilted_unit(1.0, 0.02); let b = tilted_unit(1.0, 0.03); let c = tilted_unit(1.0, 0.04); let seq = [a, b, c]; - // Sanity: consecutive similarities are all above the 0.995 default. - assert!(cosine_similarity(&seq[0], &seq[1]) > 0.995); + // Sanity: consecutive similarities are all above the 0.985 default. + assert!(cosine_similarity(&seq[0], &seq[1]) > 0.985); assert!( !check_frame_variance(&seq, DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), "near-static sequence must be rejected" @@ -563,13 +566,13 @@ mod tests { #[test] fn frame_variance_accepts_live_like_sequence() { - // Live-like drift (sim well below the 0.995 default) must pass. + // Live-like drift (sim well below the 0.985 default) must pass. let a = tilted_unit(1.0, 0.0); let b = tilted_unit(1.0, 0.30); let c = tilted_unit(1.0, 0.60); let seq = [a, b, c]; - assert!(cosine_similarity(&seq[0], &seq[1]) < 0.995); - assert!(cosine_similarity(&seq[1], &seq[2]) < 0.995); + assert!(cosine_similarity(&seq[0], &seq[1]) < 0.985); + assert!(cosine_similarity(&seq[1], &seq[2]) < 0.985); assert!( check_frame_variance(&seq, DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY), "live-like sequence must pass" @@ -613,8 +616,8 @@ mod tests { "still frames must never satisfy the variance gate" ); } - // Now the user moves: consecutive drift cos(0.15) ~= 0.9888 <= 0.995. - for (i, theta) in [0.15f32, 0.30, 0.45].iter().enumerate() { + // Now the user moves: consecutive drift cos(0.20) ~= 0.9801 <= 0.985. + for (i, theta) in [0.20f32, 0.40, 0.60].iter().enumerate() { w.push(unit_at_angle(*theta)); if i >= 2 { assert!( @@ -645,7 +648,7 @@ mod tests { #[test] fn variance_window_near_static_replay_never_passes() { // A paused replay / photo with sensor noise sits at pair similarity - // >= ~0.999 — still above the 0.995 default, so it must never pass. + // >= ~0.999 — still above the 0.985 default, so it must never pass. let mut w = FrameVarianceWindow::new(3); for i in 0..50 { // steps of 0.02 rad: consecutive similarity cos(0.02) ~= 0.9998 @@ -659,29 +662,32 @@ mod tests { #[test] fn variance_window_boundary_at_default() { - assert_eq!(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY, 0.995); + assert_eq!(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY, 0.985); - // Just above the default (pair sim ~0.9965 > 0.995): rejected. + // Just above the default (pair sim ~0.9888 > 0.985): rejected. This is + // inside the field-measured frozen-human band (0.98, 0.995] — a fully + // frozen user is deliberately held until they move slightly, at which + // point the sliding window recovers (see recovery test above). let mut too_still = FrameVarianceWindow::new(2); too_still.push(unit_at_angle(0.0)); - too_still.push(unit_at_angle(0.0837)); // cos ~= 0.9965 + too_still.push(unit_at_angle(0.15)); // cos ~= 0.9888 let (mn, _) = too_still.min_max_pair_similarity().unwrap(); assert!( - mn > 0.995, + mn > 0.985, "sanity: pair must sit above the cutoff, got {mn}" ); assert!(!too_still.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY)); - // Frozen-but-live human range (field-measured (0.98, 0.995]): accepted. - let mut frozen_human = FrameVarianceWindow::new(2); - frozen_human.push(unit_at_angle(0.0)); - frozen_human.push(unit_at_angle(0.1415)); // cos ~= 0.9900 - let (mn, mx) = frozen_human.min_max_pair_similarity().unwrap(); + // Barely-moving live human just under the cutoff ((0.98, 0.985]): accepted. + let mut barely_moving = FrameVarianceWindow::new(2); + barely_moving.push(unit_at_angle(0.0)); + barely_moving.push(unit_at_angle(0.19)); // cos ~= 0.9820 + let (mn, mx) = barely_moving.min_max_pair_similarity().unwrap(); assert!( - mn > 0.98 && mx <= 0.995, - "sanity: pair in frozen-human band, got {mn}..{mx}" + mn > 0.98 && mx <= 0.985, + "sanity: pair just below the cutoff, got {mn}..{mx}" ); - assert!(frozen_human.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY)); + assert!(barely_moving.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY)); } #[test] diff --git a/crates/facelock-daemon/tests/daemon_integration.rs b/crates/facelock-daemon/tests/daemon_integration.rs index 0a39371..f46a4ee 100644 --- a/crates/facelock-daemon/tests/daemon_integration.rs +++ b/crates/facelock-daemon/tests/daemon_integration.rs @@ -305,9 +305,9 @@ fn still_then_moving_frames_recover_and_authenticate() { still, still, still, - unit_at_angle(0.15), // pair drift cos(0.15) ~= 0.9888 <= 0.995 - unit_at_angle(0.30), - unit_at_angle(0.45), + unit_at_angle(0.20), // pair drift cos(0.20) ~= 0.9801 <= 0.985 + unit_at_angle(0.40), + unit_at_angle(0.60), ]; let mut camera = MockCamera::bright(64, 64, 16); let mut engine = MockFaceEngine::cycling(frames); diff --git a/docs/contracts.md b/docs/contracts.md index 82579a7..3e322c2 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -174,11 +174,11 @@ pam_facelock(): for user |---------|--------|---------| | IR camera enforcement | `security.require_ir` | **true** | | Frame variance check | `security.require_frame_variance` | **true** | -| Frame variance cutoff | `security.frame_variance_max_similarity` | 0.995 | +| Frame variance cutoff | `security.frame_variance_max_similarity` | 0.985 | | IR texture cutoff (raw frame) | `security.ir_texture_min_stddev` | 10.0 | | Landmark liveness | `security.require_landmark_liveness` | **false** | | Minimum auth frames (= variance window size) | `security.min_auth_frames` | 3 | -| Frame variance default const | `DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY` | 0.995 | +| Frame variance default const | `DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY` | 0.985 | IR classification requires a whole `ir`/`infrared` name token or a quirks `force_ir` entry; a GREY/Y16 format alone is not treated as IR. A `force_ir` quirk is @@ -187,8 +187,10 @@ capture nodes and at least one has an IR-like format, only the format-bearing node(s) classify IR (see `docs/security.md` §A). Frame variance is passive anti-photo only (does not stop video replay); it is evaluated over a sliding window of the most recent `min_auth_frames` matched frames (see `docs/security.md` §B), with -a 0.995 cutoff separating truly static input (≳0.999) from a frozen live human -(0.98–0.995). IR texture is measured on the raw frame, never CLAHE. These defaults +a 0.985 cutoff rejecting truly static input (≳0.999) with margin; the +field-measured frozen-human band is 0.98–0.995, and the default sits inside it — +a fully frozen user recovers via the sliding window as soon as they move +slightly. IR texture is measured on the raw frame, never CLAHE. These defaults must not be weakened without security review. ## Models diff --git a/docs/security.md b/docs/security.md index c6ac3fc..d84ba8b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -90,7 +90,7 @@ The check is evaluated over a **sliding window** of the most recent `min_auth_frames` matched-frame embeddings (`FrameVarianceWindow` in `facelock-core/src/types.rs`): the gate passes only when the window is full AND every consecutive pair inside it has cosine similarity at or below the cutoff -(`security.frame_variance_max_similarity`, default **0.995**, +(`security.frame_variance_max_similarity`, default **0.985**, `DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY`). **Why a sliding window**: an earlier version accumulated every matched frame for @@ -110,11 +110,18 @@ session length. Embeddings evicted from the window are zeroized at eviction. | Frozen, non-blinking live human | 0.98 – 0.995 | | Naturally moving live human | well below 0.98 | -The default cutoff (0.995) sits at the top of the frozen-human band: a live user -holding naturally still at a login prompt passes, perfectly static input never -does. (An earlier default of 0.97 assumed a 0.02–0.10 live-drift range that is -empirically wrong for a still user — it caused hard false-reject lockups where -auth reported "no match" despite 0.91–0.98 recognition similarity.) +The default cutoff (0.985) sits *inside* the frozen-human band — deliberately +stricter than the top of the band (0.995) for extra margin against static +replays that carry sensor-noise-level drift. The honest tradeoff: a fully +frozen, non-blinking user may not pass at 0.985, but because the gate is a +sliding window it recovers the moment they move slightly — the worst case is a +brief delay, never a lockout, and the PAM stack falls through to password +regardless. (An earlier default of 0.97 assumed a 0.02–0.10 live-drift range +that is empirically wrong for a still user — it caused hard false-reject +lockups where auth reported "no match" despite 0.91–0.98 recognition +similarity. The window-less design of that era turned stillness into a +permanent failure; the sliding window is what makes the stricter 0.985 default +safe to ship.) **Honest scope — this does NOT stop video replay.** Frame-variance only rules out a *static* image (printed photo, single frozen frame). A recorded video of the enrolled @@ -124,10 +131,12 @@ input; **IR enforcement plus the raw-frame texture check (§A, §C) are the load anti-spoof defenses**, and active liveness (opt-in landmark/blink) is the answer to video replay. -**False-reject tradeoff**: lowering the cutoff below 0.995 rejects users who hold -naturally still; raising it above ~0.998 starts admitting sensor-noise-level drift. -`frame_variance_max_similarity` is the tuning knob and stays purely passive either -way. When the timeout expires with matching frames but an unsatisfied variance gate, +**False-reject tradeoff**: `frame_variance_max_similarity` is the user-tunable +knob and stays purely passive either way. Loosen toward 0.995 (top of the +frozen-human band) if a very still user finds the delay annoying; tighten toward +0.97 for paranoia, accepting that holding still delays auth until you move. +Raising it above ~0.998 starts admitting sensor-noise-level drift and defeats +the check. When the timeout expires with matching frames but an unsatisfied variance gate, `facelock test` says so explicitly, and per-window min/max pair similarities are logged at debug level (values only, never embeddings) for tuning. @@ -135,7 +144,7 @@ Config: ```toml [security] require_frame_variance = true # Reject static images (photo attack defense) -frame_variance_max_similarity = 0.995 # Max consecutive-frame similarity in the window +frame_variance_max_similarity = 0.985 # Max consecutive-frame similarity in the window min_auth_frames = 3 # Matched frames required = variance window size ``` @@ -354,7 +363,7 @@ abort_if_ssh = true # Refuse face auth over SSH abort_if_lid_closed = true # Refuse if laptop lid closed require_ir = true # CRITICAL: refuse non-IR cameras (anti-spoof, load-bearing) require_frame_variance = true # Reject static images (photo defense; NOT video replay) -frame_variance_max_similarity = 0.995 # Max pair similarity in the sliding window (static >= ~0.999) +frame_variance_max_similarity = 0.985 # Max pair similarity in the sliding window (static >= ~0.999) ir_texture_min_stddev = 10.0 # Min raw-frame face std_dev for IR texture (flat < 5, real > 15) require_landmark_liveness = false # Require landmark movement between frames (off by default) min_auth_frames = 3 # Minimum frames before accepting (variance check) From e700cbcb327b9c5f29924f6d8c7861054cf5ddb1 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Sat, 4 Jul 2026 14:05:44 -0700 Subject: [PATCH 8/8] test(oneshot): restore quirks DB via EXIT trap so failures don't corrupt CI state Under `set -euo pipefail`, a failing require_ir anti-spoof test aborted the script before the trailing inline restore ran, leaving /usr/share/facelock/quirks.d moved aside and corrupting shared state for later tests in the same CI job (e.g. pamtester, which needs the quirks DB present). Wrap the move/restore in a `trap restore_quirks EXIT` so restoration always runs on any exit path. The inline restore is kept (and the trap dropped) so happy-path behavior is unchanged: subsequent tests still see the restored DB. Co-Authored-By: Claude Fable 5 --- test/run-oneshot-tests.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/run-oneshot-tests.sh b/test/run-oneshot-tests.sh index ad16488..1fc45d5 100755 --- a/test/run-oneshot-tests.sh +++ b/test/run-oneshot-tests.sh @@ -150,7 +150,19 @@ run_test "facelock auth authenticates (oneshot)" \ # CAMERA-REQUIRED: only meaningful on a host with /dev/video*; skipped headless. QUIRKS_SYS="/usr/share/facelock/quirks.d" QUIRKS_BAK="/tmp/facelock-quirks.bak" +# Restore the system quirks DB. Idempotent: a no-op once the DB is back in place, +# so it is safe to call both inline (happy path) and from the EXIT trap. +restore_quirks() { + if [ -d "$QUIRKS_BAK" ]; then + rm -rf "$QUIRKS_SYS" + mv "$QUIRKS_BAK" "$QUIRKS_SYS" + fi +} rm -rf "$QUIRKS_BAK" +# Guarantee restoration on ANY exit: under `set -euo pipefail` a failing test below +# aborts the script before the inline restore runs, which would leave the quirks DB +# moved aside and corrupt shared state for later tests in the same CI job. +trap restore_quirks EXIT [ -d "$QUIRKS_SYS" ] && mv "$QUIRKS_SYS" "$QUIRKS_BAK" cp /etc/facelock/config.toml /tmp/facelock-requireir.toml if grep -q '^require_ir' /tmp/facelock-requireir.toml; then @@ -161,8 +173,10 @@ fi run_test "facelock auth refuses non-IR camera when require_ir=true (anti-spoof, H1)" \ "facelock auth --user testuser --config /tmp/facelock-requireir.toml" \ 2 -# Restore the system quirks DB. -[ -d "$QUIRKS_BAK" ] && rm -rf "$QUIRKS_SYS" && mv "$QUIRKS_BAK" "$QUIRKS_SYS" +# Restore the system quirks DB inline so the remaining tests see it, then drop the +# trap now that shared state is consistent again. +restore_quirks +trap - EXIT # PAM authentication (the real deal — no daemon) run_test "pamtester authenticates (oneshot, no daemon)" \