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

@@ -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<SessionDescriptor>, Vec<Identity>, Vec<AccountStat
enum AuthState {
Idle,
Pending(Receiver<Result<AuthTicket, Error>>),
PasswordChangePending(Receiver<Result<(), Error>>),
Failed,
}
enum AuthCompletion {
Login(Result<AuthTicket, Error>),
PasswordChange(Result<(), Error>),
}
#[derive(Debug, Clone, Copy)]
enum NoticeLevel {
Info,
@@ -103,6 +110,9 @@ struct App {
focus: Focus,
username: String,
password: Zeroizing<String>,
pending_password: Zeroizing<Vec<u8>>,
pending_new_password: Zeroizing<String>,
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<Result<AuthTicket, Error>> {
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<AuthCompletion> {
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<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) {
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::<String>();
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");