From f266a992b4d5f565aadac012c1e82af03e98a83c Mon Sep 17 00:00:00 2001 From: Zane Walker Date: Sat, 26 Sep 2026 02:44:29 +0530 Subject: [PATCH] feat: complete session preferences and PAM fixture gates --- CHANGELOG.md | 6 +- Dockerfile.test | 3 +- Makefile | 5 +- README.md | 5 +- crates/latchd-auth/tests/pam_fixture.rs | 58 ++++++ crates/latchd-core/src/config.rs | 13 ++ crates/latchd-core/src/lib.rs | 2 + crates/latchd-core/src/preferences.rs | 259 ++++++++++++++++++++++++ crates/latchd-core/src/session.rs | 4 +- crates/latchd-tui/src/lib.rs | 90 ++++++++ docs/e2e-testing.md | 4 + extra/config.toml | 2 + scripts/test-pam-fixture.sh | 27 +++ 13 files changed, 473 insertions(+), 5 deletions(-) create mode 100644 crates/latchd-auth/tests/pam_fixture.rs create mode 100644 crates/latchd-core/src/preferences.rs create mode 100755 scripts/test-pam-fixture.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index ddcf55a..10c34b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,12 @@ current-password/new-password prompt sequence. - Added redacted structured audit events for authentication, password changes, and session handoff. Raw PAM messages and password material are excluded. -- Added TUI and core policy-transition tests; the workspace now contains 33 +- Added TUI, core, and preference tests; the workspace now contains 37 Rust unit tests. +- Added bounded, atomic last-session preferences per account with owner-only + file permissions and a safe fallback when the state file is unavailable. +- Added disposable PAM deny-policy fault-injection tests. Docker runs them as + root; ordinary host runs skip them without touching the host PAM policy. ## 0.4.0 — Advanced login workflow diff --git a/Dockerfile.test b/Dockerfile.test index 4f543f7..7f68b82 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -21,4 +21,5 @@ RUN cargo fmt --all -- --check \ && RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps \ && cargo build --locked --release \ && ./scripts/test-cli.sh target/release/latchd \ - && ./scripts/test-system-e2e.sh target/release/latchd + && ./scripts/test-system-e2e.sh target/release/latchd \ + && ./scripts/test-pam-fixture.sh diff --git a/Makefile b/Makefile index 322d4bf..d19bc02 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ RED := endif .PHONY: all deps build release-build test check fmt lint doc audit docker-test \ - integration-test system-e2e system-e2e-strict \ + integration-test pam-fixture-test system-e2e system-e2e-strict \ package release release-check version install uninstall clean run-preview \ legacy-build legacy-check enable update help @@ -98,6 +98,9 @@ docker-test: ## Run the pinned Docker verification suite integration-test: build ## Exercise safe, non-authenticating CLI paths @./scripts/test-cli.sh '$(BUILD_DIR)/$(BINARY)' +pam-fixture-test: ## Run the disposable PAM fault-injection tests when privileged + @./scripts/test-pam-fixture.sh + system-e2e: ## Probe PAM, VT, X11, Wayland, and logind without requiring privileges @./scripts/test-system-e2e.sh '$(BUILD_DIR)/$(BINARY)' diff --git a/README.md b/README.md index 4a9c568..6091853 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Latchd is written in Rust. The old Go prototype is kept in `legacy/go/` for refe - Shows password, account-expiry, locked-account, and faillock status without replacing PAM's decision. - Runs PAM password changes for accounts that require a new password before login. - Lets you switch users and sessions without leaving the login screen. +- Remembers each account's last successful session choice in a root-owned state file. - Finds X11 and Wayland desktop entries and can include a TTY shell. - Starts sessions with a cleared, explicit environment and drops privileges before running the user process. - Records user sessions with UTMPX when the platform provides it. @@ -72,7 +73,8 @@ The complete example is in [`extra/config.toml`](extra/config.toml). Important s - `[accounts]` controls NSS discovery, the faillock helper, faillock policy, and `/etc/shadow` account-age checks. - `[x11]` and `[wayland]` select desktop-entry and administrator script directories. -- `[environment_switcher]` controls session choices and the optional TTY shell. +- `[environment_switcher]` controls session choices, the optional TTY shell, + and the owner-checked last-session preference file. - `[visual]` controls wallpaper size, animation, notifications, and reduced motion. - `[power_controls]` contains absolute program paths and argv arrays. Commands are never passed through a shell. @@ -107,6 +109,7 @@ make check make audit make docker-test make integration-test +make pam-fixture-test # runs when root is available; otherwise reports SKIP make system-e2e ``` diff --git a/crates/latchd-auth/tests/pam_fixture.rs b/crates/latchd-auth/tests/pam_fixture.rs new file mode 100644 index 0000000..956ba4b --- /dev/null +++ b/crates/latchd-auth/tests/pam_fixture.rs @@ -0,0 +1,58 @@ +use std::env; + +use latchd_auth::AuthService; +use latchd_core::error::Error; + +fn fixture_service() -> String { + env::var("LATCHD_PAM_FIXTURE_SERVICE") + .expect("LATCHD_PAM_FIXTURE_SERVICE must point at the disposable PAM fixture") +} + +fn assert_sanitized_pam_failure(result: Result<(), Error>, secret: &str) { + let error = result.expect_err("the fault-injection PAM fixture must reject the operation"); + match error { + Error::Pam { + status, message, .. + } => { + assert_ne!(status, 0); + assert!(!message.contains(secret)); + } + _ => panic!("fault-injection fixture returned a non-PAM error"), + } +} + +#[test] +#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"] +fn authentication_fault_is_rejected_without_secret_leakage() { + let service = AuthService::new().expect("PAM worker should start"); + let secret = "fixture-auth-secret"; + let result = service.authenticate("root".into(), secret.as_bytes().to_vec(), fixture_service()); + let error = match result { + Ok(_) => panic!("pam_deny fixture must reject authentication"), + Err(error) => error, + }; + match error { + Error::Pam { + status, message, .. + } => { + assert_ne!(status, 0); + assert!(!message.contains(secret)); + } + _ => panic!("fault-injection fixture returned a non-PAM error"), + } +} + +#[test] +#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"] +fn password_change_fault_is_rejected_without_secret_leakage() { + let service = AuthService::new().expect("PAM worker should start"); + assert_sanitized_pam_failure( + service.change_password( + "root".into(), + b"fixture-old-secret".to_vec(), + b"fixture-new-secret".to_vec(), + fixture_service(), + ), + "fixture-new-secret", + ); +} diff --git a/crates/latchd-core/src/config.rs b/crates/latchd-core/src/config.rs index e862bf0..8ad775d 100644 --- a/crates/latchd-core/src/config.rs +++ b/crates/latchd-core/src/config.rs @@ -181,6 +181,10 @@ pub struct SwitcherConfig { pub include_tty_shell: bool, /// Maximum visible title width. pub max_display_length: usize, + /// Restore each account's last successful session selection. + pub remember_last_session: bool, + /// Owner-checked file used for the last-session preferences. + pub last_session_path: PathBuf, } impl Default for SwitcherConfig { @@ -188,6 +192,8 @@ impl Default for SwitcherConfig { Self { include_tty_shell: false, max_display_length: 18, + remember_last_session: true, + last_session_path: PathBuf::from("/var/lib/latchd/last-session.toml"), } } } @@ -287,6 +293,13 @@ pub fn validate(config: &Config) -> Result<(), Error> { "environment_switcher.max_display_length must be greater than zero".into(), )); } + if config.environment_switcher.remember_last_session + && !config.environment_switcher.last_session_path.is_absolute() + { + return Err(Error::Config( + "environment_switcher.last_session_path must be absolute".into(), + )); + } for (name, path) in [ ("main_log_path", &config.main_log_path), ("client_log_path", &config.client_log_path), diff --git a/crates/latchd-core/src/lib.rs b/crates/latchd-core/src/lib.rs index 863fd11..0cc901d 100644 --- a/crates/latchd-core/src/lib.rs +++ b/crates/latchd-core/src/lib.rs @@ -13,5 +13,7 @@ pub mod audit; pub mod config; /// Structured application errors. pub mod error; +/// Safe persistence for per-account last-used session preferences. +pub mod preferences; /// Session discovery and process lifecycle. pub mod session; diff --git a/crates/latchd-core/src/preferences.rs b/crates/latchd-core/src/preferences.rs new file mode 100644 index 0000000..436fc10 --- /dev/null +++ b/crates/latchd-core/src/preferences.rs @@ -0,0 +1,259 @@ +//! Bounded, owner-checked persistence for last-used session choices. + +use std::{ + fs::{self, OpenOptions}, + io::Write, + os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + error::{Error, ResultExt, io_error}, + session::{SessionDescriptor, SessionKind}, +}; + +const MAX_ENTRIES: usize = 128; + +/// A previously successful session selection for one account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LastUsedEntry { + /// NSS username. + pub username: String, + /// Session family. + pub kind: SessionKind, + /// Stable discovered session title. + pub title: String, +} + +/// Bounded last-used session preferences. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LastUsedPreferences { + entries: Vec, +} + +impl LastUsedPreferences { + /// Records a successful selection, moving the newest entry to the front. + pub fn remember(&mut self, username: &str, session: &SessionDescriptor) { + if username.is_empty() || session.title.is_empty() { + return; + } + self.entries.retain(|entry| entry.username != username); + self.entries.insert( + 0, + LastUsedEntry { + username: username.to_owned(), + kind: session.kind, + title: session.title.clone(), + }, + ); + self.entries.truncate(MAX_ENTRIES); + } + + /// Finds the exact previously selected session for an account. + pub fn preferred_index(&self, username: &str, sessions: &[SessionDescriptor]) -> Option { + let entry = self + .entries + .iter() + .find(|entry| entry.username == username)?; + sessions + .iter() + .position(|session| session.kind == entry.kind && session.title == entry.title) + } + + /// Returns persisted entries for diagnostics and tests. + pub fn entries(&self) -> &[LastUsedEntry] { + &self.entries + } +} + +/// Loads preferences from an owner-checked TOML file. +pub fn load(path: &Path) -> Result { + if !path.is_absolute() { + return Err(Error::Config("last-session path must be absolute".into())); + } + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(LastUsedPreferences::default()); + } + Err(error) => return Err(io_error(format!("inspect {}", path.display()), error)), + }; + if !trusted_file(&metadata) { + return Err(Error::Config(format!( + "last-session file must be an owner-only regular file: {}", + path.display() + ))); + } + let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?; + let preferences = + toml::from_str::(&raw).map_err(|source| Error::Toml { + path: path.display().to_string(), + source, + })?; + validate(&preferences, path)?; + Ok(preferences) +} + +/// Atomically saves preferences with mode `0600` under a trusted directory. +pub fn save(path: &Path, preferences: &LastUsedPreferences) -> Result<(), Error> { + if !path.is_absolute() { + return Err(Error::Config("last-session path must be absolute".into())); + } + validate(preferences, path)?; + let parent = path.parent().ok_or_else(|| { + Error::Config(format!( + "last-session path has no parent: {}", + path.display() + )) + })?; + fs::create_dir_all(parent).io_context(format!("create {}", parent.display()))?; + let parent_metadata = + fs::symlink_metadata(parent).io_context(format!("inspect {}", parent.display()))?; + if !trusted_directory(&parent_metadata) { + return Err(Error::Config(format!( + "last-session directory must be owner-owned and non-writable: {}", + parent.display() + ))); + } + let content = toml::to_string_pretty(preferences) + .map_err(|error| Error::Config(format!("serialize last-session preferences: {error}")))?; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let temporary = parent.join(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("last-session"), + std::process::id(), + nonce + )); + let write_result = (|| -> Result<(), Error> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&temporary) + .io_context(format!("create {}", temporary.display()))?; + file.write_all(content.as_bytes()) + .io_context(format!("write {}", temporary.display()))?; + file.sync_all() + .io_context(format!("sync {}", temporary.display()))?; + fs::rename(&temporary, path).io_context(format!("replace {}", path.display()))?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .io_context(format!("secure {}", path.display()))?; + Ok(()) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&temporary); + } + write_result +} + +fn validate(preferences: &LastUsedPreferences, path: &Path) -> Result<(), Error> { + if preferences.entries.len() > MAX_ENTRIES { + return Err(Error::Config(format!( + "last-session file contains more than {MAX_ENTRIES} entries: {}", + path.display() + ))); + } + if preferences + .entries + .iter() + .any(|entry| entry.username.is_empty() || entry.title.is_empty()) + { + return Err(Error::Config(format!( + "last-session entries must have a username and title: {}", + path.display() + ))); + } + Ok(()) +} + +fn trusted_file(metadata: &std::fs::Metadata) -> bool { + metadata.is_file() && metadata.uid() == effective_uid() && metadata.mode() & 0o077 == 0 +} + +fn trusted_directory(metadata: &std::fs::Metadata) -> bool { + metadata.is_dir() && metadata.uid() == effective_uid() && metadata.mode() & 0o022 == 0 +} + +fn effective_uid() -> u32 { + // SAFETY: geteuid has no pointer arguments or memory preconditions. + unsafe { libc::geteuid() } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn descriptor(kind: SessionKind, title: &str) -> SessionDescriptor { + SessionDescriptor { + kind, + title: title.into(), + argv: Vec::new(), + current_desktop: None, + session_desktop: None, + } + } + + fn test_path() -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "latchd-preferences-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()) + )) + } + + #[test] + fn latest_successful_session_is_preferred_per_account() { + let wayland = descriptor(SessionKind::Wayland, "Sway"); + let tty = descriptor(SessionKind::Tty, "TTY shell"); + let mut preferences = LastUsedPreferences::default(); + preferences.remember("alice", &wayland); + assert_eq!( + preferences.preferred_index("alice", &[tty.clone(), wayland]), + Some(1) + ); + preferences.remember("alice", &tty); + assert_eq!( + preferences.preferred_index("alice", &[tty, descriptor(SessionKind::Wayland, "Sway")]), + Some(0) + ); + assert_eq!(preferences.entries()[0].username, "alice"); + } + + #[test] + fn preferences_round_trip_atomically_with_private_permissions() { + let directory = test_path(); + let path = directory.join("last-session.toml"); + let session = descriptor(SessionKind::X11, "GNOME"); + let mut preferences = LastUsedPreferences::default(); + preferences.remember("alice", &session); + save(&path, &preferences).expect("preference save should succeed"); + let loaded = load(&path).expect("preference load should succeed"); + assert_eq!(loaded, preferences); + let mode = fs::symlink_metadata(&path) + .expect("preference metadata should exist") + .mode(); + assert_eq!(mode & 0o077, 0); + let _ = fs::remove_dir_all(directory); + } + + #[test] + fn missing_preferences_are_a_safe_empty_store() { + let path = test_path().join("missing.toml"); + assert_eq!( + load(&path).expect("missing preferences are optional"), + LastUsedPreferences::default() + ); + } +} diff --git a/crates/latchd-core/src/session.rs b/crates/latchd-core/src/session.rs index eee9f6a..98199d7 100644 --- a/crates/latchd-core/src/session.rs +++ b/crates/latchd-core/src/session.rs @@ -26,10 +26,12 @@ use crate::{ error::{Error, ResultExt, io_error}, }; use rand::RngCore; +use serde::{Deserialize, Serialize}; use signal_hook::{consts::SIGUSR1, flag}; /// A session family supported by latchd. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum SessionKind { /// A login shell on the current virtual terminal. Tty, diff --git a/crates/latchd-tui/src/lib.rs b/crates/latchd-tui/src/lib.rs index 9840a7f..59207de 100644 --- a/crates/latchd-tui/src/lib.rs +++ b/crates/latchd-tui/src/lib.rs @@ -21,6 +21,7 @@ use latchd_core::{ audit::{AuditAction, AuditEvent}, config::Config, error::{Error, ResultExt}, + preferences::{self, LastUsedPreferences}, session::{self, SessionDescriptor}, }; @@ -130,6 +131,8 @@ struct App { refresh_receiver: Option>, refreshing: bool, high_contrast: bool, + preferences: LastUsedPreferences, + preference_path: Option, } impl App { @@ -181,6 +184,8 @@ impl App { refresh_receiver: None, refreshing: false, high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(), + preferences: LastUsedPreferences::default(), + preference_path: None, } } @@ -258,6 +263,7 @@ impl App { .get(self.selected) .cloned() .ok_or_else(|| Error::Session("no session selected".into()))?; + self.remember_selected_session(&selected); Ok(Some(LoginSelection { ticket, session: selected, @@ -334,6 +340,25 @@ impl App { ); } + fn apply_preferred_session(&mut self) { + if let Some(index) = self + .preferences + .preferred_index(&self.username, &self.sessions) + { + self.selected = index; + } + } + + fn remember_selected_session(&mut self, session: &SessionDescriptor) { + self.preferences.remember(&self.username, session); + let Some(path) = self.preference_path.clone() else { + return; + }; + if let Err(error) = preferences::save(&path, &self.preferences) { + tracing::debug!(%error, "could not persist last-session preference"); + } + } + fn poll_faillock(&mut self) { let Some(receiver) = &self.faillock_receiver else { return; @@ -995,6 +1020,7 @@ impl App { self.auth_blocked_until = None; self.faillock_status = None; self.status = format!("Selected account {}", self.username); + self.apply_preferred_session(); self.start_faillock_query(); self.notify( &format!("Selected account {}", self.username), @@ -1368,9 +1394,25 @@ pub fn run( (Vec::new(), Vec::new()) }; let background = Background::load(&config.visual)?; + let preference_path = config + .environment_switcher + .remember_last_session + .then(|| config.environment_switcher.last_session_path.clone()); + let preferences = + preference_path + .as_deref() + .map_or_else(LastUsedPreferences::default, |path| { + preferences::load(path).unwrap_or_else(|error| { + tracing::debug!(%error, "could not load last-session preferences"); + LastUsedPreferences::default() + }) + }); let mut app = App::new(config, auth, preview, sessions, accounts, background); app.account_statuses = account_statuses; app.account_index = (!app.accounts.is_empty()).then_some(0); + app.preferences = preferences; + app.preference_path = preference_path; + app.apply_preferred_session(); app.start_faillock_query(); ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string())) } @@ -1654,6 +1696,54 @@ mod tests { assert!(rendered.contains("New password")); } + #[test] + fn last_session_preference_follows_account_and_persists_success() { + let auth = AuthService::new().expect("PAM worker should start"); + let sessions = vec![ + SessionDescriptor { + kind: session::SessionKind::Tty, + title: "TTY shell".into(), + argv: Vec::new(), + current_desktop: None, + session_desktop: None, + }, + SessionDescriptor { + kind: session::SessionKind::Wayland, + title: "Sway".into(), + argv: vec!["sway".into()], + current_desktop: None, + session_desktop: None, + }, + ]; + let mut app = App::new( + Config::default(), + auth, + true, + sessions, + vec![Identity { + username: "alice".into(), + uid: 1000, + gid: 1000, + home: "/home/alice".into(), + shell: "/bin/sh".into(), + }], + Background::load(&Config::default().visual).expect("default background should load"), + ); + app.preferences.remember("alice", &app.sessions[1]); + app.apply_preferred_session(); + assert_eq!(app.selected, 1); + + let directory = + std::env::temp_dir().join(format!("latchd-tui-preferences-{}", std::process::id())); + app.preference_path = Some(directory.join("last-session.toml")); + let selected = app.sessions[1].clone(); + app.remember_selected_session(&selected); + let loaded = preferences::load(&directory.join("last-session.toml")) + .expect("selected session should persist"); + assert_eq!(loaded.preferred_index("alice", &app.sessions), Some(1)); + let _ = std::fs::remove_dir_all(directory); + } + #[test] fn filtering_is_smart_case_and_selects_a_matching_session() { let auth = AuthService::new().expect("PAM worker should start"); diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 52e5051..1eb7bbe 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -5,6 +5,9 @@ Latchd has two test layers: * `make integration-test` builds the binary and exercises metadata, TOML validation, preflight, and the non-authenticating preview path. It does not need PAM credentials, root, a VT, X11, or Wayland. +* `make pam-fixture-test` installs a unique temporary `pam_deny` service only + when run as root, executes the ignored authentication/password-change fault + tests, and removes the service on exit. Non-root runs report `SKIP`. * `make system-e2e` probes the host for PAM, faillock, VT, X11, Wayland, and systemd-logind capabilities. Missing capabilities are reported as `SKIP`, never as a successful test. Use `make system-e2e-strict` only in a prepared @@ -22,6 +25,7 @@ For a local release-style run: ```sh make integration-test +make pam-fixture-test make docker-test make system-e2e ``` diff --git a/extra/config.toml b/extra/config.toml index 3212ffb..0dd7132 100644 --- a/extra/config.toml +++ b/extra/config.toml @@ -23,6 +23,8 @@ scripts_path = "/etc/latchd/wayland" [environment_switcher] include_tty_shell = false max_display_length = 18 +remember_last_session = true +last_session_path = "/var/lib/latchd/last-session.toml" # Power actions use an executable path and argv, never a shell command string. # Keep this section empty unless the installation explicitly wants console diff --git a/scripts/test-pam-fixture.sh b/scripts/test-pam-fixture.sh new file mode 100755 index 0000000..1f568c8 --- /dev/null +++ b/scripts/test-pam-fixture.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Run ignored PAM fault-injection tests against a disposable service policy. +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "pam-fixture: SKIP (root is required to install a temporary PAM policy)" + exit 0 +fi + +service="latchd-fixture-$$" +policy="/etc/pam.d/$service" +cleanup() { + rm -f "$policy" +} +trap cleanup EXIT HUP INT TERM + +umask 077 +cat >"$policy" <<'PAM' +# Disposable deny policy used only by the ignored fixture tests. +auth required pam_deny.so +account required pam_permit.so +password required pam_deny.so +session required pam_permit.so +PAM + +LATCHD_PAM_FIXTURE_SERVICE="$service" \ + cargo test --locked -p latchd-auth --test pam_fixture -- --ignored