Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions config/facelock.toml
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,28 @@
# dir = "/var/log/facelock/snapshots"


# ── Polkit ─────────────────────────────────────────────────────────
#
# Controls the facelock polkit authentication agent. Face auth is SCOPED,
# not universal: a single face match must not authorize every privileged
# polkit action (pkexec, package install, disk mount, user admin).
#
# `face_eligible_actions` lists the polkit action_ids for which face auth
# may be offered. Any action NOT in the list is declined by the agent so
# polkit falls through to the password dialog (another agent handles it) —
# it is never denied outright.
#
# The default is a small, low-risk set. High-risk actions — pkexec
# (org.freedesktop.policykit.exec), PackageKit install/remove, udisks mount,
# accounts-service user administration — are intentionally EXCLUDED. Extend
# the list deliberately if you want face to reach further (like a fingerprint
# reader), then restart the agent. An empty list disables face for all
# actions.
#
# [polkit]
# face_eligible_actions = ["org.freedesktop.login1.lock-sessions"]


# ── TPM ────────────────────────────────────────────────────────────
#
# TPM 2.0 settings for sealing the AES encryption key.
Expand Down
132 changes: 132 additions & 0 deletions crates/facelock-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub struct Config {
pub encryption: EncryptionConfig,
#[serde(default)]
pub audit: AuditConfig,
#[serde(default)]
pub polkit: PolkitConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -489,6 +491,48 @@ impl Default for AuditConfig {
}
}

/// Configuration for the polkit authentication agent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolkitConfig {
/// polkit `action_id`s for which face authentication may be offered.
///
/// A single face match must NOT authorize every polkit action (pkexec,
/// package install, disk mount, user admin). Any action not in this list
/// is declined by the agent so polkit falls through to the password
/// dialog handled by another agent — it is never denied outright.
///
/// The default is a small vetted set of low/moderate-sensitivity actions.
/// Users may extend it deliberately (like a fingerprint reader's reach),
/// but high-risk actions are excluded by default.
#[serde(default = "default_face_eligible_actions")]
pub face_eligible_actions: Vec<String>,
}

impl Default for PolkitConfig {
fn default() -> Self {
Self {
face_eligible_actions: default_face_eligible_actions(),
}
}
}

impl PolkitConfig {
/// Whether the given polkit `action_id` may be authorized by face auth.
pub fn is_face_eligible(&self, action_id: &str) -> bool {
self.face_eligible_actions.iter().any(|a| a == action_id)
}
}

/// Default polkit actions eligible for face authentication.
///
/// Deliberately small and low-risk. High-risk actions — pkexec
/// (`org.freedesktop.policykit.exec`), PackageKit install/remove, udisks
/// mount, and accounts-service user admin — are intentionally EXCLUDED so a
/// single face match cannot become a universal root key.
fn default_face_eligible_actions() -> Vec<String> {
vec!["org.freedesktop.login1.lock-sessions".to_string()]
}

fn default_audit_path() -> String {
"/var/log/facelock/audit.jsonl".to_string()
}
Expand Down Expand Up @@ -994,6 +1038,94 @@ key_path = "/etc/facelock/my.key"
assert_eq!(config.encryption.key_path, "/etc/facelock/my.key");
}

#[test]
fn polkit_config_default_allowlist_is_low_risk() {
let config = PolkitConfig::default();
// Default must offer face only for the vetted low-risk action.
assert_eq!(
config.face_eligible_actions,
vec!["org.freedesktop.login1.lock-sessions".to_string()]
);
assert!(config.is_face_eligible("org.freedesktop.login1.lock-sessions"));
}

#[test]
fn polkit_config_default_excludes_high_risk_actions() {
let config = PolkitConfig::default();
// High-risk actions must NOT be face-eligible by default.
for action in [
"org.freedesktop.policykit.exec",
"org.freedesktop.packagekit.package-install",
"org.freedesktop.udisks2.filesystem-mount",
"org.freedesktop.accounts.user-administration",
] {
assert!(
!config.is_face_eligible(action),
"{action} must not be face-eligible by default"
);
}
}

#[test]
fn polkit_config_defaults_when_section_absent() {
let toml = r#"
[device]
path = "/dev/video0"
"#;
let config = Config::parse(toml).unwrap();
assert_eq!(
config.polkit.face_eligible_actions,
vec!["org.freedesktop.login1.lock-sessions".to_string()]
);
}

#[test]
fn polkit_config_allowlist_is_configurable() {
let toml = r#"
[device]
path = "/dev/video0"
[polkit]
face_eligible_actions = [
"org.freedesktop.login1.lock-sessions",
"org.freedesktop.udisks2.filesystem-mount",
]
"#;
let config = Config::parse(toml).unwrap();
assert!(
config
.polkit
.is_face_eligible("org.freedesktop.udisks2.filesystem-mount")
);
assert!(
config
.polkit
.is_face_eligible("org.freedesktop.login1.lock-sessions")
);
// Still excludes anything the user did not add.
assert!(
!config
.polkit
.is_face_eligible("org.freedesktop.policykit.exec")
);
}

#[test]
fn polkit_config_empty_allowlist_declines_everything() {
let toml = r#"
[device]
path = "/dev/video0"
[polkit]
face_eligible_actions = []
"#;
let config = Config::parse(toml).unwrap();
// An explicitly empty list means "never offer face" — no fail-open.
assert!(
!config
.polkit
.is_face_eligible("org.freedesktop.login1.lock-sessions")
);
}

#[test]
fn warmup_frames_zero() {
let toml = r#"
Expand Down
98 changes: 92 additions & 6 deletions crates/facelock-polkit/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ use zbus::connection::Builder;
use zbus::zvariant::Value;
use zbus::{Connection, fdo, interface};

use facelock_core::config::PolkitConfig;
use facelock_core::dbus_interface::{BUS_NAME, INTERFACE_NAME, OBJECT_PATH};

/// Polkit authentication agent that attempts face authentication via the
/// facelock daemon, falling back to declining (so another agent can handle
/// password auth).
struct PolkitAgent {
system_conn: Connection,
/// Actions eligible for face authentication. Any action not in this list
/// is declined so polkit falls through to the password dialog.
polkit_config: PolkitConfig,
/// Tracks in-flight authentication attempts by cookie.
/// Sending on the oneshot signals cancellation.
active_auths: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>,
Expand All @@ -32,6 +36,17 @@ impl PolkitAgent {
) -> fdo::Result<()> {
tracing::info!(action_id, message, "polkit auth request received");

// Scope face auth to an allowlist. A single face match must not
// authorize every polkit action. If this action is not eligible,
// decline (return Err) so polkit routes to a password agent —
// this is a fall-through, NOT a denial.
if !self.polkit_config.is_face_eligible(action_id) {
tracing::info!(action_id, "not eligible for face auth, declining");
return Err(fdo::Error::Failed(format!(
"action '{action_id}' not eligible for face authentication"
)));
}

let user = extract_username(&identities).unwrap_or_else(current_username);

// Set up cancellation channel for this auth attempt.
Expand Down Expand Up @@ -147,9 +162,10 @@ async fn respond_to_polkit(
.await
.context("failed to build polkit authority proxy")?;

let uid = nix::unistd::User::from_name(user)?
.map(|u| u.uid.as_raw())
.unwrap_or(0);
// Fail closed: an unresolvable username must NEVER be sent to polkit as
// UID 0. Resolve the name, then refuse if it does not map to a real uid.
let resolved = nix::unistd::User::from_name(user)?.map(|u| u.uid.as_raw());
let uid = uid_from_resolution(user, resolved)?;

let _: () = proxy
.call("AuthenticationAgentResponse2", &(uid, cookie))
Expand All @@ -159,6 +175,15 @@ async fn respond_to_polkit(
Ok(())
}

/// Map a resolved uid to a value to send to polkit, refusing when the name did
/// not resolve. Never substitutes UID 0 for an unresolved name (which would be
/// a fail-open to root).
fn uid_from_resolution(user: &str, resolved: Option<u32>) -> anyhow::Result<u32> {
resolved.ok_or_else(|| {
anyhow::anyhow!("cannot resolve user '{user}'; refusing to respond to polkit")
})
}

/// Register this process as a polkit authentication agent.
async fn register_agent(system_conn: &Connection) -> anyhow::Result<()> {
let authority = zbus::Proxy::new(
Expand Down Expand Up @@ -199,20 +224,50 @@ async fn main() -> anyhow::Result<()> {
.await
.context("failed to connect to system D-Bus")?;

// Load the face-eligible action allowlist from config. If config is
// missing or unreadable, fall back to the safe (restrictive) default —
// never to an open policy.
let polkit_config = match facelock_core::config::Config::load() {
Ok(config) => config.polkit,
Err(e) => {
tracing::warn!(error = %e, "could not load config; using default face-eligible action allowlist");
PolkitConfig::default()
}
};
tracing::info!(
actions = ?polkit_config.face_eligible_actions,
"face authentication scoped to allowlisted polkit actions"
);

let agent = PolkitAgent {
system_conn: system_conn.clone(),
polkit_config,
active_auths: Arc::new(Mutex::new(HashMap::new())),
};

// Test hook: skip polkit registration and claim a well-known session-bus
// name so the agent's D-Bus boundary can be exercised in a container
// without a live polkit authority. Never used in production.
let skip_register = std::env::var_os("FACELOCK_POLKIT_SKIP_REGISTER").is_some();

// Build a session bus connection that serves the agent interface.
let _session_conn = Builder::session()?
.serve_at("/org/facelock/PolkitAgent", agent)?
let mut builder = Builder::session()?.serve_at("/org/facelock/PolkitAgent", agent)?;
if skip_register {
builder = builder.name("org.facelock.PolkitAgent")?;
}
let _session_conn = builder
.build()
.await
.context("failed to build session D-Bus connection")?;

// Register with polkit on the system bus.
register_agent(&system_conn).await?;
if skip_register {
tracing::warn!(
"FACELOCK_POLKIT_SKIP_REGISTER set: not registering with polkit (test mode)"
);
} else {
register_agent(&system_conn).await?;
}

tracing::info!("facelock polkit agent running, waiting for auth requests");

Expand Down Expand Up @@ -258,4 +313,35 @@ mod tests {
let name = current_username();
assert!(!name.is_empty());
}

#[test]
fn uid_resolution_refuses_unresolved_name() {
// An unresolved username (from_name returned Ok(None)) must produce a
// refusal — never fall open to UID 0.
let err = uid_from_resolution("ghost", None).unwrap_err();
assert!(err.to_string().contains("cannot resolve user 'ghost'"));
}

#[test]
fn uid_resolution_never_emits_uid_zero_for_unresolved_name() {
// Explicitly assert the fail-open-to-root regression cannot recur:
// no unresolved name may map to uid 0.
assert!(uid_from_resolution("nonexistent-user", None).is_err());
}

#[test]
fn uid_resolution_passes_through_resolved_uid() {
// A genuinely resolved uid (including 0 for real root) is returned.
assert_eq!(uid_from_resolution("root", Some(0)).unwrap(), 0);
assert_eq!(uid_from_resolution("alice", Some(1000)).unwrap(), 1000);
}

#[test]
fn allowlist_gates_face_eligibility() {
// The agent offers face only for allowlisted actions; everything else
// is declined (falls through to password).
let config = PolkitConfig::default();
assert!(config.is_face_eligible("org.freedesktop.login1.lock-sessions"));
assert!(!config.is_face_eligible("org.freedesktop.policykit.exec"));
}
}
41 changes: 41 additions & 0 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ TOML format. All keys optional — camera auto-detected, sensible defaults for e
| `[encryption]` | `method` (none/keyfile/tpm), `key_path`, `sealed_key_path` |
| `[audit]` | `enabled`, `path`, `rotate_size_mb` |
| `[tpm]` | `pcr_binding`, `pcr_indices`, `tcti` |
| `[polkit]` | `face_eligible_actions` |

`[polkit].face_eligible_actions` is the allowlist of polkit `action_id`s for which
the face authentication agent may offer face auth. Default:
`["org.freedesktop.login1.lock-sessions"]`. Any action not in the list is declined
by the agent. An empty list disables face for all actions. High-risk actions
(pkexec, PackageKit, udisks mount, accounts-service) are excluded by default.

**NOTE (under review):** polkit registers a single authentication agent per
session and does not chain agents. When this agent declines a non-allowlisted
action it returns an error, which — depending on the desktop's agent
registration — may present as an authorization denial rather than a
fallthrough to a password dialog. The intended UX (non-eligible actions
handled by the desktop's normal password agent) is unverified pending
live-desktop testing and may require a design change. Behavior here is
fail-closed: a non-eligible action is never face-authorized.

### Camera Auto-Detection

Expand Down Expand Up @@ -162,6 +178,31 @@ PAM module never blocks indefinitely. All operations have timeouts.
pam_facelock(<service>): <result> for user <username>
```

## Polkit Agent Semantics

The `facelock-polkit-agent` offers face authentication for polkit actions, but
scoped to an allowlist — face is **not** a universal key for every privileged action.

| Outcome | Agent behavior |
|---------|----------------|
| `action_id` not in `polkit.face_eligible_actions` | Declines (returns `org.freedesktop.DBus.Error.Failed`) — see fallthrough-vs-denial caveat below |
| Allowlisted action, face matches | Responds success to polkit authority |
| Allowlisted action, no match / daemon error | Declines (same caveat) |
| Username cannot be resolved to a uid | Refuses to respond; **never** sends UID 0 for an unresolved name |

**NOTE (under review):** polkit registers a single authentication agent per
session and does not chain agents. When this agent declines, the decline
returns an error, which — depending on the desktop's agent registration — may
present as an authorization denial rather than a fallthrough to a password
dialog. The intended UX (non-eligible actions handled by the desktop's normal
password agent) is unverified pending live-desktop testing and may require a
design change. Behavior here is fail-closed: a non-eligible action is never
face-authorized.

A decline never fails open to root, and never causes this agent itself to grant
authorization it should not — but see the caveat above on whether polkit
treats a decline as a fall-through to another agent or as an outright denial.

## Anti-Spoofing

| Defense | Config | Default |
Expand Down
Loading
Loading