feat: handle PAM password policy changes

This commit is contained in:
2026-09-26 01:50:02 +05:30
parent 9492dbd7c9
commit 83345a1842
12 changed files with 697 additions and 57 deletions

View File

@@ -1,5 +1,19 @@
# Changelog # 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 ## 0.4.0 — Advanced login workflow
- Added account policy inspection for locked passwords, password expiry, - Added account policy inspection for locked passwords, password expiry,

8
Cargo.lock generated
View File

@@ -782,7 +782,7 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
[[package]] [[package]]
name = "latchd" name = "latchd"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"clap", "clap",
"color-eyre", "color-eyre",
@@ -796,7 +796,7 @@ dependencies = [
[[package]] [[package]]
name = "latchd-auth" name = "latchd-auth"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"latchd-core", "latchd-core",
"libc", "libc",
@@ -805,7 +805,7 @@ dependencies = [
[[package]] [[package]]
name = "latchd-core" name = "latchd-core"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"libc", "libc",
"rand 0.9.5", "rand 0.9.5",
@@ -818,7 +818,7 @@ dependencies = [
[[package]] [[package]]
name = "latchd-tui" name = "latchd-tui"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"crossterm", "crossterm",
"image", "image",

View File

@@ -3,7 +3,7 @@ members = [".", "crates/latchd-core", "crates/latchd-auth", "crates/latchd-tui"]
resolver = "3" resolver = "3"
[workspace.package] [workspace.package]
version = "0.4.0" version = "0.5.0"
edition = "2024" edition = "2024"
rust-version = "1.88" rust-version = "1.88"
license = "Apache-2.0" license = "Apache-2.0"

View File

@@ -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. - Finds normal login accounts through NSS.
- Shows password, account-expiry, locked-account, and faillock status without replacing PAM's decision. - 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. - Lets you switch users and sessions without leaving the login screen.
- Finds X11 and Wayland desktop entries and can include a TTY shell. - 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. - 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-N / Ctrl-P | Select the next or previous account |
| Ctrl-B | Toggle wallpaper and ambient effects | | Ctrl-B | Toggle wallpaper and ambient effects |
| Ctrl-M | Toggle reduced motion | | 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 | | Esc | Clear the password or close a popup |
| `?` | Show keyboard help | | `?` | Show keyboard help |
| Ctrl-C | Restore the terminal and quit | | Ctrl-C | Restore the terminal and quit |
@@ -115,6 +116,8 @@ make system-e2e
- PAM handles stay on one worker thread. - PAM handles stay on one worker thread.
- Password buffers are zeroized and never placed in argv, logs, or the TUI. - 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. - Session environments are rebuilt from an allowlist.
- Desktop files, scripts, binaries, homes, logs, and configuration sources are checked before use. - Desktop files, scripts, binaries, homes, logs, and configuration sources are checked before use.
- Xauthority files and session logs use private permissions and cleanup guards. - Xauthority files and session logs use private permissions and cleanup guards.
@@ -125,7 +128,7 @@ make system-e2e
```text ```text
crates/latchd-core/ configuration, account discovery, sessions, UTMPX 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 crates/latchd-tui/ Ratatui state, rendering, filtering, and effects
rust/main.rs command-line entry point and service orchestration rust/main.rs command-line entry point and service orchestration
extra/ example configuration, PAM policy, and systemd unit extra/ example configuration, PAM policy, and systemd unit

View File

@@ -27,6 +27,7 @@ const PAM_SYSTEM_ERR: c_int = 4;
const PAM_BUF_ERR: c_int = 5; const PAM_BUF_ERR: c_int = 5;
const PAM_CONV_ERR: c_int = 19; const PAM_CONV_ERR: c_int = 19;
const PAM_AUTHINFO_UNAVAIL: c_int = 9; 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_OFF: c_int = 1;
const PAM_PROMPT_ECHO_ON: c_int = 2; const PAM_PROMPT_ECHO_ON: c_int = 2;
@@ -76,6 +77,8 @@ unsafe extern "C" {
) -> c_int; ) -> c_int;
#[link_name = "pam_authenticate"] #[link_name = "pam_authenticate"]
fn pam_authenticate(pamh: *mut PamHandle, flags: c_int) -> c_int; 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"] #[link_name = "pam_acct_mgmt"]
fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int; fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_setcred"] #[link_name = "pam_setcred"]
@@ -119,6 +122,13 @@ enum Command {
service: String, service: String,
response: std::sync::mpsc::SyncSender<Result<AuthTicket, Error>>, response: std::sync::mpsc::SyncSender<Result<AuthTicket, Error>>,
}, },
ChangePassword {
username: String,
current_password: Zeroizing<Vec<u8>>,
new_password: Zeroizing<Vec<u8>>,
service: String,
response: std::sync::mpsc::SyncSender<Result<(), Error>>,
},
Run { Run {
id: u64, id: u64,
spec: SessionSpec, spec: SessionSpec,
@@ -170,6 +180,33 @@ impl AuthService {
Ok(ticket) 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<u8>,
new_password: Vec<u8>,
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. /// Runs the authenticated session and closes its PAM transaction.
pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> { pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> {
let (response, result) = sync_channel(0); let (response, result) = sync_channel(0);
@@ -196,6 +233,8 @@ impl AuthService {
struct ConversationData { struct ConversationData {
username: CString, username: CString,
password: Zeroizing<Vec<u8>>, password: Zeroizing<Vec<u8>>,
new_password: Option<Zeroizing<Vec<u8>>>,
new_password_prompts: usize,
} }
impl Drop for ConversationData { impl Drop for ConversationData {
@@ -261,6 +300,16 @@ fn worker(requests: Receiver<Command>) {
authenticate(&username, password, &service, &mut next_id, &mut sessions); authenticate(&username, password, &service, &mut next_id, &mut sessions);
let _ = response.send(result); 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 } => { Command::Run { id, spec, response } => {
let result = match sessions.remove(&id) { let result = match sessions.remove(&id) {
Some(mut session) => { Some(mut session) => {
@@ -312,6 +361,8 @@ fn authenticate(
let conversation = Box::new(ConversationData { let conversation = Box::new(ConversationData {
username: username_c.clone(), username: username_c.clone(),
password, password,
new_password: None,
new_password_prompts: 0,
}); });
let data = Box::into_raw(conversation); let data = Box::into_raw(conversation);
let conversation_ptr = PamConv { let conversation_ptr = PamConv {
@@ -398,6 +449,71 @@ fn authenticate(
}) })
} }
fn change_password(
username: &str,
current_password: Zeroizing<Vec<u8>>,
new_password: Zeroizing<Vec<u8>>,
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<Identity, Error> { fn lookup_identity(username: &str) -> Result<Identity, Error> {
let name = CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?; let name = CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?;
let passwd = unsafe { libc::getpwnam(name.as_ptr()) }; let passwd = unsafe { libc::getpwnam(name.as_ptr()) };
@@ -456,7 +572,7 @@ unsafe extern "C" fn converse(
if output.is_null() { if output.is_null() {
return PAM_BUF_ERR; return PAM_BUF_ERR;
} }
let context = &*(data.cast::<ConversationData>()); let context = &mut *(data.cast::<ConversationData>());
for index in 0..count { for index in 0..count {
let message = *messages.add(index); let message = *messages.add(index);
if message.is_null() { if message.is_null() {
@@ -464,7 +580,18 @@ unsafe extern "C" fn converse(
return PAM_CONV_ERR; return PAM_CONV_ERR;
} }
let value: Option<&[u8]> = match (*message).msg_style { 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_PROMPT_ECHO_ON => Some(context.username.as_bytes()),
PAM_ERROR_MSG | PAM_TEXT_INFO => None, PAM_ERROR_MSG | PAM_TEXT_INFO => None,
_ => { _ => {
@@ -524,6 +651,8 @@ mod tests {
let context = Box::new(ConversationData { let context = Box::new(ConversationData {
username: CString::new("alice").unwrap(), username: CString::new("alice").unwrap(),
password: Zeroizing::new(b"secret".to_vec()), password: Zeroizing::new(b"secret".to_vec()),
new_password: None,
new_password_prompts: 0,
}); });
let pointer = Box::into_raw(context); let pointer = Box::into_raw(context);
let message = PamMessage { let message = PamMessage {
@@ -545,4 +674,36 @@ mod tests {
drop(Box::from_raw(pointer)); 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)) };
}
} }

View File

@@ -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<PamReason>,
}
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);
}
}

View File

@@ -47,6 +47,21 @@ pub enum Error {
Worker(String), Worker(String),
} }
impl Error {
/// Returns a PAM status when this error came directly from PAM.
pub fn pam_status(&self) -> Option<i32> {
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. /// Adds a stable operation label to an I/O error.
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error { pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
Error::Io { Error::Io {

View File

@@ -7,6 +7,8 @@
pub mod accounting; pub mod accounting;
/// NSS account discovery and faillock status. /// NSS account discovery and faillock status.
pub mod accounts; pub mod accounts;
/// Redacted, structured security audit events.
pub mod audit;
/// Validated application configuration. /// Validated application configuration.
pub mod config; pub mod config;
/// Structured application errors. /// Structured application errors.

View File

@@ -18,6 +18,7 @@ use zeroize::Zeroizing;
use latchd_auth::{AuthService, AuthTicket}; use latchd_auth::{AuthService, AuthTicket};
use latchd_core::{ use latchd_core::{
accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity}, accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity},
audit::{AuditAction, AuditEvent},
config::Config, config::Config,
error::{Error, ResultExt}, error::{Error, ResultExt},
session::{self, SessionDescriptor}, session::{self, SessionDescriptor},
@@ -73,9 +74,15 @@ type DiscoverySnapshot = (Vec<SessionDescriptor>, Vec<Identity>, Vec<AccountStat
enum AuthState { enum AuthState {
Idle, Idle,
Pending(Receiver<Result<AuthTicket, Error>>), Pending(Receiver<Result<AuthTicket, Error>>),
PasswordChangePending(Receiver<Result<(), Error>>),
Failed, Failed,
} }
enum AuthCompletion {
Login(Result<AuthTicket, Error>),
PasswordChange(Result<(), Error>),
}
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
enum NoticeLevel { enum NoticeLevel {
Info, Info,
@@ -103,6 +110,9 @@ struct App {
focus: Focus, focus: Focus,
username: String, username: String,
password: Zeroizing<String>, password: Zeroizing<String>,
pending_password: Zeroizing<Vec<u8>>,
pending_new_password: Zeroizing<String>,
password_change_mode: bool,
status: String, status: String,
auth_state: AuthState, auth_state: AuthState,
auth_failures: u32, auth_failures: u32,
@@ -151,6 +161,9 @@ impl App {
focus: Focus::Username, focus: Focus::Username,
username, username,
password: Zeroizing::new(String::new()), 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(), status: "Enter your username".into(),
auth_state: AuthState::Idle, auth_state: AuthState::Idle,
auth_failures: 0, auth_failures: 0,
@@ -189,32 +202,9 @@ impl App {
terminal terminal
.draw(|frame| self.draw(frame)) .draw(|frame| self.draw(frame))
.map_err(|error| Error::Terminal(error.to_string()))?; .map_err(|error| Error::Terminal(error.to_string()))?;
if let Some(result) = self.poll_auth() { if let Some(completion) = self.poll_auth() {
match result { if let Some(selection) = self.handle_auth_completion(completion)? {
Ok(ticket) => { return Ok(Some(selection));
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 self.should_quit { if self.should_quit {
@@ -230,19 +220,120 @@ impl App {
} }
} }
fn poll_auth(&mut self) -> Option<Result<AuthTicket, Error>> { fn poll_auth(&mut self) -> Option<AuthCompletion> {
let AuthState::Pending(receiver) = &self.auth_state else { match &self.auth_state {
return None; AuthState::Pending(receiver) => match receiver.try_recv() {
}; Ok(result) => Some(AuthCompletion::Login(result)),
match receiver.try_recv() {
Ok(result) => Some(result),
Err(mpsc::TryRecvError::Empty) => None, Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) => Some(Err(Error::Worker( Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::Login(Err(
"authentication task disconnected".into(), 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<Option<LoginSelection>, 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) { fn poll_faillock(&mut self) {
let Some(receiver) = &self.faillock_receiver else { let Some(receiver) = &self.faillock_receiver else {
return; return;
@@ -546,7 +637,10 @@ impl App {
self.should_quit = true; self.should_quit = true;
return; return;
} }
if matches!(self.auth_state, AuthState::Pending(_)) { if matches!(
self.auth_state,
AuthState::Pending(_) | AuthState::PasswordChangePending(_)
) {
if key.code == KeyCode::Esc { if key.code == KeyCode::Esc {
self.status = "Authentication is still in progress".into(); 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(); self.status = "Preview mode — authentication and session startup are disabled".into();
return; return;
} }
if self.password_change_mode {
self.begin_password_change();
return;
}
if self.username.is_empty() || self.password.is_empty() { if self.username.is_empty() || self.password.is_empty() {
self.status = "Username and password are required".into(); self.status = "Username and password are required".into();
return; return;
@@ -709,6 +807,7 @@ impl App {
} }
let username = self.username.clone(); let username = self.username.clone();
let password = self.password.as_bytes().to_vec(); let password = self.password.as_bytes().to_vec();
self.pending_password = Zeroizing::new(password.clone());
let service = self.config.pam_service.clone(); let service = self.config.pam_service.clone();
let auth = self.auth.clone(); let auth = self.auth.clone();
let (sender, receiver) = mpsc::sync_channel(0); let (sender, receiver) = mpsc::sync_channel(0);
@@ -720,11 +819,59 @@ impl App {
}); });
match task { match task {
Ok(_) => { Ok(_) => {
self.audit(AuditEvent::started(
AuditAction::Authentication,
&self.username,
));
self.auth_state = AuthState::Pending(receiver); self.auth_state = AuthState::Pending(receiver);
self.status = "Authenticating…".into(); self.status = "Authenticating…".into();
self.password.clear(); 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 { self.status = match self.focus {
Focus::Sessions => "Choose a session with ←/→".into(), Focus::Sessions => "Choose a session with ←/→".into(),
Focus::Username => "Enter your username".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(), Focus::Password => "Enter your password and press Enter".into(),
}; };
} }
@@ -837,6 +987,9 @@ impl App {
self.account_index = Some(index); self.account_index = Some(index);
self.username = username; self.username = username;
self.password.clear(); self.password.clear();
self.pending_password.clear();
self.pending_new_password.clear();
self.password_change_mode = false;
self.auth_state = AuthState::Idle; self.auth_state = AuthState::Idle;
self.auth_failures = 0; self.auth_failures = 0;
self.auth_blocked_until = None; self.auth_blocked_until = None;
@@ -950,8 +1103,16 @@ impl App {
let username = Paragraph::new(self.username.as_str()) let username = Paragraph::new(self.username.as_str())
.block(self.block(&account_title, self.focus == Focus::Username)); .block(self.block(&account_title, self.focus == Focus::Username));
let password_marker = if self.ascii { "*" } else { "•" }; let password_marker = if self.ascii { "*" } else { "•" };
let password = Paragraph::new(password_marker.repeat(self.password.chars().count())) let password = Paragraph::new(password_marker.repeat(self.password.chars().count())).block(
.block(self.block("Password", self.focus == Focus::Password)); self.block(
if self.password_change_mode {
"New password"
} else {
"Password"
},
self.focus == Focus::Password,
),
);
frame.render_widget(username, fields[0]); frame.render_widget(username, fields[0]);
frame.render_widget(password, fields[1]); frame.render_widget(password, fields[1]);
let status = self.status_line(); let status = self.status_line();
@@ -972,6 +1133,9 @@ impl App {
let mut hint = match self.focus { let mut hint = match self.focus {
Focus::Sessions => "Tab focus ←/→ choose / filter Ctrl-R refresh ? help", Focus::Sessions => "Tab focus ←/→ choose / filter Ctrl-R refresh ? help",
Focus::Username => "Tab focus / filter Ctrl-N/P switch 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", Focus::Password => "Tab focus Enter login Ctrl-M motion ? help",
} }
.to_owned(); .to_owned();
@@ -1041,7 +1205,7 @@ impl App {
frame.render_widget(Clear, popup); frame.render_widget(Clear, popup);
frame.render_widget( frame.render_widget(
Paragraph::new( 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)) .block(self.block("Help", false))
.wrap(Wrap { trim: true }), .wrap(Wrap { trim: true }),
@@ -1072,7 +1236,10 @@ impl App {
fn status_line(&self) -> String { fn status_line(&self) -> String {
let mut status = self.status.clone(); 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 { let spinner = if self.config.visual.animations && !self.reduced_motion {
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
.get(self.animation_frame % 10) .get(self.animation_frame % 10)
@@ -1083,7 +1250,11 @@ impl App {
} else { } 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(until) = self.auth_blocked_until {
if let Some(remaining) = until.checked_duration_since(Instant::now()) { 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)); 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::<String>();
assert!(rendered.contains("New password"));
}
#[test] #[test]
fn filtering_is_smart_case_and_selects_a_matching_session() { fn filtering_is_smart_case_and_selects_a_matching_session() {
let auth = AuthService::new().expect("PAM worker should start"); let auth = AuthService::new().expect("PAM worker should start");

View File

@@ -12,10 +12,11 @@ Latchd has two test layers:
The Docker test image runs the first layer and starts an isolated Xvfb server 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 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 host VT. The password-change path is covered by FFI/conversation tests in the
with a dedicated test account and an explicitly provisioned PAM policy; the container, but a real PAM password rotation and login handoff require a
container probe intentionally reports those checks as capability information disposable VM with a dedicated test account and an explicitly provisioned PAM
only. policy. The container probe intentionally reports those checks as capability
information only.
For a local release-style run: For a local release-style run:

View File

@@ -13,6 +13,7 @@ use std::{
use clap::Parser; use clap::Parser;
use latchd_auth::AuthService; use latchd_auth::AuthService;
use latchd_core::{ use latchd_core::{
audit::{AuditAction, AuditEvent},
config::{self, Config}, config::{self, Config},
error::{Error, ResultExt}, error::{Error, ResultExt},
session, session,
@@ -173,8 +174,39 @@ fn run() -> Result<(), Error> {
} }
}; };
info!(user = %identity.username, session = %selection.session.title, "starting session"); 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) { 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(()) Ok(())

View File

@@ -54,6 +54,11 @@ if need_command ldconfig && ldconfig -p 2>/dev/null | grep -q 'libpam\.so'; then
else else
skip "PAM runtime library is unavailable" skip "PAM runtime library is unavailable"
fi 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 if [ -r /etc/pam.d/latchd ]; then
pass "latchd PAM policy is installed" pass "latchd PAM policy is installed"
else else