diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bfcbca..ddcf55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.5.0 — PAM password policy flow + +- Added PAM password-change handling for accounts that return + `PAM_NEW_AUTHTOK_REQD` or `PAM_AUTHTOK_EXPIRED` during login. +- Added an explicit TUI new-password state. PAM still enforces password + history, strength, and token policy; the login screen never implements its + own password rules. +- Added zeroizing old/new password buffers and conversation tests for the + 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 + Rust unit tests. + ## 0.4.0 — Advanced login workflow - Added account policy inspection for locked passwords, password expiry, diff --git a/Cargo.lock b/Cargo.lock index ff6f0c4..adb0081 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -782,7 +782,7 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" [[package]] name = "latchd" -version = "0.4.0" +version = "0.5.0" dependencies = [ "clap", "color-eyre", @@ -796,7 +796,7 @@ dependencies = [ [[package]] name = "latchd-auth" -version = "0.4.0" +version = "0.5.0" dependencies = [ "latchd-core", "libc", @@ -805,7 +805,7 @@ dependencies = [ [[package]] name = "latchd-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "libc", "rand 0.9.5", @@ -818,7 +818,7 @@ dependencies = [ [[package]] name = "latchd-tui" -version = "0.4.0" +version = "0.5.0" dependencies = [ "crossterm", "image", diff --git a/Cargo.toml b/Cargo.toml index 9883fa6..e469540 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "crates/latchd-core", "crates/latchd-auth", "crates/latchd-tui"] resolver = "3" [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2024" rust-version = "1.88" license = "Apache-2.0" diff --git a/README.md b/README.md index 42d2cbc..4a9c568 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Latchd is written in Rust. The old Go prototype is kept in `legacy/go/` for refe - Finds normal login accounts through NSS. - 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. - 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. @@ -89,7 +90,7 @@ Configuration files used by a real root service must be root-owned and not writa | Ctrl-N / Ctrl-P | Select the next or previous account | | Ctrl-B | Toggle wallpaper and ambient effects | | Ctrl-M | Toggle reduced motion | -| Enter | Authenticate from the password field | +| Enter | Authenticate, or submit a PAM password change when requested | | Esc | Clear the password or close a popup | | `?` | Show keyboard help | | Ctrl-C | Restore the terminal and quit | @@ -115,6 +116,8 @@ make system-e2e - PAM handles stay on one worker thread. - Password buffers are zeroized and never placed in argv, logs, or the TUI. +- Security audit events contain only the account name and stable outcome/reason + fields; PAM's free-form error text and password material are not logged. - Session environments are rebuilt from an allowlist. - Desktop files, scripts, binaries, homes, logs, and configuration sources are checked before use. - Xauthority files and session logs use private permissions and cleanup guards. @@ -125,7 +128,7 @@ make system-e2e ```text crates/latchd-core/ configuration, account discovery, sessions, UTMPX -crates/latchd-auth/ PAM FFI and the authentication worker +crates/latchd-auth/ PAM FFI, password changes, and the authentication worker crates/latchd-tui/ Ratatui state, rendering, filtering, and effects rust/main.rs command-line entry point and service orchestration extra/ example configuration, PAM policy, and systemd unit diff --git a/crates/latchd-auth/src/lib.rs b/crates/latchd-auth/src/lib.rs index ace9c98..68ea0e4 100644 --- a/crates/latchd-auth/src/lib.rs +++ b/crates/latchd-auth/src/lib.rs @@ -27,6 +27,7 @@ const PAM_SYSTEM_ERR: c_int = 4; const PAM_BUF_ERR: c_int = 5; const PAM_CONV_ERR: c_int = 19; const PAM_AUTHINFO_UNAVAIL: c_int = 9; +const PAM_CHANGE_EXPIRED_AUTHTOK: c_int = 0x0020; const PAM_PROMPT_ECHO_OFF: c_int = 1; const PAM_PROMPT_ECHO_ON: c_int = 2; @@ -76,6 +77,8 @@ unsafe extern "C" { ) -> c_int; #[link_name = "pam_authenticate"] fn pam_authenticate(pamh: *mut PamHandle, flags: c_int) -> c_int; + #[link_name = "pam_chauthtok"] + fn pam_chauthtok(pamh: *mut PamHandle, flags: c_int) -> c_int; #[link_name = "pam_acct_mgmt"] fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int; #[link_name = "pam_setcred"] @@ -119,6 +122,13 @@ enum Command { service: String, response: std::sync::mpsc::SyncSender>, }, + ChangePassword { + username: String, + current_password: Zeroizing>, + new_password: Zeroizing>, + service: String, + response: std::sync::mpsc::SyncSender>, + }, Run { id: u64, spec: SessionSpec, @@ -170,6 +180,33 @@ impl AuthService { Ok(ticket) } + /// Changes an expired account password through the configured PAM policy. + /// + /// PAM owns password-strength, history, and token validation. The current + /// and replacement passwords remain zeroizing buffers for the entire + /// worker transaction and are never returned in an error. + pub fn change_password( + &self, + username: String, + current_password: Vec, + new_password: Vec, + service: String, + ) -> Result<(), Error> { + let (response, result) = sync_channel(0); + self.commands + .send(Command::ChangePassword { + username, + current_password: Zeroizing::new(current_password), + new_password: Zeroizing::new(new_password), + service, + response, + }) + .map_err(|_| Error::Worker("PAM worker stopped".into()))?; + result + .recv() + .map_err(|_| Error::Worker("PAM worker disconnected".into()))? + } + /// Runs the authenticated session and closes its PAM transaction. pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> { let (response, result) = sync_channel(0); @@ -196,6 +233,8 @@ impl AuthService { struct ConversationData { username: CString, password: Zeroizing>, + new_password: Option>>, + new_password_prompts: usize, } impl Drop for ConversationData { @@ -261,6 +300,16 @@ fn worker(requests: Receiver) { authenticate(&username, password, &service, &mut next_id, &mut sessions); let _ = response.send(result); } + Command::ChangePassword { + username, + current_password, + new_password, + service, + response, + } => { + let result = change_password(&username, current_password, new_password, &service); + let _ = response.send(result); + } Command::Run { id, spec, response } => { let result = match sessions.remove(&id) { Some(mut session) => { @@ -312,6 +361,8 @@ fn authenticate( let conversation = Box::new(ConversationData { username: username_c.clone(), password, + new_password: None, + new_password_prompts: 0, }); let data = Box::into_raw(conversation); let conversation_ptr = PamConv { @@ -398,6 +449,71 @@ fn authenticate( }) } +fn change_password( + username: &str, + current_password: Zeroizing>, + new_password: Zeroizing>, + service: &str, +) -> Result<(), Error> { + let service = + CString::new(service).map_err(|_| Error::Config("PAM service contains NUL".into()))?; + let username_c = + CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?; + let conversation = Box::new(ConversationData { + username: username_c.clone(), + password: current_password, + new_password: Some(new_password), + new_password_prompts: 0, + }); + let data = Box::into_raw(conversation); + let conversation_ptr = PamConv { + conv: Some(converse), + appdata_ptr: data.cast(), + }; + let mut handle = ptr::null_mut(); + let started = unsafe { + pam_start( + service.as_ptr(), + username_c.as_ptr(), + &conversation_ptr, + &mut handle, + ) + }; + let conversation = unsafe { Box::from_raw(data) }; + if started != PAM_SUCCESS { + let error = pam_error(handle, "pam_start", started); + if !handle.is_null() { + let _ = unsafe { pam_end(handle, started) }; + } + drop(conversation); + return Err(error); + } + if handle.is_null() { + drop(conversation); + return Err(Error::Pam { + operation: "pam_start", + status: PAM_SYSTEM_ERR, + message: "returned a null handle".into(), + }); + } + + let mut session = PamSession { + handle, + conversation, + opened: false, + credentials_established: false, + identity: None, + }; + let changed = unsafe { pam_chauthtok(session.handle, PAM_CHANGE_EXPIRED_AUTHTOK) }; + let error = + (changed != PAM_SUCCESS).then(|| pam_error(session.handle, "pam_chauthtok", changed)); + let close = session.close(changed); + if let Some(error) = error { + return Err(error); + } + close +} + fn lookup_identity(username: &str) -> Result { let name = CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?; let passwd = unsafe { libc::getpwnam(name.as_ptr()) }; @@ -456,7 +572,7 @@ unsafe extern "C" fn converse( if output.is_null() { return PAM_BUF_ERR; } - let context = &*(data.cast::()); + let context = &mut *(data.cast::()); for index in 0..count { let message = *messages.add(index); if message.is_null() { @@ -464,7 +580,18 @@ unsafe extern "C" fn converse( return PAM_CONV_ERR; } let value: Option<&[u8]> = match (*message).msg_style { - PAM_PROMPT_ECHO_OFF => Some(context.password.as_slice()), + PAM_PROMPT_ECHO_OFF => { + let prompt = context.new_password_prompts; + context.new_password_prompts = prompt.saturating_add(1); + if prompt == 0 { + Some(context.password.as_slice()) + } else { + match context.new_password.as_ref() { + Some(password) => Some(password.as_slice()), + None => Some(context.password.as_slice()), + } + } + } PAM_PROMPT_ECHO_ON => Some(context.username.as_bytes()), PAM_ERROR_MSG | PAM_TEXT_INFO => None, _ => { @@ -524,6 +651,8 @@ mod tests { let context = Box::new(ConversationData { username: CString::new("alice").unwrap(), password: Zeroizing::new(b"secret".to_vec()), + new_password: None, + new_password_prompts: 0, }); let pointer = Box::into_raw(context); let message = PamMessage { @@ -545,4 +674,36 @@ mod tests { drop(Box::from_raw(pointer)); } } + + #[test] + fn password_change_conversation_uses_old_then_new_password() { + let context = Box::new(ConversationData { + username: CString::new("alice").unwrap(), + password: Zeroizing::new(b"old-secret".to_vec()), + new_password: Some(Zeroizing::new(b"new-secret".to_vec())), + new_password_prompts: 0, + }); + let pointer = Box::into_raw(context); + let message = PamMessage { + msg_style: PAM_PROMPT_ECHO_OFF, + msg: ptr::null(), + }; + let mut message_pointer = &message as *const PamMessage; + for expected in [b"old-secret".as_slice(), b"new-secret".as_slice()] { + let mut responses = ptr::null_mut(); + let status = + unsafe { converse(1, &mut message_pointer, &mut responses, pointer.cast()) }; + assert_eq!(status, PAM_SUCCESS); + let response = unsafe { &*responses }; + assert_eq!( + unsafe { CStr::from_ptr(response.resp) }.to_bytes(), + expected + ); + unsafe { + libc::free(response.resp.cast()); + libc::free(responses.cast()); + } + } + unsafe { drop(Box::from_raw(pointer)) }; + } } diff --git a/crates/latchd-core/src/audit.rs b/crates/latchd-core/src/audit.rs new file mode 100644 index 0000000..6b1f0b1 --- /dev/null +++ b/crates/latchd-core/src/audit.rs @@ -0,0 +1,180 @@ +//! Redacted security audit events. +//! +//! Audit records deliberately contain no password, token, PAM message, or +//! command-line detail. Callers can send the fields to a structured logger +//! without accidentally turning an authentication failure into a secret dump. + +use std::fmt; + +use crate::error::Error; + +/// The security operation that produced an audit event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuditAction { + /// A PAM authentication attempt. + Authentication, + /// A PAM password-change transaction. + PasswordChange, + /// A user session handoff. + Session, +} + +impl fmt::Display for AuditAction { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Authentication => "authentication", + Self::PasswordChange => "password_change", + Self::Session => "session", + }) + } +} + +/// The outcome of a security operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuditOutcome { + /// The operation was requested. + Started, + /// The operation completed successfully. + Succeeded, + /// The operation was rejected or failed. + Failed, + /// PAM requires a password change before proceeding. + PasswordChangeRequired, +} + +impl fmt::Display for AuditOutcome { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Started => "started", + Self::Succeeded => "succeeded", + Self::Failed => "failed", + Self::PasswordChangeRequired => "password_change_required", + }) + } +} + +/// A safe category for a PAM failure, without exposing PAM's free-form text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PamReason { + /// Password or other authentication material was rejected. + AuthenticationRejected, + /// PAM could not reach its backing authentication service. + AuthenticationUnavailable, + /// PAM reports that the account has expired. + AccountExpired, + /// PAM requires a password change. + PasswordChangeRequired, + /// PAM reports that the retry limit was reached. + TooManyAttempts, + /// PAM conversation or token handling failed. + Conversation, + /// A PAM/system operation failed. + System, + /// An unclassified PAM status was returned. + Unknown, +} + +impl fmt::Display for PamReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::AuthenticationRejected => "authentication_rejected", + Self::AuthenticationUnavailable => "authentication_unavailable", + Self::AccountExpired => "account_expired", + Self::PasswordChangeRequired => "password_change_required", + Self::TooManyAttempts => "too_many_attempts", + Self::Conversation => "conversation_error", + Self::System => "system_error", + Self::Unknown => "unknown_pam_error", + }) + } +} + +/// A structured event whose fields are safe to send to an audit logger. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditEvent<'a> { + /// Security operation. + pub action: AuditAction, + /// Operation outcome. + pub outcome: AuditOutcome, + /// Account name supplied by the user; never a password or token. + pub account: &'a str, + /// Sanitized PAM failure category, when applicable. + pub pam_reason: Option, +} + +impl<'a> AuditEvent<'a> { + /// Creates an event for an operation that has just started. + pub const fn started(action: AuditAction, account: &'a str) -> Self { + Self { + action, + outcome: AuditOutcome::Started, + account, + pam_reason: None, + } + } + + /// Creates a successful event. + pub const fn succeeded(action: AuditAction, account: &'a str) -> Self { + Self { + action, + outcome: AuditOutcome::Succeeded, + account, + pam_reason: None, + } + } + + /// Creates a failure event with a sanitized PAM category. + pub fn failed(action: AuditAction, account: &'a str, error: &Error) -> Self { + Self { + action, + outcome: if error.requires_password_change() { + AuditOutcome::PasswordChangeRequired + } else { + AuditOutcome::Failed + }, + account, + pam_reason: error.pam_status().map(pam_reason), + } + } +} + +/// Maps Linux-PAM status codes to stable, non-sensitive categories. +pub const fn pam_reason(status: i32) -> PamReason { + match status { + 6 | 7 => PamReason::AuthenticationRejected, + 9 | 10 => PamReason::AuthenticationUnavailable, + 11 => PamReason::TooManyAttempts, + 12 | 27 => PamReason::PasswordChangeRequired, + 13 => PamReason::AccountExpired, + 19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 => PamReason::Conversation, + 1 | 2 | 3 | 4 | 5 | 14 | 15 | 16 | 17 | 18 | 25 | 26 | 28 | 29 => PamReason::System, + _ => PamReason::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn password_change_status_is_classified_without_raw_message() { + let error = Error::Pam { + operation: "pam_acct_mgmt", + status: 12, + message: "secret module detail".into(), + }; + let event = AuditEvent::failed(AuditAction::Authentication, "alice", &error); + assert_eq!(event.outcome, AuditOutcome::PasswordChangeRequired); + assert_eq!(event.pam_reason, Some(PamReason::PasswordChangeRequired)); + assert!(!format!("{event:?}").contains("secret module detail")); + } + + #[test] + fn every_known_pam_family_has_a_stable_reason() { + assert_eq!(pam_reason(7), PamReason::AuthenticationRejected); + assert_eq!(pam_reason(9), PamReason::AuthenticationUnavailable); + assert_eq!(pam_reason(13), PamReason::AccountExpired); + assert_eq!(pam_reason(11), PamReason::TooManyAttempts); + assert_eq!(pam_reason(999), PamReason::Unknown); + } +} diff --git a/crates/latchd-core/src/error.rs b/crates/latchd-core/src/error.rs index 55589de..a176d3c 100644 --- a/crates/latchd-core/src/error.rs +++ b/crates/latchd-core/src/error.rs @@ -47,6 +47,21 @@ pub enum Error { Worker(String), } +impl Error { + /// Returns a PAM status when this error came directly from PAM. + pub fn pam_status(&self) -> Option { + match self { + Self::Pam { status, .. } => Some(*status), + _ => None, + } + } + + /// Returns whether PAM requires a password change before login can finish. + pub fn requires_password_change(&self) -> bool { + matches!(self.pam_status(), Some(12 | 27)) + } +} + /// Adds a stable operation label to an I/O error. pub fn io_error(context: impl Into, source: io::Error) -> Error { Error::Io { diff --git a/crates/latchd-core/src/lib.rs b/crates/latchd-core/src/lib.rs index 1092145..863fd11 100644 --- a/crates/latchd-core/src/lib.rs +++ b/crates/latchd-core/src/lib.rs @@ -7,6 +7,8 @@ pub mod accounting; /// NSS account discovery and faillock status. pub mod accounts; +/// Redacted, structured security audit events. +pub mod audit; /// Validated application configuration. pub mod config; /// Structured application errors. diff --git a/crates/latchd-tui/src/lib.rs b/crates/latchd-tui/src/lib.rs index 490c1f9..9840a7f 100644 --- a/crates/latchd-tui/src/lib.rs +++ b/crates/latchd-tui/src/lib.rs @@ -18,6 +18,7 @@ use zeroize::Zeroizing; use latchd_auth::{AuthService, AuthTicket}; use latchd_core::{ accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity}, + audit::{AuditAction, AuditEvent}, config::Config, error::{Error, ResultExt}, session::{self, SessionDescriptor}, @@ -73,9 +74,15 @@ type DiscoverySnapshot = (Vec, Vec, Vec>), + PasswordChangePending(Receiver>), Failed, } +enum AuthCompletion { + Login(Result), + PasswordChange(Result<(), Error>), +} + #[derive(Debug, Clone, Copy)] enum NoticeLevel { Info, @@ -103,6 +110,9 @@ struct App { focus: Focus, username: String, password: Zeroizing, + pending_password: Zeroizing>, + pending_new_password: Zeroizing, + password_change_mode: bool, status: String, auth_state: AuthState, auth_failures: u32, @@ -151,6 +161,9 @@ impl App { focus: Focus::Username, username, password: Zeroizing::new(String::new()), + pending_password: Zeroizing::new(Vec::new()), + pending_new_password: Zeroizing::new(String::new()), + password_change_mode: false, status: "Enter your username".into(), auth_state: AuthState::Idle, auth_failures: 0, @@ -189,32 +202,9 @@ impl App { terminal .draw(|frame| self.draw(frame)) .map_err(|error| Error::Terminal(error.to_string()))?; - if let Some(result) = self.poll_auth() { - match result { - Ok(ticket) => { - self.auth_failures = 0; - self.auth_blocked_until = None; - let selected = self - .sessions - .get(self.selected) - .cloned() - .ok_or_else(|| Error::Session("no session selected".into()))?; - return Ok(Some(LoginSelection { - ticket, - session: selected, - })); - } - Err(error) => { - tracing::warn!(%error, "authentication attempt failed"); - self.auth_state = AuthState::Failed; - self.password.clear(); - self.auth_failures = self.auth_failures.saturating_add(1); - self.auth_blocked_until = - Some(Instant::now() + auth_backoff(self.auth_failures)); - self.status = "Authentication failed".into(); - self.notify("Authentication failed", NoticeLevel::Warning); - self.start_faillock_query(); - } + if let Some(completion) = self.poll_auth() { + if let Some(selection) = self.handle_auth_completion(completion)? { + return Ok(Some(selection)); } } if self.should_quit { @@ -230,19 +220,120 @@ impl App { } } - fn poll_auth(&mut self) -> Option> { - let AuthState::Pending(receiver) = &self.auth_state else { - return None; - }; - match receiver.try_recv() { - Ok(result) => Some(result), - Err(mpsc::TryRecvError::Empty) => None, - Err(mpsc::TryRecvError::Disconnected) => Some(Err(Error::Worker( - "authentication task disconnected".into(), - ))), + fn poll_auth(&mut self) -> Option { + match &self.auth_state { + AuthState::Pending(receiver) => match receiver.try_recv() { + Ok(result) => Some(AuthCompletion::Login(result)), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::Login(Err( + Error::Worker("authentication task disconnected".into()), + ))), + }, + AuthState::PasswordChangePending(receiver) => match receiver.try_recv() { + Ok(result) => Some(AuthCompletion::PasswordChange(result)), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::PasswordChange(Err( + Error::Worker("password-change task disconnected".into()), + ))), + }, + AuthState::Idle | AuthState::Failed => None, } } + fn handle_auth_completion( + &mut self, + completion: AuthCompletion, + ) -> Result, Error> { + match completion { + AuthCompletion::Login(Ok(ticket)) => { + self.audit(AuditEvent::succeeded( + AuditAction::Authentication, + &self.username, + )); + self.auth_failures = 0; + self.auth_blocked_until = None; + self.pending_password.clear(); + let selected = self + .sessions + .get(self.selected) + .cloned() + .ok_or_else(|| Error::Session("no session selected".into()))?; + Ok(Some(LoginSelection { + ticket, + session: selected, + })) + } + AuthCompletion::Login(Err(error)) => { + let audit = AuditEvent::failed(AuditAction::Authentication, &self.username, &error); + self.audit(audit); + if error.requires_password_change() { + self.auth_state = AuthState::Idle; + self.password_change_mode = true; + self.password.clear(); + self.auth_failures = 0; + self.auth_blocked_until = None; + self.focus = Focus::Password; + self.status = "PAM requires a new password; enter it and press Enter".into(); + self.notify("Password change required by PAM", NoticeLevel::Warning); + } else { + self.auth_state = AuthState::Failed; + self.pending_password.clear(); + self.password.clear(); + self.auth_failures = self.auth_failures.saturating_add(1); + self.auth_blocked_until = + Some(Instant::now() + auth_backoff(self.auth_failures)); + self.status = "Authentication failed".into(); + self.notify("Authentication failed", NoticeLevel::Warning); + self.start_faillock_query(); + } + Ok(None) + } + AuthCompletion::PasswordChange(Ok(())) => { + self.audit(AuditEvent::succeeded( + AuditAction::PasswordChange, + &self.username, + )); + self.password = std::mem::replace( + &mut self.pending_new_password, + Zeroizing::new(String::new()), + ); + self.pending_password.clear(); + self.password_change_mode = false; + self.auth_state = AuthState::Idle; + self.status = "Password changed; authenticating…".into(); + self.notify("Password changed; signing in", NoticeLevel::Info); + self.begin_authentication(); + Ok(None) + } + AuthCompletion::PasswordChange(Err(error)) => { + self.audit(AuditEvent::failed( + AuditAction::PasswordChange, + &self.username, + &error, + )); + self.auth_state = AuthState::Failed; + self.password_change_mode = false; + self.pending_password.clear(); + self.pending_new_password.clear(); + self.password.clear(); + self.status = "PAM rejected the password change".into(); + self.notify("Password change failed", NoticeLevel::Warning); + Ok(None) + } + } + } + + fn audit(&self, event: AuditEvent<'_>) { + tracing::info!( + target: "latchd.audit", + action = %event.action, + outcome = %event.outcome, + account = %event.account, + pam_reason = ?event.pam_reason, + "security event" + ); + } + fn poll_faillock(&mut self) { let Some(receiver) = &self.faillock_receiver else { return; @@ -546,7 +637,10 @@ impl App { self.should_quit = true; return; } - if matches!(self.auth_state, AuthState::Pending(_)) { + if matches!( + self.auth_state, + AuthState::Pending(_) | AuthState::PasswordChangePending(_) + ) { if key.code == KeyCode::Esc { self.status = "Authentication is still in progress".into(); } @@ -693,6 +787,10 @@ impl App { self.status = "Preview mode — authentication and session startup are disabled".into(); return; } + if self.password_change_mode { + self.begin_password_change(); + return; + } if self.username.is_empty() || self.password.is_empty() { self.status = "Username and password are required".into(); return; @@ -709,6 +807,7 @@ impl App { } let username = self.username.clone(); let password = self.password.as_bytes().to_vec(); + self.pending_password = Zeroizing::new(password.clone()); let service = self.config.pam_service.clone(); let auth = self.auth.clone(); let (sender, receiver) = mpsc::sync_channel(0); @@ -720,11 +819,59 @@ impl App { }); match task { Ok(_) => { + self.audit(AuditEvent::started( + AuditAction::Authentication, + &self.username, + )); self.auth_state = AuthState::Pending(receiver); self.status = "Authenticating…".into(); self.password.clear(); } - Err(error) => self.status = format!("Could not start authentication: {error}"), + Err(error) => { + self.pending_password.clear(); + self.status = format!("Could not start authentication: {error}"); + } + } + } + + fn begin_password_change(&mut self) { + if self.pending_password.is_empty() { + self.password_change_mode = false; + self.status = "Password-change context expired; authenticate again".into(); + return; + } + if self.password.is_empty() { + self.status = "A new password is required".into(); + return; + } + let username = self.username.clone(); + let current_password = self.pending_password.to_vec(); + let new_password = self.password.as_bytes().to_vec(); + self.pending_new_password = Zeroizing::new(self.password.as_str().to_owned()); + let service = self.config.pam_service.clone(); + let auth = self.auth.clone(); + let (sender, receiver) = mpsc::sync_channel(0); + let task = thread::Builder::new() + .name("latchd-password-change".into()) + .spawn(move || { + let result = + auth.change_password(username, current_password, new_password, service); + let _ = sender.send(result); + }); + match task { + Ok(_) => { + self.audit(AuditEvent::started( + AuditAction::PasswordChange, + &self.username, + )); + self.auth_state = AuthState::PasswordChangePending(receiver); + self.status = "Changing password through PAM…".into(); + self.password.clear(); + } + Err(error) => { + self.pending_new_password.clear(); + self.status = format!("Could not start password change: {error}"); + } } } @@ -776,6 +923,9 @@ impl App { self.status = match self.focus { Focus::Sessions => "Choose a session with ←/→".into(), Focus::Username => "Enter your username".into(), + Focus::Password if self.password_change_mode => { + "Enter a new password and press Enter".into() + } Focus::Password => "Enter your password and press Enter".into(), }; } @@ -837,6 +987,9 @@ impl App { self.account_index = Some(index); self.username = username; self.password.clear(); + self.pending_password.clear(); + self.pending_new_password.clear(); + self.password_change_mode = false; self.auth_state = AuthState::Idle; self.auth_failures = 0; self.auth_blocked_until = None; @@ -950,8 +1103,16 @@ impl App { let username = Paragraph::new(self.username.as_str()) .block(self.block(&account_title, self.focus == Focus::Username)); let password_marker = if self.ascii { "*" } else { "•" }; - let password = Paragraph::new(password_marker.repeat(self.password.chars().count())) - .block(self.block("Password", self.focus == Focus::Password)); + let password = Paragraph::new(password_marker.repeat(self.password.chars().count())).block( + self.block( + if self.password_change_mode { + "New password" + } else { + "Password" + }, + self.focus == Focus::Password, + ), + ); frame.render_widget(username, fields[0]); frame.render_widget(password, fields[1]); let status = self.status_line(); @@ -972,6 +1133,9 @@ impl App { let mut hint = match self.focus { Focus::Sessions => "Tab focus ←/→ choose / filter Ctrl-R refresh ? help", Focus::Username => "Tab focus / filter Ctrl-N/P switch Ctrl-R refresh ? help", + Focus::Password if self.password_change_mode => { + "Tab focus Enter change password Ctrl-M motion ? help" + } Focus::Password => "Tab focus Enter login Ctrl-M motion ? help", } .to_owned(); @@ -1041,7 +1205,7 @@ impl App { frame.render_widget(Clear, popup); frame.render_widget( Paragraph::new( - "Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\n/ filter sessions or accounts\nCtrl-N / Ctrl-P switch account\nCtrl-R refresh discovery\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate / accept filter\nEsc clear password / close popup\nCtrl-C quit cleanly\n? close help", + "Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\n/ filter sessions or accounts\nCtrl-N / Ctrl-P switch account\nCtrl-R refresh discovery\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate / change password\nEsc clear password / close popup\nCtrl-C quit cleanly\n? close help", ) .block(self.block("Help", false)) .wrap(Wrap { trim: true }), @@ -1072,7 +1236,10 @@ impl App { fn status_line(&self) -> String { let mut status = self.status.clone(); - if matches!(self.auth_state, AuthState::Pending(_)) { + if matches!( + self.auth_state, + AuthState::Pending(_) | AuthState::PasswordChangePending(_) + ) { let spinner = if self.config.visual.animations && !self.reduced_motion { ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] .get(self.animation_frame % 10) @@ -1083,7 +1250,11 @@ impl App { } else { "…" }; - status = format!("{spinner} Authenticating…"); + status = if matches!(self.auth_state, AuthState::PasswordChangePending(_)) { + format!("{spinner} Changing password through PAM…") + } else { + format!("{spinner} Authenticating…") + }; } if let Some(until) = self.auth_blocked_until { if let Some(remaining) = until.checked_duration_since(Instant::now()) { @@ -1427,6 +1598,62 @@ mod tests { assert_eq!(auth_backoff(100), Duration::from_secs(16)); } + #[test] + fn pam_password_change_requirement_enters_explicit_new_password_mode() { + let auth = AuthService::new().expect("PAM worker should start"); + let mut app = App::new( + Config::default(), + auth, + true, + Vec::new(), + Vec::new(), + Background::load(&Config::default().visual).expect("default background should load"), + ); + app.pending_password = Zeroizing::new(b"old-secret".to_vec()); + app.handle_auth_completion(AuthCompletion::Login(Err(Error::Pam { + operation: "pam_acct_mgmt", + status: 12, + message: "module detail must stay out of the UI".into(), + }))) + .expect("policy transition should not fail"); + assert!(app.password_change_mode); + assert_eq!(app.focus, Focus::Password); + assert!(app.status.contains("new password")); + } + + #[test] + fn password_change_mode_has_a_distinct_prompt() { + let auth = AuthService::new().expect("PAM worker should start"); + let mut app = App::new( + Config::default(), + auth, + true, + vec![SessionDescriptor { + kind: session::SessionKind::Tty, + title: "TTY shell".into(), + argv: Vec::new(), + current_desktop: None, + session_desktop: None, + }], + Vec::new(), + Background::load(&Config::default().visual).expect("default background should load"), + ); + app.password_change_mode = true; + app.focus = Focus::Password; + app.update_hint(); + assert!(app.status.contains("new password")); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("terminal"); + terminal.draw(|frame| app.draw(frame)).expect("frame"); + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("New password")); + } + #[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 20870dc..52e5051 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -12,10 +12,11 @@ Latchd has two test layers: The Docker test image runs the first layer and starts an isolated Xvfb server when available. It does not use host display sockets, host PAM files, or a -host VT. PAM authentication and a real login handoff require a disposable VM -with a dedicated test account and an explicitly provisioned PAM policy; the -container probe intentionally reports those checks as capability information -only. +host VT. The password-change path is covered by FFI/conversation tests in the +container, but a real PAM password rotation and login handoff require a +disposable VM with a dedicated test account and an explicitly provisioned PAM +policy. The container probe intentionally reports those checks as capability +information only. For a local release-style run: diff --git a/rust/main.rs b/rust/main.rs index 6ae5f21..f107cbb 100644 --- a/rust/main.rs +++ b/rust/main.rs @@ -13,6 +13,7 @@ use std::{ use clap::Parser; use latchd_auth::AuthService; use latchd_core::{ + audit::{AuditAction, AuditEvent}, config::{self, Config}, error::{Error, ResultExt}, session, @@ -173,8 +174,39 @@ fn run() -> Result<(), Error> { } }; info!(user = %identity.username, session = %selection.session.title, "starting session"); + let audit = AuditEvent::started(AuditAction::Session, &identity.username); + tracing::info!( + target: "latchd.audit", + action = %audit.action, + outcome = %audit.outcome, + account = %audit.account, + pam_reason = ?audit.pam_reason, + session = %selection.session.title, + "security event" + ); if let Err(error) = auth.run_session(selection.ticket, spec) { - error!(%error, "session ended with an error"); + let audit = AuditEvent::failed(AuditAction::Session, &identity.username, &error); + tracing::info!( + target: "latchd.audit", + action = %audit.action, + outcome = %audit.outcome, + account = %audit.account, + pam_reason = ?audit.pam_reason, + session = %selection.session.title, + "security event" + ); + error!("session ended with an error; see the structured audit event"); + } else { + let audit = AuditEvent::succeeded(AuditAction::Session, &identity.username); + tracing::info!( + target: "latchd.audit", + action = %audit.action, + outcome = %audit.outcome, + account = %audit.account, + pam_reason = ?audit.pam_reason, + session = %selection.session.title, + "security event" + ); } } Ok(()) diff --git a/scripts/test-system-e2e.sh b/scripts/test-system-e2e.sh index 5bb3858..b83b86a 100755 --- a/scripts/test-system-e2e.sh +++ b/scripts/test-system-e2e.sh @@ -54,6 +54,11 @@ if need_command ldconfig && ldconfig -p 2>/dev/null | grep -q 'libpam\.so'; then else skip "PAM runtime library is unavailable" fi +if [ -n "$binary" ] && need_command nm && nm -D "$binary" 2>/dev/null | grep -q 'pam_chauthtok'; then + pass "PAM password-change entry point is linked" +else + skip "PAM password-change entry point could not be inspected" +fi if [ -r /etc/pam.d/latchd ]; then pass "latchd PAM policy is installed" else