diff --git a/config/facelock.toml b/config/facelock.toml index 6558db8..606338d 100644 --- a/config/facelock.toml +++ b/config/facelock.toml @@ -173,6 +173,24 @@ # Default: true # require_frame_variance = true +# 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 (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. +# 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. @@ -191,7 +209,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/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 ffb980e..a1458d3 100644 --- a/crates/facelock-camera/src/device.rs +++ b/crates/facelock-camera/src/device.rs @@ -49,49 +49,277 @@ 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) +} + +/// 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): +/// 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). +/// +/// 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 force_ir; + return if force_ir { + IrSource::Quirk + } else { + IrSource::None + }; } } } + heuristic_ir_source(device) +} - // 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_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 ")); - has_ir_name || has_ir_format + .map(|d| crate::quirks::read_usb_ids(&d.path)) + .collect(); + classify_ir_sources_with_ids(devices, quirks, &usb_ids) +} + +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 IR cameras, falls back to the first available device. +/// +/// 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()?; - devices - .iter() - .find(|d| is_ir_camera(d)) - .or(devices.first()) + let quirks = crate::quirks::QuirksDb::load(); + 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}")))?; @@ -153,68 +381,318 @@ 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))); + } + + 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] diff --git a/crates/facelock-camera/src/lib.rs b/crates/facelock-camera/src/lib.rs index d9a3730..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, auto_detect_device, 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/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..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) { @@ -150,11 +161,17 @@ 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. /// 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..5f969f4 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 { @@ -193,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/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 1ed3f83..475afda 100644 --- a/crates/facelock-cli/src/commands/setup.rs +++ b/crates/facelock-cli/src/commands/setup.rs @@ -291,9 +291,25 @@ fn wizard_camera_selection(theme: &ColorfulTheme, config: &mut Config) -> anyhow return Ok(()); } + // 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 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| facelock_camera::is_ir_camera(d)) + .enumerate() + .filter(|(i, _)| is_ir_at(*i)) + .map(|(_, d)| d) .collect(); // If exactly one IR camera, auto-select it @@ -307,12 +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 facelock_camera::is_ir_camera(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(); @@ -323,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(facelock_camera::is_ir_camera) + (0..devices.len()).find(|&i| is_ir_at(i)) }) .unwrap_or(0); 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 fce366b..df2c4b0 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_resolved, list_devices, validate_device, }; use facelock_core::config::DeviceConfig; use facelock_core::config::{Config, EncryptionMethod}; @@ -46,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)), } } @@ -97,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 { @@ -126,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"), } @@ -261,9 +269,18 @@ 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), 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(dev) { " [IR]" } else { "" }; + for (dev, source) in devices.iter().zip(&sources) { + let ir_tag = if *source != facelock_camera::IrSource::None { + " [IR]" + } else { + "" + }; println!(" {}{ir_tag}", dev.path); println!(" Name: {}", dev.name); println!(" Driver: {}", dev.driver); @@ -311,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; @@ -340,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; @@ -387,11 +409,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 +427,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" @@ -417,16 +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) - { + 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() @@ -452,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 { @@ -484,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 { @@ -512,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 { @@ -520,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 659b4fd..1dcec55 100644 --- a/crates/facelock-core/src/config.rs +++ b/crates/facelock-core/src/config.rs @@ -200,6 +200,21 @@ 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, evaluated over a sliding window of the most recent + /// `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)] pub rate_limit: RateLimitConfig, } @@ -234,6 +249,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 +468,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 +598,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 +1029,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..0cdb685 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) @@ -87,28 +101,161 @@ 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. 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 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.985; /// 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; } } 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 @@ -392,6 +539,196 @@ 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 (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.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" + ); + } + + #[test] + fn frame_variance_accepts_live_like_sequence() { + // 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.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" + ); + } + + #[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)); + } + + /// 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.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!( + 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.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 + 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.985); + + // 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.15)); // cos ~= 0.9888 + let (mn, _) = too_still.min_max_pair_similarity().unwrap(); + assert!( + mn > 0.985, + "sanity: pair must sit above the cutoff, got {mn}" + ); + assert!(!too_still.passes(DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY)); + + // 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.985, + "sanity: pair just below the cutoff, got {mn}..{mx}" + ); + assert!(barely_moving.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 d5898e4..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; @@ -269,26 +275,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 +301,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, @@ -322,23 +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) - { + 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!( @@ -354,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" ); @@ -382,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 { @@ -433,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!( @@ -481,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" ); @@ -512,6 +526,7 @@ fn authenticate_inner( model_id: None, label: None, similarity: best_similarity, + failure_reason, }) } diff --git a/crates/facelock-daemon/src/handler.rs b/crates/facelock-daemon/src/handler.rs index 2d6783f..8b9e36b 100644 --- a/crates/facelock-daemon/src/handler.rs +++ b/crates/facelock-daemon/src/handler.rs @@ -401,28 +401,36 @@ impl Handler { }, DaemonRequest::ListDevices => { - use facelock_camera::{is_ir_camera, 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, 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(d), - 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/crates/facelock-daemon/tests/daemon_integration.rs b/crates/facelock-daemon/tests/daemon_integration.rs index cdab2b0..f46a4ee 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.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); + 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 4839e43..3e322c2 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` | @@ -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 @@ -168,11 +174,24 @@ 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.985 | +| 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 | - -These defaults must not be weakened without security review. +| Minimum auth frames (= variance window size) | `security.min_auth_frames` | 3 | +| 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 +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); it is evaluated over a sliding window +of the most recent `min_auth_frames` matched frames (see `docs/security.md` §B), with +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 1fcd844..d84ba8b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -38,21 +38,37 @@ 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 +// +// 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 { message: "IR camera required for authentication. Set security.require_ir = false to override (NOT RECOMMENDED).".into() }; @@ -61,37 +77,75 @@ 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. -#### B. Frame Variance Check (Required) +**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. -Require minimum variance across consecutive frames during authentication: +**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). -```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; - for window in embeddings.windows(2) { - let sim = cosine_similarity(&window[0].1, &window[1].1); - max_similarity = max_similarity.max(sim); - } - // 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 -} -``` +#### B. Frame Variance Check (Required) + +Require minimum variance across consecutive matched frames during authentication. +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.985**, +`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.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 +user contains genuine inter-frame motion and will pass this check. Frame-variance is a +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**: `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. 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.985 # Max consecutive-frame similarity in the window +min_auth_frames = 3 # Matched frames required = variance window size ``` #### C. Dark Frame / IR Texture Validation (Recommended) @@ -103,18 +157,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 +361,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.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) 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..1fc45d5 100755 --- a/test/run-oneshot-tests.sh +++ b/test/run-oneshot-tests.sh @@ -77,6 +77,48 @@ 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. 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" \ + "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; + # 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 '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" \ @@ -87,15 +129,55 @@ 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)" \ "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" +# 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 + 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 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)" \ "timeout --foreground $LIVE_TIMEOUT pamtester facelock-test testuser authenticate"