Skip to content

Commit a8e5a78

Browse files
tyvsmithclaude
andcommitted
feat(setup): detect display managers and screen lockers; offer per-service opt-in
Replaces the hardcoded [sudo, polkit-1, hyprlock] list with a PAM_CANDIDATES table that is filtered at runtime by /etc/pam.d/<service> existence, then presented to the user one service at a time via dialoguer::Confirm. New services covered: - swaylock (Sway/wlroots screen lock) — default YES - kscreenlocker_greet (KDE Plasma screen lock) — default YES - gdm-password (GNOME display manager login screen) — default NO - sddm (KDE display manager login screen) — default NO - lightdm (Ubuntu/Xfce/Mint display manager login screen) — default NO Default-YES rationale: screen lockers have safe password fallback; worst case is an extra Enter keypress. Default-NO rationale: display manager login screens lock users out of the system if face auth fails and they have no other recovery path; opt-in should be explicit. Explicit non-targets (never offered, even if /etc/pam.d/<name> exists): - system-auth / common-auth: shared stacks that would spread face auth to passwd, su, chsh, chfn, etc. - login: TTY login; camera may not be initialized at boot. - su, passwd, chsh, chfn: credential/privilege-change tools that must require a real password. Wizard flow change: replaces MultiSelect-all-at-once with individual Confirm prompts so the user sees the default recommendation per service. Non-interactive mode uses each candidate's default_enabled value. Adds two unit tests: - detect_candidates_filters_by_presence: verifies candidates_in() against a TempDir; only services with a matching file are returned. - no_excluded_services_in_candidates: asserts the excluded service list never appears in PAM_CANDIDATES. Adds tempfile as a dev-dependency of facelock-cli. Note: PR #1 (uninstall cleanup) and PR #2 (PAM confirmation prompt) are racing. This PR may have minor conflicts with #2 if both touch the per-service iteration; resolve at merge time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 67308e3 commit a8e5a78

3 files changed

Lines changed: 155 additions & 37 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/facelock-cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ tss-esapi = { workspace = true, optional = true }
4242
zeroize = "1"
4343
bytemuck = "1"
4444

45+
[dev-dependencies]
46+
tempfile = "3"
47+
4548
[features]
4649
default = ["wayland"]
4750
wayland = ["dep:smithay-client-toolkit", "dep:wayland-client", "dep:xkbcommon"]

crates/facelock-cli/src/commands/setup.rs

Lines changed: 151 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::path::Path;
44
use std::process::Command;
55

66
use anyhow::{Context, bail};
7-
use dialoguer::{Confirm, MultiSelect, Select, theme::ColorfulTheme};
7+
use dialoguer::{Confirm, Select, theme::ColorfulTheme};
88
use indicatif::{ProgressBar, ProgressStyle};
99
use sha2::{Digest, Sha256};
1010

@@ -926,58 +926,46 @@ fn wizard_systemd_setup(theme: &ColorfulTheme) -> anyhow::Result<bool> {
926926
Ok(true)
927927
}
928928

929-
fn wizard_pam_setup(theme: &ColorfulTheme) -> anyhow::Result<Vec<String>> {
929+
fn wizard_pam_setup(_theme: &ColorfulTheme) -> anyhow::Result<Vec<String>> {
930930
if !Path::new(PAM_MODULE_PATH).exists() {
931931
println!(" PAM module not found at {PAM_MODULE_PATH}.");
932932
println!(" Install it first, then run: sudo facelock setup --pam");
933933
return Ok(Vec::new());
934934
}
935935

936-
let available_services = ["sudo", "polkit-1", "hyprlock"];
937-
let service_descriptions = [
938-
"sudo - authenticate sudo commands with face recognition",
939-
"polkit-1 - authenticate graphical privilege prompts with face recognition",
940-
"hyprlock - authenticate lock screen with face recognition",
941-
];
936+
let candidates = detect_available_pam_candidates();
942937

943-
// Filter to services that actually exist on the system
944-
let mut valid_services: Vec<&str> = Vec::new();
945-
let mut valid_descriptions: Vec<&str> = Vec::new();
946-
for (svc, desc) in available_services.iter().zip(service_descriptions.iter()) {
947-
let pam_path = format!("/etc/pam.d/{svc}");
948-
if Path::new(&pam_path).exists() {
949-
valid_services.push(svc);
950-
valid_descriptions.push(desc);
951-
}
952-
}
953-
954-
if valid_services.is_empty() {
938+
if candidates.is_empty() {
955939
println!(" No supported PAM service files found in /etc/pam.d/.");
956940
return Ok(Vec::new());
957941
}
958942

959-
let selections = MultiSelect::with_theme(theme)
960-
.with_prompt("Select PAM services to configure (space to toggle, enter to confirm)")
961-
.items(&valid_descriptions)
962-
.interact()?;
963-
964-
if selections.is_empty() {
965-
println!(" No PAM services selected.");
966-
return Ok(Vec::new());
967-
}
968-
969943
let mut configured = Vec::new();
970-
for idx in selections {
971-
let service = valid_services[idx];
972-
println!(" Configuring PAM for {service}...");
973-
match pam_install(service, true) {
974-
Ok(()) => configured.push(service.to_string()),
975-
Err(e) => {
976-
println!(" Failed to configure {service}: {e}");
944+
for candidate in candidates {
945+
let prompt = format!("Enable face auth for {}?", candidate.description);
946+
let should_enable = if !is_interactive() {
947+
candidate.default_enabled
948+
} else {
949+
Confirm::new()
950+
.with_prompt(&prompt)
951+
.default(candidate.default_enabled)
952+
.interact()?
953+
};
954+
if should_enable {
955+
println!(" Configuring PAM for {}...", candidate.service);
956+
match pam_install(candidate.service, true) {
957+
Ok(()) => configured.push(candidate.service.to_string()),
958+
Err(e) => {
959+
println!(" Failed to configure {}: {e}", candidate.service);
960+
}
977961
}
978962
}
979963
}
980964

965+
if configured.is_empty() {
966+
println!(" No PAM services selected.");
967+
}
968+
981969
Ok(configured)
982970
}
983971

@@ -1386,6 +1374,97 @@ fn download_model(entry: &ModelEntry, dest: &Path) -> anyhow::Result<()> {
13861374
Ok(())
13871375
}
13881376

1377+
// --- PAM candidate detection ---
1378+
1379+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1380+
enum PamCategory {
1381+
PrivilegeEscalation, // sudo, polkit-1
1382+
LockScreen, // hyprlock, swaylock, kscreenlocker_greet
1383+
DisplayManager, // gdm-password, sddm, lightdm
1384+
}
1385+
1386+
#[derive(Clone, Debug)]
1387+
struct PamCandidate {
1388+
service: &'static str,
1389+
#[allow(dead_code)] // retained for documentation and future category-based filtering
1390+
category: PamCategory,
1391+
description: &'static str,
1392+
default_enabled: bool,
1393+
}
1394+
1395+
// Intentionally excluded from PAM_CANDIDATES:
1396+
// - system-auth (Arch) and common-auth (Debian): shared stacks included by many
1397+
// services. Editing them gives face auth to passwd, su, chsh, etc. — risky.
1398+
// - login: TTY login. Cameras often aren't initialized at boot.
1399+
// - su, passwd, chsh, chfn: privilege/credential-change tools that should
1400+
// require a real password.
1401+
// - any unknown service: detection is by /etc/pam.d/<name> existence, but only
1402+
// services in PAM_CANDIDATES are offered.
1403+
const PAM_CANDIDATES: &[PamCandidate] = &[
1404+
PamCandidate {
1405+
service: "sudo",
1406+
category: PamCategory::PrivilegeEscalation,
1407+
description: "sudo (privilege escalation)",
1408+
default_enabled: true,
1409+
},
1410+
PamCandidate {
1411+
service: "polkit-1",
1412+
category: PamCategory::PrivilegeEscalation,
1413+
description: "polkit-1 (GUI privilege escalation prompts)",
1414+
default_enabled: true,
1415+
},
1416+
PamCandidate {
1417+
service: "hyprlock",
1418+
category: PamCategory::LockScreen,
1419+
description: "hyprlock (Hyprland screen lock)",
1420+
default_enabled: true,
1421+
},
1422+
PamCandidate {
1423+
service: "swaylock",
1424+
category: PamCategory::LockScreen,
1425+
description: "swaylock (Sway screen lock)",
1426+
default_enabled: true,
1427+
},
1428+
PamCandidate {
1429+
service: "kscreenlocker_greet",
1430+
category: PamCategory::LockScreen,
1431+
description: "KDE Plasma screen lock",
1432+
default_enabled: true,
1433+
},
1434+
PamCandidate {
1435+
service: "gdm-password",
1436+
category: PamCategory::DisplayManager,
1437+
description: "GDM login screen (GNOME) \u{2014} declining recommended unless you have recovery access",
1438+
default_enabled: false,
1439+
},
1440+
PamCandidate {
1441+
service: "sddm",
1442+
category: PamCategory::DisplayManager,
1443+
description: "SDDM login screen (KDE) \u{2014} declining recommended unless you have recovery access",
1444+
default_enabled: false,
1445+
},
1446+
PamCandidate {
1447+
service: "lightdm",
1448+
category: PamCategory::DisplayManager,
1449+
description: "LightDM login screen (Ubuntu/Xfce/Mint) \u{2014} declining recommended unless you have recovery access",
1450+
default_enabled: false,
1451+
},
1452+
];
1453+
1454+
/// Returns the subset of PAM_CANDIDATES whose `/etc/pam.d/<service>` file exists,
1455+
/// delegating path construction to `candidates_in` for testability.
1456+
fn detect_available_pam_candidates() -> Vec<&'static PamCandidate> {
1457+
candidates_in(Path::new("/etc/pam.d"))
1458+
}
1459+
1460+
/// Returns candidates from `PAM_CANDIDATES` whose service file exists under `base`.
1461+
fn candidates_in(base: &Path) -> Vec<&'static PamCandidate> {
1462+
PAM_CANDIDATES
1463+
.iter()
1464+
.filter(|c| base.join(c.service).exists())
1465+
.collect()
1466+
}
1467+
13891468
// --- PAM installation ---
13901469

13911470
const PAM_LINE: &str = "auth sufficient pam_facelock.so";
@@ -1815,6 +1894,41 @@ account include system-login
18151894
assert!(!SENSITIVE_SERVICES.contains(&"sudo"));
18161895
}
18171896

1897+
#[test]
1898+
fn detect_candidates_filters_by_presence() {
1899+
let tmp = tempfile::TempDir::new().unwrap();
1900+
std::fs::write(tmp.path().join("sudo"), "").unwrap();
1901+
std::fs::write(tmp.path().join("hyprlock"), "").unwrap();
1902+
let found: Vec<_> = candidates_in(tmp.path())
1903+
.iter()
1904+
.map(|c| c.service)
1905+
.collect();
1906+
assert!(found.contains(&"sudo"));
1907+
assert!(found.contains(&"hyprlock"));
1908+
assert!(!found.contains(&"sddm"));
1909+
assert!(!found.contains(&"gdm-password"));
1910+
assert!(!found.contains(&"swaylock"));
1911+
}
1912+
1913+
#[test]
1914+
fn no_excluded_services_in_candidates() {
1915+
let excluded = [
1916+
"system-auth",
1917+
"common-auth",
1918+
"login",
1919+
"su",
1920+
"passwd",
1921+
"chsh",
1922+
"chfn",
1923+
];
1924+
for ex in excluded {
1925+
assert!(
1926+
!PAM_CANDIDATES.iter().any(|c| c.service == ex),
1927+
"{ex} must not appear in PAM_CANDIDATES"
1928+
);
1929+
}
1930+
}
1931+
18181932
#[test]
18191933
fn check_model_correct_sha256() {
18201934
let dir = std::env::temp_dir().join("facelock_cli_test_sha");

0 commit comments

Comments
 (0)