diff --git a/.github/workflows/scripts/build-deb.sh b/.github/workflows/scripts/build-deb.sh index 2b66674..01df681 100755 --- a/.github/workflows/scripts/build-deb.sh +++ b/.github/workflows/scripts/build-deb.sh @@ -107,6 +107,10 @@ install -m644 systemd/facelock-daemon.service "${PKG_DIR}/usr/lib/systemd/system install -m644 dbus/org.facelock.Daemon.conf "${PKG_DIR}/usr/share/dbus-1/system.d/org.facelock.Daemon.conf" install -m644 dbus/org.facelock.Daemon.service "${PKG_DIR}/usr/share/dbus-1/system-services/org.facelock.Daemon.service" +# Polkit action (interactive authorization for preview frame bytes) +mkdir -p "${PKG_DIR}/usr/share/polkit-1/actions" +install -m644 dbus/org.facelock.policy "${PKG_DIR}/usr/share/polkit-1/actions/org.facelock.policy" + # sysusers.d and tmpfiles.d install -m644 dist/facelock.sysusers "${PKG_DIR}/usr/lib/sysusers.d/facelock.conf" install -m644 dist/facelock.tmpfiles "${PKG_DIR}/usr/lib/tmpfiles.d/facelock.conf" diff --git a/.github/workflows/scripts/validate-deb.sh b/.github/workflows/scripts/validate-deb.sh index c789976..4069748 100755 --- a/.github/workflows/scripts/validate-deb.sh +++ b/.github/workflows/scripts/validate-deb.sh @@ -15,6 +15,7 @@ CHECKS=( "etc/facelock/config.toml:config" "dbus-1/system.d/org.facelock.Daemon.conf:D-Bus policy" "dbus-1/system-services/org.facelock.Daemon.service:D-Bus activation" + "polkit-1/actions/org.facelock.policy:polkit action policy" "sysusers.d/facelock.conf:sysusers" "tmpfiles.d/facelock.conf:tmpfiles" "usr/lib/facelock/libonnxruntime.so:bundled ORT" diff --git a/.github/workflows/scripts/validate-rpm.sh b/.github/workflows/scripts/validate-rpm.sh index 1b54688..4ce941d 100755 --- a/.github/workflows/scripts/validate-rpm.sh +++ b/.github/workflows/scripts/validate-rpm.sh @@ -15,6 +15,7 @@ CHECKS=( "etc/facelock/config.toml:config" "dbus-1/system.d/org.facelock.Daemon.conf:D-Bus policy" "dbus-1/system-services/org.facelock.Daemon.service:D-Bus activation" + "polkit-1/actions/org.facelock.policy:polkit action policy" "sysusers.d/facelock.conf:sysusers" "tmpfiles.d/facelock.conf:tmpfiles" "authselect/vendor/facelock:authselect" diff --git a/book/src/contracts.md b/book/src/contracts.md index e701305..c5beab3 100644 --- a/book/src/contracts.md +++ b/book/src/contracts.md @@ -127,6 +127,13 @@ D-Bus system bus (`org.facelock.Daemon`). Only used in daemon mode. The daemon e ### Methods `Authenticate`, `Enroll`, `ListModels`, `RemoveModel`, `ClearModels`, `PreviewFrame`, `PreviewDetectFrame`, `ListDevices`, `ReleaseCamera`, `Ping`, `Shutdown` +Raw camera frames require privilege. `PreviewFrame` remains root-only. `PreviewDetectFrame` returns the `jpeg_data` frame bytes to root unconditionally; a non-root caller receives them only after an interactive polkit authorization for the action `org.facelock.preview-frames` (checked via `org.freedesktop.PolicyKit1.Authority.CheckAuthorization`, `AllowUserInteraction=true`). While unauthorized — denied, prompt pending, polkit unreachable, or any D-Bus error — the daemon fails closed: `jpeg_data` is empty and the caller receives detection and recognition metadata only. + +Capture concurrency: `Authenticate`, `Enroll`, `PreviewFrame`, and `PreviewDetectFrame` fail immediately with a `daemon busy` error while another capture is in flight. Clients treat this like any other daemon error and degrade to password auth. + +### Signals +`AuthAttempted(user: s, matched: b)` — emitted after each authentication attempt. Carries no similarity score. Bus policy restricts reception to root and the `facelock` group. + ### Return Types `AuthResult`, `Enrolled`, `Models`, `Removed`, `Frame`, `DetectFrame`, `Devices`, `Ok`, `Error` diff --git a/crates/facelock-cli/src/commands/daemon.rs b/crates/facelock-cli/src/commands/daemon.rs index 9ce3de1..77881c7 100644 --- a/crates/facelock-cli/src/commands/daemon.rs +++ b/crates/facelock-cli/src/commands/daemon.rs @@ -1,5 +1,6 @@ +use std::collections::HashMap; use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, TryLockError}; use std::time::{Duration, Instant}; @@ -65,6 +66,333 @@ fn lock_handler_with_timeout( } } +/// Tracks whether a camera-capture operation is currently in flight. +/// +/// Camera captures serialize on the handler mutex; without this guard a +/// second caller would queue on that mutex for up to `HANDLER_LOCK_TIMEOUT` +/// (10s), letting any authorized caller stall others (local DoS). The slot +/// lets capture methods reject concurrent requests immediately with a +/// "daemon busy" error instead. Callers (PAM, CLI) treat that like any other +/// daemon error and degrade to password auth — never a lockout. Per-user +/// rate limiting is unaffected; this is orthogonal contention control. +#[derive(Debug, Default)] +struct CaptureSlot { + busy: AtomicBool, +} + +impl CaptureSlot { + /// Try to claim the capture slot. Returns a RAII guard on success, or an + /// immediate "daemon busy" error if another capture is already in flight. + fn try_acquire(self: &Arc, operation: &str) -> fdo::Result { + if self + .busy + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_ok() + { + Ok(CaptureGuard(Arc::clone(self))) + } else { + warn!( + operation = operation, + "capture already in flight — rejecting immediately with busy" + ); + Err(fdo::Error::Failed(format!( + "daemon busy: another capture operation is in progress ({operation} rejected)" + ))) + } + } +} + +/// RAII guard for [`CaptureSlot`]; releases the slot when dropped +/// (including on panic unwind inside a blocking task). +#[derive(Debug)] +struct CaptureGuard(Arc); + +impl Drop for CaptureGuard { + fn drop(&mut self) { + self.0.busy.store(false, Ordering::Release); + } +} + +/// Raw camera frames require privilege: root gets them unconditionally (the +/// `PreviewFrame` contract), and a non-root caller only after an interactive +/// polkit authorization for `org.facelock.preview-frames`. When frames are +/// not allowed the bytes are stripped — the caller gets detection and +/// recognition metadata only, never raw camera/IR imagery. +fn sanitize_preview_jpeg(jpeg_data: Vec, allow_frames: bool) -> Vec { + if allow_frames { jpeg_data } else { Vec::new() } +} + +/// Polkit action a non-root caller must be authorized for to receive raw +/// preview frame bytes. Shipped in `dbus/org.facelock.policy` +/// (installed to /usr/share/polkit-1/actions/). +const PREVIEW_FRAMES_ACTION_ID: &str = "org.facelock.preview-frames"; + +/// `AllowUserInteraction` flag for `CheckAuthorization` — lets the caller's +/// polkit agent prompt interactively on the first frame request. +const POLKIT_ALLOW_USER_INTERACTION: u32 = 0x1; + +/// How long a *granted* frame authorization is cached per caller connection. +/// The polkit grant itself (`auth_self_keep`) is what bounds the real +/// authorization lifetime (~5 min); this cache only saves the per-frame +/// round-trip and dies with the caller's bus connection or this TTL, +/// whichever comes first. +const AUTHZ_GRANTED_TTL: Duration = Duration::from_secs(120); + +/// How long a *denied* (or errored) polkit verdict is cached. Short, so a +/// dismissed prompt can be retried quickly — but long enough that a caller +/// polling frames doesn't re-trigger an agent prompt on every frame. +const AUTHZ_DENIED_TTL: Duration = Duration::from_secs(15); + +/// Upper bound on cached caller connections (DoS control). +const AUTHZ_CACHE_MAX_ENTRIES: usize = 64; + +/// Give up on a polkit interaction (user typing a password) after this long. +const POLKIT_INTERACTION_TIMEOUT: Duration = Duration::from_secs(120); + +/// Cached authorization state for one caller connection (unique bus name). +#[derive(Clone, Copy, Debug, PartialEq)] +enum AuthzEntry { + /// A `CheckAuthorization` round-trip is in flight for this caller. + InFlight, + /// Polkit answered; valid until `expires_at`. + Decided { + authorized: bool, + expires_at: Instant, + }, +} + +/// Result of a frame-authorization cache lookup. +#[derive(Clone, Copy, Debug, PartialEq)] +enum FrameAuthz { + /// Polkit authorized this caller connection (cached, unexpired). + Granted, + /// Polkit denied this caller (or the check errored) — fail closed. + Refused, + /// A polkit check is already in flight — metadata only for now. + Pending, + /// No usable cache entry; the caller was marked in-flight and a polkit + /// check must be started. + NeedsCheck, +} + +/// The single authorization decision point for raw preview frame bytes: +/// root always gets frames; a non-root caller only with a fresh polkit +/// grant. Denied, pending, errored, or unknown states all FAIL CLOSED. +fn allow_preview_jpeg(caller_is_root: bool, authz: FrameAuthz) -> bool { + caller_is_root || authz == FrameAuthz::Granted +} + +/// Per-connection cache of polkit frame-authorization verdicts, keyed by the +/// caller's unique bus name (`:1.42`). Entries die with the caller's bus +/// connection (see [`watch_bus_disconnects`]) or their TTL, whichever first. +#[derive(Debug, Default)] +struct PreviewAuthzCache { + entries: Mutex>, +} + +impl PreviewAuthzCache { + /// Look up the cached verdict for `caller`. If there is none (or it + /// expired), atomically mark the caller in-flight and return + /// [`FrameAuthz::NeedsCheck`] so that exactly one polkit round-trip is + /// started per caller connection. + fn begin_or_lookup(&self, caller: &str, now: Instant) -> FrameAuthz { + let Ok(mut entries) = self.entries.lock() else { + // Poisoned cache: fail closed without starting new checks. + return FrameAuthz::Refused; + }; + + match entries.get(caller) { + Some(AuthzEntry::InFlight) => return FrameAuthz::Pending, + Some(AuthzEntry::Decided { + authorized, + expires_at, + }) if *expires_at > now => { + return if *authorized { + FrameAuthz::Granted + } else { + FrameAuthz::Refused + }; + } + _ => {} + } + + // Missing or expired — bound the cache before inserting. + entries.retain(|_, entry| match entry { + AuthzEntry::InFlight => true, + AuthzEntry::Decided { expires_at, .. } => *expires_at > now, + }); + if entries.len() >= AUTHZ_CACHE_MAX_ENTRIES { + // Evict the decided entry closest to expiry; never evict + // in-flight entries (their completion handler expects them). + let victim = entries + .iter() + .filter_map(|(name, entry)| match entry { + AuthzEntry::Decided { expires_at, .. } => Some((name.clone(), *expires_at)), + AuthzEntry::InFlight => None, + }) + .min_by_key(|(_, expires_at)| *expires_at); + match victim { + Some((name, _)) => { + entries.remove(&name); + } + None => { + // Cache full of in-flight checks — refuse to start + // another one (fail closed, no polkit storm). + warn!(caller, "frame authz cache full of in-flight checks"); + return FrameAuthz::Pending; + } + } + } + + entries.insert(caller.to_string(), AuthzEntry::InFlight); + FrameAuthz::NeedsCheck + } + + /// Record the outcome of a polkit round-trip. `Ok(true)` caches a grant, + /// `Ok(false)` a denial, and `Err` (polkit unreachable, D-Bus error, + /// timeout) is treated as a denial — FAIL CLOSED. The result is dropped + /// if the caller's entry vanished (connection already closed). + fn complete(&self, caller: &str, verdict: Result, now: Instant) { + let authorized = match verdict { + Ok(authorized) => authorized, + Err(e) => { + warn!(caller, error = %e, "polkit frame authorization check failed — failing closed"); + false + } + }; + let ttl = if authorized { + AUTHZ_GRANTED_TTL + } else { + AUTHZ_DENIED_TTL + }; + + let Ok(mut entries) = self.entries.lock() else { + return; + }; + // Only settle an in-flight check; if the entry is gone the caller + // disconnected and the verdict must not outlive the connection. + if let Some(entry @ AuthzEntry::InFlight) = entries.get_mut(caller) { + *entry = AuthzEntry::Decided { + authorized, + expires_at: now + ttl, + }; + } + } + + /// Drop all cached state for a caller (its bus connection went away). + fn evict(&self, caller: &str) { + if let Ok(mut entries) = self.entries.lock() { + entries.remove(caller); + } + } +} + +/// Reply shape of `org.freedesktop.PolicyKit1.Authority.CheckAuthorization`. +#[derive(Debug, serde::Deserialize, zbus::zvariant::Type)] +struct PolkitAuthorizationResult { + is_authorized: bool, + #[allow(dead_code)] + is_challenge: bool, + #[allow(dead_code)] + details: HashMap, +} + +async fn polkit_authority_proxy( + connection: &zbus::Connection, +) -> std::result::Result, String> { + zbus::Proxy::new( + connection, + "org.freedesktop.PolicyKit1", + "/org/freedesktop/PolicyKit1/Authority", + "org.freedesktop.PolicyKit1.Authority", + ) + .await + .map_err(|e| format!("polkit authority proxy: {e}")) +} + +/// Ask polkit whether the caller connection is authorized for +/// [`PREVIEW_FRAMES_ACTION_ID`], allowing interactive agent prompts. +/// The caller's unique bus name doubles as the cancellation id. +async fn check_polkit_frame_authorization( + connection: &zbus::Connection, + caller: &str, +) -> std::result::Result { + let proxy = polkit_authority_proxy(connection).await?; + + let mut subject_details: HashMap<&str, zbus::zvariant::Value<'_>> = HashMap::new(); + subject_details.insert("name", zbus::zvariant::Value::from(caller)); + let details: HashMap<&str, &str> = HashMap::new(); + + let result: PolkitAuthorizationResult = proxy + .call( + "CheckAuthorization", + &( + ("system-bus-name", subject_details), + PREVIEW_FRAMES_ACTION_ID, + details, + POLKIT_ALLOW_USER_INTERACTION, + caller, + ), + ) + .await + .map_err(|e| format!("CheckAuthorization failed: {e}"))?; + + Ok(result.is_authorized) +} + +/// Background task: run one polkit check for `caller` and settle the cache. +/// Never blocks a D-Bus method reply and never holds the capture slot, so a +/// pending agent prompt cannot starve `Authenticate`. +async fn run_frame_authz_check( + connection: zbus::Connection, + cache: Arc, + caller: String, +) { + let verdict = match tokio::time::timeout( + POLKIT_INTERACTION_TIMEOUT, + check_polkit_frame_authorization(&connection, &caller), + ) + .await + { + Ok(result) => result, + Err(_) => { + // Best effort: tell polkit to tear down the pending prompt. + if let Ok(proxy) = polkit_authority_proxy(&connection).await { + let _: std::result::Result<(), _> = proxy + .call("CancelCheckAuthorization", &(caller.as_str(),)) + .await; + } + Err("polkit authorization timed out".to_string()) + } + }; + + if let Ok(authorized) = &verdict { + info!(caller = %caller, authorized, "polkit frame authorization verdict"); + } + cache.complete(&caller, verdict, Instant::now()); +} + +/// Evict cached frame authorizations when their bus connection disappears +/// (`NameOwnerChanged` with no new owner for a unique name), so a grant can +/// never outlive the caller's connection. +async fn watch_bus_disconnects( + connection: zbus::Connection, + cache: Arc, +) -> zbus::Result<()> { + let proxy = fdo::DBusProxy::new(&connection).await?; + let mut stream = proxy.receive_name_owner_changed().await?; + while let Some(signal) = stream.next().await { + let Ok(args) = signal.args() else { continue }; + if args.new_owner().is_none() { + let name = args.name().to_string(); + if name.starts_with(':') { + cache.evict(&name); + } + } + } + Ok(()) +} + #[derive(Clone, Debug, Eq, PartialEq)] struct CallerIdentity { uid: u32, @@ -258,6 +586,10 @@ struct FacelockService { config_mtime: Arc>>, /// UID of the caller that currently owns preview camera cleanup rights. camera_owner_uid: Arc>>, + /// In-flight guard for camera-capture operations (DoS control). + capture_slot: Arc, + /// Per-connection polkit frame-authorization cache (PreviewDetectFrame). + preview_authz: Arc, } impl FacelockService { @@ -337,6 +669,7 @@ impl FacelockService { self.maybe_reload_handler(); verify_caller_authorized(&hdr, connection, user, "Authenticate").await?; self.clear_camera_owner(); + let capture_guard = self.capture_slot.try_acquire("Authenticate")?; let handler = self.handler.clone(); let user = user.to_string(); let signal_user = user.clone(); @@ -345,6 +678,9 @@ impl FacelockService { let request = DaemonRequest::Authenticate { user: user.clone() }; let response = handler.handle(request); drop(handler); + // Capture finished — free the slot before slower follow-up work + // (notifications) so the next auth isn't rejected needlessly. + drop(capture_guard); match response { DaemonResponse::AuthResult(result) => { // Send desktop notification (fire-and-forget, runs as root → setpriv) @@ -390,15 +726,12 @@ impl FacelockService { .await .map_err(|e| fdo::Error::Failed(format!("task join error: {e}")))?; - // Emit auth_attempted signal (best-effort, don't fail auth if signal fails) + // Emit auth_attempted signal (best-effort, don't fail auth if signal + // fails). The payload deliberately carries no similarity score — the + // raw biometric score is a spoof-tuning oracle for anyone able to + // receive the broadcast; `matched` + user is enough for consumers. if let Ok(ref auth_result) = result { - let _ = Self::auth_attempted( - &ctxt, - &signal_user, - auth_result.matched, - auth_result.similarity, - ) - .await; + let _ = Self::auth_attempted(&ctxt, &signal_user, auth_result.matched).await; } result @@ -415,10 +748,12 @@ impl FacelockService { self.maybe_reload_handler(); verify_caller_is_root(&hdr, connection, "Enroll").await?; self.clear_camera_owner(); + let capture_guard = self.capture_slot.try_acquire("Enroll")?; let handler = self.handler.clone(); let user = user.to_string(); let label = label.to_string(); tokio::task::spawn_blocking(move || { + let _capture_guard = capture_guard; let mut handler = lock_handler_with_timeout(&handler)?; let request = DaemonRequest::Enroll { user, label }; let response = handler.handle(request); @@ -533,8 +868,10 @@ impl FacelockService { self.last_activity.store(now_secs(), Ordering::Relaxed); let caller = resolve_caller_identity(&hdr, connection).await?; require_root(&caller, "PreviewFrame")?; + let capture_guard = self.capture_slot.try_acquire("PreviewFrame")?; let handler = self.handler.clone(); let result = tokio::task::spawn_blocking(move || { + let _capture_guard = capture_guard; let mut handler = lock_handler_with_timeout(&handler)?; let request = DaemonRequest::PreviewFrame; let response = handler.handle(request); @@ -563,14 +900,43 @@ impl FacelockService { self.last_activity.store(now_secs(), Ordering::Relaxed); let caller = resolve_caller_identity(&hdr, connection).await?; require_user_authorized(&caller, user, "PreviewDetectFrame")?; + let caller_is_root = caller.uid == 0; + + // Frame-byte authorization (root bypasses polkit). Resolved BEFORE + // the capture slot is claimed so a pending interactive polkit prompt + // can never hold the slot and starve Authenticate. + let authz = if caller_is_root { + FrameAuthz::Granted + } else { + let sender = hdr + .sender() + .ok_or_else(|| fdo::Error::Failed("no sender in D-Bus message".into()))? + .to_string(); + let authz = self.preview_authz.begin_or_lookup(&sender, Instant::now()); + if authz == FrameAuthz::NeedsCheck { + // Exactly one polkit round-trip per caller connection; the + // verdict lands in the cache for subsequent frame requests. + tokio::spawn(run_frame_authz_check( + connection.clone(), + self.preview_authz.clone(), + sender, + )); + } + authz + }; + let allow_frames = allow_preview_jpeg(caller_is_root, authz); + + let capture_guard = self.capture_slot.try_acquire("PreviewDetectFrame")?; let handler = self.handler.clone(); let user = user.to_string(); let result = tokio::task::spawn_blocking(move || { + let _capture_guard = capture_guard; let mut handler = lock_handler_with_timeout(&handler)?; let request = DaemonRequest::PreviewDetectFrame { user }; let response = handler.handle(request); match response { DaemonResponse::DetectFrame { jpeg_data, faces } => { + let jpeg_data = sanitize_preview_jpeg(jpeg_data, allow_frames); let face_infos: Vec = faces .into_iter() .map(|f| PreviewFaceInfo { @@ -695,12 +1061,15 @@ impl FacelockService { } /// Signal emitted after each authentication attempt. + /// + /// Carries only the user and the match outcome — never the raw + /// similarity score (an information leak / spoof-tuning oracle). + /// The bus policy additionally restricts who may receive this signal. #[zbus(signal)] async fn auth_attempted( emitter: &SignalEmitter<'_>, user: &str, matched: bool, - similarity: f64, ) -> zbus::Result<()>; } @@ -887,11 +1256,14 @@ async fn run_dbus_server( startup_config_mtime: Option, ) -> anyhow::Result<()> { let last_activity = Arc::new(AtomicU64::new(now_secs())); + let preview_authz = Arc::new(PreviewAuthzCache::default()); let service = FacelockService { handler: handler.clone(), last_activity: last_activity.clone(), config_mtime: Arc::new(Mutex::new(startup_config_mtime)), camera_owner_uid: Arc::new(Mutex::new(None)), + capture_slot: Arc::new(CaptureSlot::default()), + preview_authz: preview_authz.clone(), }; let _connection = zbus::connection::Builder::system()? @@ -902,6 +1274,15 @@ async fn run_dbus_server( info!("facelock daemon running on D-Bus system bus as {BUS_NAME}"); + // Evict cached frame authorizations when their caller disconnects, so a + // polkit grant can never outlive the caller's bus connection. + let conn_for_watch = _connection.clone(); + tokio::spawn(async move { + if let Err(e) = watch_bus_disconnects(conn_for_watch, preview_authz).await { + warn!("failed to watch bus disconnects for authz eviction: {e}"); + } + }); + // Drop capabilities now that initialization is complete — camera fd is // open, models are loaded, D-Bus is connected, database is open. match drop_capabilities() { @@ -1107,4 +1488,160 @@ mod tests { .unwrap_err(); assert!(matches!(err, fdo::Error::AccessDenied(_))); } + + #[test] + fn capture_slot_grants_when_free() { + let slot = Arc::new(CaptureSlot::default()); + assert!(slot.try_acquire("Authenticate").is_ok()); + } + + #[test] + fn capture_slot_rejects_concurrent_capture_immediately() { + let slot = Arc::new(CaptureSlot::default()); + let _guard = slot.try_acquire("Authenticate").expect("first acquire"); + let err = slot.try_acquire("Authenticate").unwrap_err(); + // Busy must surface as a plain daemon error so PAM degrades to + // password (never a lockout), and the message must say "busy". + match err { + fdo::Error::Failed(msg) => assert!(msg.contains("busy"), "message: {msg}"), + other => panic!("expected fdo::Error::Failed, got {other:?}"), + } + } + + #[test] + fn capture_slot_frees_on_guard_drop() { + let slot = Arc::new(CaptureSlot::default()); + let guard = slot.try_acquire("Authenticate").expect("first acquire"); + drop(guard); + assert!( + slot.try_acquire("Authenticate").is_ok(), + "slot must be reusable after the previous capture finishes" + ); + } + + #[test] + fn preview_jpeg_stripped_when_frames_not_allowed() { + let jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0]; + assert!(sanitize_preview_jpeg(jpeg, false).is_empty()); + } + + #[test] + fn preview_jpeg_kept_when_frames_allowed() { + let jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0]; + assert_eq!(sanitize_preview_jpeg(jpeg.clone(), true), jpeg); + } + + // --- Frame authorization decision logic (polkit-gated frames) --- + + #[test] + fn root_gets_frames_regardless_of_polkit_state() { + for authz in [ + FrameAuthz::Granted, + FrameAuthz::Refused, + FrameAuthz::Pending, + FrameAuthz::NeedsCheck, + ] { + assert!(allow_preview_jpeg(true, authz), "root denied at {authz:?}"); + } + } + + #[test] + fn non_root_gets_frames_only_when_polkit_granted() { + assert!(allow_preview_jpeg(false, FrameAuthz::Granted)); + assert!(!allow_preview_jpeg(false, FrameAuthz::Refused)); + // Pending / not-yet-checked states FAIL CLOSED. + assert!(!allow_preview_jpeg(false, FrameAuthz::Pending)); + assert!(!allow_preview_jpeg(false, FrameAuthz::NeedsCheck)); + } + + #[test] + fn authz_cache_first_lookup_starts_a_check() { + let cache = PreviewAuthzCache::default(); + let now = Instant::now(); + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::NeedsCheck); + // Second lookup while the check is in flight must not start another. + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::Pending); + } + + #[test] + fn authz_cache_polkit_authorized_grants_until_ttl() { + let cache = PreviewAuthzCache::default(); + let now = Instant::now(); + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::NeedsCheck); + cache.complete(":1.5", Ok(true), now); + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::Granted); + // Still granted just before the TTL... + let almost = now + AUTHZ_GRANTED_TTL - Duration::from_secs(1); + assert_eq!(cache.begin_or_lookup(":1.5", almost), FrameAuthz::Granted); + // ...and re-checked (never silently extended) after it. + let expired = now + AUTHZ_GRANTED_TTL + Duration::from_secs(1); + assert_eq!( + cache.begin_or_lookup(":1.5", expired), + FrameAuthz::NeedsCheck + ); + } + + #[test] + fn authz_cache_polkit_denied_refuses_then_allows_retry() { + let cache = PreviewAuthzCache::default(); + let now = Instant::now(); + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::NeedsCheck); + cache.complete(":1.5", Ok(false), now); + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::Refused); + // A dismissed prompt can be retried after the short denial TTL. + let retry = now + AUTHZ_DENIED_TTL + Duration::from_secs(1); + assert_eq!(cache.begin_or_lookup(":1.5", retry), FrameAuthz::NeedsCheck); + } + + #[test] + fn authz_cache_polkit_error_fails_closed() { + let cache = PreviewAuthzCache::default(); + let now = Instant::now(); + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::NeedsCheck); + cache.complete(":1.5", Err("polkit unreachable".into()), now); + // A polkit/D-Bus error must never grant frames. + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::Refused); + assert!(!allow_preview_jpeg(false, FrameAuthz::Refused)); + } + + #[test] + fn authz_cache_eviction_kills_grant_with_connection() { + let cache = PreviewAuthzCache::default(); + let now = Instant::now(); + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::NeedsCheck); + cache.complete(":1.5", Ok(true), now); + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::Granted); + // Caller's bus connection went away — grant dies with it. + cache.evict(":1.5"); + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::NeedsCheck); + } + + #[test] + fn authz_cache_drops_verdict_for_disconnected_caller() { + let cache = PreviewAuthzCache::default(); + let now = Instant::now(); + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::NeedsCheck); + // Connection dies while the polkit check is in flight. + cache.evict(":1.5"); + cache.complete(":1.5", Ok(true), now); + // The late verdict must not resurrect a grant for a dead connection + // (a reconnecting caller re-runs the polkit check). + assert_eq!(cache.begin_or_lookup(":1.5", now), FrameAuthz::NeedsCheck); + } + + #[test] + fn authz_cache_is_bounded() { + let cache = PreviewAuthzCache::default(); + let now = Instant::now(); + for i in 0..(AUTHZ_CACHE_MAX_ENTRIES * 2) { + let name = format!(":1.{i}"); + assert_eq!(cache.begin_or_lookup(&name, now), FrameAuthz::NeedsCheck); + cache.complete(&name, Ok(true), now); + } + let len = cache.entries.lock().unwrap().len(); + assert!( + len <= AUTHZ_CACHE_MAX_ENTRIES, + "cache grew unbounded: {len} entries" + ); + } } diff --git a/crates/facelock-cli/src/commands/preview/wayland_preview.rs b/crates/facelock-cli/src/commands/preview/wayland_preview.rs index 38fe0d1..c6e575f 100644 --- a/crates/facelock-cli/src/commands/preview/wayland_preview.rs +++ b/crates/facelock-cli/src/commands/preview/wayland_preview.rs @@ -218,6 +218,15 @@ fn render_frame( ) { let stride = width * 4; + if jpeg_data.is_empty() { + // The daemon strips raw frame bytes until the caller is authorized + // via polkit (org.facelock.preview-frames); the first frame request + // triggers the agent prompt. Show the detection summary without + // imagery while unauthorized. + render_metadata_only(canvas, width, height, fps, faces); + return; + } + match decode_jpeg(jpeg_data) { Ok((rgb, img_w, img_h)) => { let (disp_w, disp_h) = fit_dimensions(img_w, img_h, width, height); @@ -286,6 +295,34 @@ fn render_frame( } } +/// Render detection metadata without camera imagery (non-root callers). +fn render_metadata_only( + canvas: &mut [u8], + width: u32, + height: u32, + fps: f32, + faces: &[PreviewFace], +) { + let stride = width * 4; + + for chunk in canvas.chunks_exact_mut(4) { + chunk.copy_from_slice(&[0, 0, 0, 0xFF]); + } + + super::font::draw_text( + canvas, + stride, + 8, + height / 2, + "authorize in the system prompt to view camera frames", + render::COLOR_WHITE, + ); + + let recognized = faces.iter().filter(|f| f.recognized).count() as u32; + let unrecognized = faces.len() as u32 - recognized; + render::draw_info_bar(canvas, stride, width, height, fps, recognized, unrecognized); +} + /// Render an error message on a dark red background. fn render_error(canvas: &mut [u8], width: u32, height: u32, message: &str) { let stride = width * 4; diff --git a/crates/facelock-cli/src/ipc_client.rs b/crates/facelock-cli/src/ipc_client.rs index a361ba7..e828dfd 100644 --- a/crates/facelock-cli/src/ipc_client.rs +++ b/crates/facelock-cli/src/ipc_client.rs @@ -68,8 +68,21 @@ pub fn should_use_direct(config: &facelock_core::Config) -> bool { } } -/// Create a blocking D-Bus proxy to the daemon with a 15-second method timeout. +/// Process-wide daemon proxy, built once and reused for every request. +static PROXY: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Get the blocking D-Bus proxy to the daemon (15-second method timeout). +/// +/// The underlying connection is created once per process and reused: the +/// daemon keys its polkit frame authorization (`org.facelock.preview-frames`) +/// on the caller's unique bus name, so a preview session must keep a single +/// connection for its grant to apply across frames. Failures are not cached — +/// the next call retries the connection. fn create_proxy() -> anyhow::Result> { + if let Some(proxy) = PROXY.get() { + return Ok(proxy.clone()); + } + let connection = zbus::blocking::connection::Builder::system() .map_err(|e| anyhow::anyhow!("D-Bus connection failed: {e}"))? .method_timeout(std::time::Duration::from_secs(15)) @@ -79,7 +92,7 @@ fn create_proxy() -> anyhow::Result> { let proxy = Proxy::new_owned(connection, BUS_NAME, OBJECT_PATH, INTERFACE_NAME) .map_err(|e| anyhow::anyhow!("D-Bus proxy failed: {e}"))?; - Ok(proxy) + Ok(PROXY.get_or_init(|| proxy).clone()) } /// Send a request to the daemon via D-Bus, translating to/from the old diff --git a/dbus/org.facelock.Daemon.conf b/dbus/org.facelock.Daemon.conf index bb0e528..a627e41 100644 --- a/dbus/org.facelock.Daemon.conf +++ b/dbus/org.facelock.Daemon.conf @@ -5,10 +5,17 @@ + + + + + @@ -20,5 +27,6 @@ send_interface="org.freedesktop.DBus.Properties"/> + diff --git a/dbus/org.facelock.policy b/dbus/org.facelock.policy new file mode 100644 index 0000000..362a8f0 --- /dev/null +++ b/dbus/org.facelock.policy @@ -0,0 +1,23 @@ + + + + Facelock + https://github.com/tyvsmith/facelock + + + + View live camera frames in the facelock preview + Authentication is required to view live frames from the face recognition camera + + no + no + auth_self_keep + + + diff --git a/dist/PKGBUILD b/dist/PKGBUILD index f18ecac..f8274dd 100644 --- a/dist/PKGBUILD +++ b/dist/PKGBUILD @@ -59,6 +59,7 @@ package() { # D-Bus policy and activation service install -Dm644 dbus/org.facelock.Daemon.conf "$pkgdir/usr/share/dbus-1/system.d/org.facelock.Daemon.conf" install -Dm644 dbus/org.facelock.Daemon.service "$pkgdir/usr/share/dbus-1/system-services/org.facelock.Daemon.service" + install -Dm644 dbus/org.facelock.policy "$pkgdir/usr/share/polkit-1/actions/org.facelock.policy" # sysusers.d for facelock group install -Dm644 dist/facelock.sysusers "$pkgdir/usr/lib/sysusers.d/facelock.conf" diff --git a/dist/PKGBUILD-bin b/dist/PKGBUILD-bin index ecf9ddd..5bb69a1 100644 --- a/dist/PKGBUILD-bin +++ b/dist/PKGBUILD-bin @@ -38,6 +38,7 @@ package() { install -Dm644 systemd/facelock-daemon.service "$pkgdir/usr/lib/systemd/system/facelock-daemon.service" install -Dm644 dbus/org.facelock.Daemon.conf "$pkgdir/usr/share/dbus-1/system.d/org.facelock.Daemon.conf" install -Dm644 dbus/org.facelock.Daemon.service "$pkgdir/usr/share/dbus-1/system-services/org.facelock.Daemon.service" + install -Dm644 dbus/org.facelock.policy "$pkgdir/usr/share/polkit-1/actions/org.facelock.policy" install -Dm644 dist/facelock.sysusers "$pkgdir/usr/lib/sysusers.d/facelock.conf" install -Dm644 dist/facelock.tmpfiles "$pkgdir/usr/lib/tmpfiles.d/facelock.conf" diff --git a/dist/PKGBUILD-git b/dist/PKGBUILD-git index f52c60f..987044f 100644 --- a/dist/PKGBUILD-git +++ b/dist/PKGBUILD-git @@ -62,6 +62,7 @@ package() { # D-Bus policy and activation service install -Dm644 dbus/org.facelock.Daemon.conf "$pkgdir/usr/share/dbus-1/system.d/org.facelock.Daemon.conf" install -Dm644 dbus/org.facelock.Daemon.service "$pkgdir/usr/share/dbus-1/system-services/org.facelock.Daemon.service" + install -Dm644 dbus/org.facelock.policy "$pkgdir/usr/share/polkit-1/actions/org.facelock.policy" # sysusers.d for facelock group install -Dm644 dist/facelock.sysusers "$pkgdir/usr/lib/sysusers.d/facelock.conf" diff --git a/dist/debian/rules b/dist/debian/rules index 71feadc..59b778d 100755 --- a/dist/debian/rules +++ b/dist/debian/rules @@ -34,6 +34,9 @@ override_dh_auto_install: install -Dm644 dbus/org.facelock.Daemon.conf debian/facelock/usr/share/dbus-1/system.d/org.facelock.Daemon.conf install -Dm644 dbus/org.facelock.Daemon.service debian/facelock/usr/share/dbus-1/system-services/org.facelock.Daemon.service + # Polkit action (interactive authorization for preview frame bytes) + install -Dm644 dbus/org.facelock.policy debian/facelock/usr/share/polkit-1/actions/org.facelock.policy + # sysusers.d install -Dm644 dist/facelock.sysusers debian/facelock/usr/lib/sysusers.d/facelock.conf diff --git a/dist/facelock.spec b/dist/facelock.spec index ca14acd..44d134e 100644 --- a/dist/facelock.spec +++ b/dist/facelock.spec @@ -74,6 +74,9 @@ install -Dm644 dist/facelock.tmpfiles %{buildroot}%{_tmpfilesdir}/facelock.conf install -Dm644 dbus/org.facelock.Daemon.conf %{buildroot}%{_datadir}/dbus-1/system.d/org.facelock.Daemon.conf install -Dm644 dbus/org.facelock.Daemon.service %{buildroot}%{_datadir}/dbus-1/system-services/org.facelock.Daemon.service +# Polkit action (interactive authorization for preview frame bytes) +install -Dm644 dbus/org.facelock.policy %{buildroot}%{_datadir}/polkit-1/actions/org.facelock.policy + # authselect profile install -dm755 %{buildroot}%{_datadir}/authselect/vendor/facelock install -Dm644 dist/authselect/facelock/system-auth %{buildroot}%{_datadir}/authselect/vendor/facelock/system-auth @@ -162,6 +165,7 @@ fi %{_tmpfilesdir}/facelock.conf %{_datadir}/dbus-1/system.d/org.facelock.Daemon.conf %{_datadir}/dbus-1/system-services/org.facelock.Daemon.service +%{_datadir}/polkit-1/actions/org.facelock.policy %{_datadir}/authselect/vendor/facelock/ %changelog diff --git a/dist/nix/default.nix b/dist/nix/default.nix index 6b22090..0a168e1 100644 --- a/dist/nix/default.nix +++ b/dist/nix/default.nix @@ -66,6 +66,9 @@ rustPlatform.buildRustPackage { install -Dm644 dbus/org.facelock.Daemon.conf $out/share/dbus-1/system.d/org.facelock.Daemon.conf install -Dm644 dbus/org.facelock.Daemon.service $out/share/dbus-1/system-services/org.facelock.Daemon.service + # Polkit action (interactive authorization for preview frame bytes) + install -Dm644 dbus/org.facelock.policy $out/share/polkit-1/actions/org.facelock.policy + # sysusers.d and tmpfiles.d install -Dm644 dist/facelock.sysusers $out/lib/sysusers.d/facelock.conf install -Dm644 dist/facelock.tmpfiles $out/lib/tmpfiles.d/facelock.conf diff --git a/dist/nix/module.nix b/dist/nix/module.nix index 97563e5..344736c 100644 --- a/dist/nix/module.nix +++ b/dist/nix/module.nix @@ -41,6 +41,11 @@ in # Install the package environment.systemPackages = [ facelockPackage ]; + # Polkit action for org.facelock.preview-frames (polkitd discovers + # actions from packages listed here) + security.polkit.enable = true; + environment.pathsToLink = [ "/share/polkit-1" ]; + # PAM module security.pam.services = { sudo.rules.auth.facelock = { diff --git a/docs/contracts.md b/docs/contracts.md index 4839e43..fc8497e 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -142,6 +142,44 @@ Method authorization contract: - `ReleaseCamera`: root or the Unix user that owns the active preview camera session. - `ListDevices`, `Ping`: resolve caller UID before replying and rely on the system bus policy for admission control. +Raw camera frames require privilege. `PreviewFrame` remains root-only. +`PreviewDetectFrame` returns the `jpeg_data` frame bytes to root +unconditionally; a non-root caller receives them **only** after an +interactive polkit authorization for the action +**`org.facelock.preview-frames`** (shipped in `dbus/org.facelock.policy`, +installed to `/usr/share/polkit-1/actions/org.facelock.policy`; defaults +`allow_any=no`, `allow_inactive=no`, `allow_active=auth_self_keep`). The +daemon checks the caller via +`org.freedesktop.PolicyKit1.Authority.CheckAuthorization` (subject = the +caller's unique bus name, `AllowUserInteraction=true`); the first frame +request triggers the caller's polkit agent prompt. While unauthorized — +denied, prompt pending, polkit unreachable, or any D-Bus error — the daemon +**fails closed**: `jpeg_data` is empty and the caller receives detection and +recognition metadata (bounding boxes, confidence, similarity, recognized) +only. + +The polkit verdict is cached per caller **connection** (keyed by unique bus +name): granted verdicts for at most 120 s, denied/errored verdicts for 15 s, +and every cached verdict dies when the caller's bus connection closes +(whichever comes first). Clients that want frames across a preview session +must therefore keep one D-Bus connection open for the whole session. + +Capture concurrency: `Authenticate`, `Enroll`, `PreviewFrame`, and +`PreviewDetectFrame` are serialized by an in-flight capture guard. While one +capture is in progress, a concurrent call to any of these methods fails +**immediately** with an `org.freedesktop.DBus.Error.Failed` error whose +message contains `daemon busy` (no queuing on the internal handler lock). +Clients (PAM included) must treat this like any other daemon error — degrade +to the next auth mechanism (password), never a lockout. + +### Signals +- `AuthAttempted(user: s, matched: b)` — emitted after each authentication + attempt. The payload intentionally carries **no similarity score** (the raw + biometric score is an information leak / spoof-tuning oracle). The system + bus policy (`dbus/org.facelock.Daemon.conf`) denies signal reception from + the daemon by default; only root and members of the `facelock` group may + receive it. + ### Response types `AuthResult`, `Enrolled`, `Models`, `Removed`, `Frame`, `DetectFrame`, `Devices`, `Ok`, `Error` diff --git a/docs/security.md b/docs/security.md index 1fcd844..10734f3 100644 --- a/docs/security.md +++ b/docs/security.md @@ -200,6 +200,36 @@ The daemon must also verify the caller UID via `GetConnectionUnixUser` on every - `ReleaseCamera`: root or the Unix user that owns the active preview camera session - `ListDevices`: root or a caller in the `facelock` group +The policy also self-contains two explicit defaults rather than relying on system-wide bus defaults: +- `` in the default context (name-squatting protection; only root may own the name). +- `` in the default context, with explicit allows for root and the `facelock` group (see below). + +#### A2. Auth-Attempt Signal Hygiene (Implemented) + +**Attack**: Any local user adds a match rule (or runs `dbus-monitor`) and passively observes `AuthAttempted` broadcast signals to learn who authenticates when — and, if the payload carried the raw similarity score, uses it as a spoof-tuning oracle (iterate on a photo/mask until the score climbs). + +**Mitigations**: +- The `AuthAttempted` signal payload is `(user: s, matched: b)` only. It **never** carries the similarity score; the raw biometric score is available only in the `Authenticate` method reply to the authorized caller. +- The bus policy denies delivery of the daemon's signals in the default context; only root and `facelock`-group members may receive them. + +#### A3. Raw Frame Access Parity (Implemented — polkit-authorized) + +**Attack**: `PreviewFrame` is root-only, but a `facelock`-group member pulls raw camera/IR frames through the weaker-gated `PreviewDetectFrame` "detect" variant instead — silently, with no user consent. + +**Mitigation**: `PreviewFrame` stays root-only. `PreviewDetectFrame` serves the `jpeg_data` frame bytes to root unconditionally; for a non-root caller the daemon requires an **interactive polkit authorization** for `org.facelock.preview-frames` (defaults: `allow_any=no`, `allow_inactive=no`, `allow_active=auth_self_keep` — the caller must type their own password in an active local session, and polkit keeps the grant only ~5 minutes). The daemon calls `CheckAuthorization` with `AllowUserInteraction=true` on the caller's unique bus name; the check runs in the background and never blocks the reply or holds the capture slot, so a pending prompt cannot starve `Authenticate`. + +**Fail closed**: while the verdict is pending, denied, timed out, or polkit is unreachable (any D-Bus error), the frame bytes are stripped and the caller gets detection/recognition metadata only (bounding boxes, confidence, similarity, recognized). Verdicts are cached per caller connection — granted for at most 120 s, denied for 15 s — and evicted the moment the caller's bus connection closes (`NameOwnerChanged`), so a grant can never outlive the connection it was issued to. This preserves the enroll/preview UX (the preview window prompts once via the user's polkit agent, then shows live frames) without ever handing out camera/IR imagery silently. + +`auth_self_keep` rather than `auth_admin`: the resource is the caller's *own* camera preview (the bus policy already restricts daemon access to root/`facelock` group, and `PreviewDetectFrame` to the matching Unix user). Requiring the user's own password is proportionate consent for camera imagery; `auth_admin` would lock non-admin users out of enroll feedback entirely, which is the UX regression this design fixes. + +**Residual — similarity in detect metadata (accepted, self-scoped).** The stripped-frame response still returns per-face recognition metadata — bounding boxes, confidence, and the recognition *similarity* score — so the enroll/preview UI can give live quality feedback ("your face is recognized well, hold still to capture"). A raw similarity score is a spoof-tuning oracle in general (iterate a photo/mask until the number climbs), which is exactly why A2 removed it from the broadcast `AuthAttempted` signal. Here it is deliberately **retained but bounded**: `PreviewDetectFrame` is authorized only for **root or the caller's matching Unix user** (see the method-level authorization list above), so a non-root caller can read the similarity only for *their own* face against *their own* templates. There is no cross-user query path — obtaining another account's tuning score would require being root or being that user, in which case the score reveals nothing they could not already obtain by authenticating. The continuous score therefore serves enroll UX without functioning as an oracle against another account. This residual is accepted rather than coarsened; a future option is to bucket the score (`weak`/`good`/`strong`) if the self-scoped exposure is ever deemed too precise. + +#### A4. Capture Contention Guard (Implemented) + +**Attack**: Local DoS — an authorized caller loops `Authenticate`/`PreviewDetectFrame`, keeping the global handler mutex held so every other caller (including root) queues up to the 10-second handler-lock timeout per request. + +**Mitigation**: A cheap in-flight capture guard is checked *before* the expensive handler lock. If a capture is already in flight, a concurrent `Authenticate`/`Enroll`/`PreviewFrame`/`PreviewDetectFrame` call is rejected **immediately** with a `daemon busy` error instead of queueing. PAM treats this like any daemon error (`PAM_IGNORE`) and falls through to password — degraded, never locked out. Per-user rate limiting is unchanged and orthogonal. + #### B. D-Bus Message Size Limits (Enforced by Bus) The D-Bus bus daemon enforces message size limits (typically 128MB by default, configurable in the bus configuration). This prevents oversized messages from consuming daemon memory without requiring application-level size checks. diff --git a/justfile b/justfile index 40ca5cf..73b2829 100644 --- a/justfile +++ b/justfile @@ -151,6 +151,9 @@ install-files: install -Dm644 dbus/org.facelock.Daemon.service /etc/dbus-1/system-services/org.facelock.Daemon.service fi + # Polkit action (interactive authorization for preview frame bytes) + install -Dm644 dbus/org.facelock.policy /usr/share/polkit-1/actions/org.facelock.policy + # Polkit agent binary (optional, do NOT install autostart — agent is not production-ready # and will steal polkit auth from the DE's agent, causing all privilege prompts to hang) [ -f target/release/facelock-polkit-agent ] && install -Dm755 target/release/facelock-polkit-agent /usr/bin/facelock-polkit-agent || true @@ -277,6 +280,7 @@ uninstall-files: fi rm -f /usr/share/dbus-1/system.d/org.facelock.Daemon.conf rm -f /usr/share/dbus-1/system-services/org.facelock.Daemon.service + rm -f /usr/share/polkit-1/actions/org.facelock.policy rm -f /usr/bin/facelock-polkit-agent rm -f /etc/xdg/autostart/org.facelock.AuthAgent.desktop @@ -393,6 +397,7 @@ show-paths: @echo "Models: /var/lib/facelock/models/" @echo "Database: /var/lib/facelock/facelock.db" @echo "D-Bus: /usr/share/dbus-1/system.d/org.facelock.Daemon.conf" + @echo "Polkit: /usr/share/polkit-1/actions/org.facelock.policy" @echo "Service: /usr/lib/systemd/system/facelock-daemon.service" @echo "Logs: /var/log/facelock/" diff --git a/test/Containerfile b/test/Containerfile index e833130..9b72794 100644 --- a/test/Containerfile +++ b/test/Containerfile @@ -2,7 +2,7 @@ FROM archlinux:latest # Install dependencies -RUN pacman -Syu --noconfirm pam sudo base-devel libxkbcommon just dbus onnxruntime-cpu protobuf && pacman -Scc --noconfirm +RUN pacman -Syu --noconfirm pam sudo base-devel libxkbcommon just dbus polkit onnxruntime-cpu protobuf && pacman -Scc --noconfirm # Build pamtester from upstream source RUN curl -sL https://sourceforge.net/projects/pamtester/files/pamtester/0.1.2/pamtester-0.1.2.tar.gz/download | tar xz -C /tmp && \ diff --git a/test/pkg-validate.sh b/test/pkg-validate.sh index 78168a2..0c30c47 100644 --- a/test/pkg-validate.sh +++ b/test/pkg-validate.sh @@ -58,6 +58,8 @@ run_test "PAM module exists in supported path" "[ -n \"$PAM_MODULE_PATH\" ]" run_test "config exists" "[ -f /etc/facelock/config.toml ]" run_test "D-Bus policy exists" "[ -f /usr/share/dbus-1/system.d/org.facelock.Daemon.conf ]" run_test "D-Bus activation exists" "[ -f /usr/share/dbus-1/system-services/org.facelock.Daemon.service ]" +run_test "polkit action policy exists" "[ -f /usr/share/polkit-1/actions/org.facelock.policy ]" +run_test "polkit action policy declares preview-frames action" "grep -q 'org.facelock.preview-frames' /usr/share/polkit-1/actions/org.facelock.policy" run_test "sysusers file exists" "[ -f /usr/lib/sysusers.d/facelock.conf ] || [ -f /usr/share/sysusers.d/facelock.conf ]" run_test "tmpfiles file exists" "[ -f /usr/lib/tmpfiles.d/facelock.conf ] || [ -f /usr/share/tmpfiles.d/facelock.conf ]" @@ -75,6 +77,10 @@ run_test "facelock --help exits successfully" "/usr/bin/facelock --help >/dev/nu run_test "D-Bus policy XML is valid" "if command -v xmllint >/dev/null 2>&1; then xmllint --noout \"$DBUS_POLICY_FILE\"; else python3 -c \"import os, xml.etree.ElementTree as ET; ET.parse(os.environ.get(\\\"DBUS_POLICY_FILE\\\"))\"; fi" +POLKIT_POLICY_FILE="/usr/share/polkit-1/actions/org.facelock.policy" +export POLKIT_POLICY_FILE +run_test "polkit action policy XML is valid" "if command -v xmllint >/dev/null 2>&1; then xmllint --noout \"$POLKIT_POLICY_FILE\"; else python3 -c \"import os, xml.etree.ElementTree as ET; ET.parse(os.environ.get(\\\"POLKIT_POLICY_FILE\\\"))\"; fi" + run_test "facelock group exists (sysusers)" "if command -v systemd-sysusers >/dev/null 2>&1; then systemd-sysusers >/dev/null 2>&1 || true; fi; getent group facelock >/dev/null" run_test "facelock runtime directories exist (tmpfiles)" "if command -v systemd-tmpfiles >/dev/null 2>&1; then systemd-tmpfiles --create >/dev/null 2>&1 || true; fi; [ -d /var/lib/facelock ] && [ -d /var/log/facelock ]" diff --git a/test/run-integration-tests.sh b/test/run-integration-tests.sh index 903df87..e6c0ad2 100755 --- a/test/run-integration-tests.sh +++ b/test/run-integration-tests.sh @@ -85,11 +85,26 @@ mkdir -p /run/dbus dbus-uuidgen --ensure=/etc/machine-id >/dev/null 2>&1 || true dbus-daemon --system --fork --nopidfile +# Start polkitd so PreviewDetectFrame's frame authorization exercises a real +# polkit round-trip. No authentication agent is registered in the container, +# so interactive authorization is impossible — the daemon must FAIL CLOSED +# (stripped frames) unless an explicit test rule grants the action. +POLKITD_PID="" +if [ -x /usr/lib/polkit-1/polkitd ]; then + /usr/lib/polkit-1/polkitd --no-debug > /tmp/polkitd.log 2>&1 & + POLKITD_PID=$! + sleep 1 +fi + cleanup() { if [ -n "${DAEMON_PID:-}" ]; then kill "$DAEMON_PID" 2>/dev/null || true wait "$DAEMON_PID" 2>/dev/null || true fi + rm -f /etc/polkit-1/rules.d/90-facelock-test.rules + if [ -n "${POLKITD_PID:-}" ]; then + kill "$POLKITD_PID" 2>/dev/null || true + fi pkill dbus-daemon 2>/dev/null || true } trap cleanup EXIT @@ -127,6 +142,203 @@ run_test_contains "Authenticate enrolled face (CLI)" \ run_test "Authenticate enrolled face (PAM)" \ "timeout --foreground $LIVE_TIMEOUT pamtester facelock-test testuser authenticate" +# --- D-Bus hardening assertions (security plan 06) --- + +# sigwatcher: unprivileged, NOT in the facelock group (signal eavesdropper). +# testuser: added to the facelock group (bus policy allows it to send). +useradd -m sigwatcher 2>/dev/null || true +usermod -aG facelock testuser + +# (a) Signal hardening — needs the daemon up plus one auth attempt to emit +# the signal. Unprivileged users must not receive AuthAttempted, and the +# payload must carry no similarity score (no 'double' argument). +runuser -u sigwatcher -- dbus-monitor --system \ + "type='signal',interface='org.facelock.Daemon'" > /tmp/sig-unpriv.log 2>&1 & +UNPRIV_MON_PID=$! +dbus-monitor --system \ + "type='signal',interface='org.facelock.Daemon'" > /tmp/sig-root.log 2>&1 & +ROOT_MON_PID=$! +sleep 2 +timeout --foreground "$LIVE_TIMEOUT" facelock test --user testuser > /dev/null 2>&1 || true +sleep 2 +kill "$UNPRIV_MON_PID" "$ROOT_MON_PID" 2>/dev/null || true +wait "$UNPRIV_MON_PID" "$ROOT_MON_PID" 2>/dev/null || true + +run_test "AuthAttempted signal visible to root monitor" \ + "grep -q 'member=AuthAttempted' /tmp/sig-root.log" + +run_test "AuthAttempted payload carries no similarity score" \ + "! grep -A3 'member=AuthAttempted' /tmp/sig-root.log | grep -q 'double'" + +run_test "Unprivileged user receives no AuthAttempted signal" \ + "! grep -q 'member=AuthAttempted' /tmp/sig-unpriv.log" + +# Policy: the default context explicitly denies owning the daemon name — +# only root may own org.facelock.Daemon. +check_own_denied() { + local out rc + set +e + out=$(runuser -u sigwatcher -- dbus-send --system --print-reply \ + --dest=org.freedesktop.DBus /org/freedesktop/DBus \ + org.freedesktop.DBus.RequestName string:org.facelock.Daemon uint32:0 2>&1) + rc=$? + set -e + echo "$out" + [ "$rc" -ne 0 ] || { echo "RequestName unexpectedly succeeded"; return 1; } + echo "$out" | grep -qiE "not allowed to own|AccessDenied" || return 1 + return 0 +} +run_test "Unprivileged user cannot own org.facelock.Daemon" \ + "check_own_denied" + +# (b) PreviewDetectFrame authz parity — a facelock-group non-root caller may +# call it for itself, but the reply must not contain raw frame bytes +# (dbus-send renders non-empty byte arrays as hex; a JPEG starts with ff d8). +check_preview_detect_frame_stripped() { + local out + if ! out=$(runuser -u testuser -- dbus-send --system --print-reply \ + --reply-timeout=60000 \ + --dest=org.facelock.Daemon /org/facelock/Daemon \ + org.facelock.Daemon.PreviewDetectFrame string:testuser 2>&1); then + echo "$out" + return 1 + fi + echo "$out" + echo "$out" | grep -q "method return" || return 1 + if echo "$out" | grep -qi "ff d8"; then + echo "reply contains JPEG frame bytes (ff d8) — should be stripped" + return 1 + fi + return 0 +} +run_test "PreviewDetectFrame returns no raw frame to non-root caller" \ + "check_preview_detect_frame_stripped" + +# (b2) Packaging contract — the polkit action for frame authorization is +# installed alongside the D-Bus policy. +run_test "polkit action policy installed" \ + "[ -f /usr/share/polkit-1/actions/org.facelock.policy ] && grep -q 'org.facelock.preview-frames' /usr/share/polkit-1/actions/org.facelock.policy" + +# (b3) AUTHORIZED PATH: with an explicit polkit rule granting +# org.facelock.preview-frames, a non-root preview session (one bus +# connection across frames) receives real frame bytes. The first frame is +# metadata-only while the daemon's polkit check is in flight; subsequent +# frames must carry jpeg bytes (jpeg_size > 0). +check_preview_frames_authorized() { + mkdir -p /etc/polkit-1/rules.d + cat > /etc/polkit-1/rules.d/90-facelock-test.rules <<'RULES' +polkit.addRule(function(action, subject) { + if (action.id == "org.facelock.preview-frames") { + return polkit.Result.YES; + } +}); +RULES + sleep 2 # polkitd reloads rules.d via inotify + timeout --foreground 30 runuser -u testuser -- \ + facelock preview --text-only 2>/dev/null | head -15 > /tmp/preview-authz.log || true + rm -f /etc/polkit-1/rules.d/90-facelock-test.rules + sleep 2 # let polkitd drop the rule before the fail-closed re-check + cat /tmp/preview-authz.log + grep -q '"jpeg_size":[1-9]' /tmp/preview-authz.log || return 1 + return 0 +} + +if [ -n "$POLKITD_PID" ] && kill -0 "$POLKITD_PID" 2>/dev/null; then + run_test "PreviewDetectFrame serves frames to polkit-authorized caller" \ + "check_preview_frames_authorized" + + # (b4) The grant must not leak: with the rule gone, a fresh caller is + # stripped again (fail closed). + run_test "PreviewDetectFrame stripped again after polkit rule removal" \ + "check_preview_detect_frame_stripped" +else + echo "SKIP: polkitd unavailable — polkit-authorized frame path not exercised" +fi + +# Release the preview camera session before the concurrency test +dbus-send --system --print-reply --dest=org.facelock.Daemon /org/facelock/Daemon \ + org.facelock.Daemon.ReleaseCamera > /dev/null 2>&1 || true + +# (c) CAMERA-REQUIRED: mutex-DoS guard — race two simultaneous Authenticate +# calls. Exactly one wins the capture slot; the other must be rejected +# immediately with a "busy" error (milliseconds), never queued toward the +# 10s handler-lock timeout. +timed_authenticate() { + # $1 = output file, $2 = meta file (rc + elapsed_ms), $3 = run as user ("" = root) + local s e rc + s=$(date +%s%N) + if [ -n "$3" ]; then + runuser -u "$3" -- dbus-send --system --print-reply --reply-timeout=30000 \ + --dest=org.facelock.Daemon /org/facelock/Daemon \ + org.facelock.Daemon.Authenticate string:testuser > "$1" 2>&1 + rc=$? + else + dbus-send --system --print-reply --reply-timeout=30000 \ + --dest=org.facelock.Daemon /org/facelock/Daemon \ + org.facelock.Daemon.Authenticate string:testuser > "$1" 2>&1 + rc=$? + fi + e=$(date +%s%N) + echo "$rc $(((e - s) / 1000000))" > "$2" +} + +check_concurrent_auth_busy() { + set +e + timed_authenticate /tmp/auth-a.out /tmp/auth-a.meta "" & + local pid_a=$! + timed_authenticate /tmp/auth-b.out /tmp/auth-b.meta testuser & + local pid_b=$! + wait "$pid_a" "$pid_b" + set -e + + local rc_a ms_a rc_b ms_b + read -r rc_a ms_a < /tmp/auth-a.meta + read -r rc_b ms_b < /tmp/auth-b.meta + echo "call A (root): rc=$rc_a elapsed=${ms_a}ms" + echo "call B (testuser): rc=$rc_b elapsed=${ms_b}ms" + echo "--- A reply:" + cat /tmp/auth-a.out + echo "--- B reply:" + cat /tmp/auth-b.out + + local busy=0 busy_ms=0 + if grep -qi "busy" /tmp/auth-a.out; then + busy=$((busy + 1)) + busy_ms=$ms_a + fi + if grep -qi "busy" /tmp/auth-b.out; then + busy=$((busy + 1)) + busy_ms=$ms_b + fi + [ "$busy" -eq 1 ] || { echo "expected exactly one busy rejection, got $busy"; return 1; } + # Rejected immediately — well under the 10s handler-lock stall + [ "$busy_ms" -lt 5000 ] || { echo "busy rejection took ${busy_ms}ms (stall)"; return 1; } + return 0 +} +run_test "Concurrent Authenticate rejected immediately with busy" \ + "check_concurrent_auth_busy" + +# The busy guard must not starve legitimate sequential auth: once the +# concurrent capture finished, a new Authenticate must run a full capture +# (match or no-match depending on who is in front of the camera), and must +# NOT be rejected with a busy error. +check_sequential_auth_not_starved() { + local out rc + set +e + out=$(timeout --foreground "$LIVE_TIMEOUT" facelock test --user testuser 2>&1) + rc=$? + set -e + echo "$out" + if echo "$out" | grep -qi "busy"; then + echo "sequential auth was rejected as busy (starved by the guard)" + return 1 + fi + echo "$out" | grep -qE "Matched model|No match" || return 1 + return 0 +} +run_test "Sequential auth not starved after busy rejection" \ + "check_sequential_auth_not_starved" + # Clean up run_test "Clear enrolled models" \ "facelock clear --user testuser --yes"