feat: handle PAM password policy changes
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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
8
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
180
crates/latchd-core/src/audit.rs
Normal file
180
crates/latchd-core/src/audit.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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() {
|
Err(mpsc::TryRecvError::Empty) => None,
|
||||||
Ok(result) => Some(result),
|
Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::Login(Err(
|
||||||
Err(mpsc::TryRecvError::Empty) => None,
|
Error::Worker("authentication task disconnected".into()),
|
||||||
Err(mpsc::TryRecvError::Disconnected) => Some(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) {
|
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");
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
34
rust/main.rs
34
rust/main.rs
@@ -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(())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user