feat: complete session preferences and PAM fixture gates

This commit is contained in:
2026-09-26 02:44:29 +05:30
parent 83345a1842
commit ee6186ed0b
34 changed files with 801 additions and 327 deletions

View File

@@ -1,8 +1,6 @@
//! PAM authentication with one dedicated worker thread.
//!
//! PAM handles are intentionally never moved out of the worker. The public
//! ticket contains only an opaque ID, so the UI cannot accidentally use a PAM
//! transaction from the wrong thread or forget to close it.
//! The worker owns all PAM handles. Public tickets contain only an opaque ID.
use std::{
collections::HashMap,
@@ -35,6 +33,17 @@ const PAM_ERROR_MSG: c_int = 3;
const PAM_TEXT_INFO: c_int = 4;
const PAM_ESTABLISH_CRED: c_int = 0x0002;
const PAM_DELETE_CRED: c_int = 0x0004;
const PAM_TTY: c_int = 3;
const PAM_CHILD_ENVIRONMENT_KEYS: [&CStr; 8] = [
c"XDG_SESSION_CLASS",
c"XDG_SESSION_TYPE",
c"XDG_SESSION_DESKTOP",
c"XDG_SEAT",
c"XDG_VTNR",
c"XDG_RUNTIME_DIR",
c"XDG_SESSION_ID",
c"DBUS_SESSION_BUS_ADDRESS",
];
#[repr(C)]
struct PamHandle {
@@ -83,6 +92,12 @@ unsafe extern "C" {
fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_setcred"]
fn pam_setcred(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_set_item"]
fn pam_set_item(pamh: *mut PamHandle, item_type: c_int, item: *const c_void) -> c_int;
#[link_name = "pam_putenv"]
fn pam_putenv(pamh: *mut PamHandle, name_value: *const c_char) -> c_int;
#[link_name = "pam_getenv"]
fn pam_getenv(pamh: *mut PamHandle, name: *const c_char) -> *const c_char;
#[link_name = "pam_open_session"]
fn pam_open_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_close_session"]
@@ -101,7 +116,6 @@ pub struct AuthTicket {
}
impl AuthTicket {
/// Returns the account identity associated with this ticket.
pub fn identity(&self) -> &Identity {
&self.identity
}
@@ -146,7 +160,6 @@ pub struct AuthService {
}
impl AuthService {
/// Starts the PAM worker.
pub fn new() -> Result<Self, Error> {
let (commands, requests) = sync_channel::<Command>(1);
thread::Builder::new()
@@ -156,7 +169,6 @@ impl AuthService {
Ok(Self { commands })
}
/// Starts one authentication transaction without blocking the UI thread.
pub fn authenticate(
&self,
username: String,
@@ -182,9 +194,8 @@ impl AuthService {
/// 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.
/// PAM enforces password strength, history, and token rules.
/// Zeroizing buffers hold both passwords and errors never include them.
pub fn change_password(
&self,
username: String,
@@ -220,7 +231,6 @@ impl AuthService {
.map_err(|_| Error::Worker("PAM worker disconnected".into()))?
}
/// Cancels a ticket that will not be used for a session.
pub fn cancel(&self, mut ticket: AuthTicket) -> Result<(), Error> {
let id = ticket.id;
ticket.owner = None;
@@ -312,30 +322,7 @@ fn worker(requests: Receiver<Command>) {
}
Command::Run { id, spec, response } => {
let result = match sessions.remove(&id) {
Some(mut session) => {
let opened = unsafe { pam_open_session(session.handle, 0) };
if opened != PAM_SUCCESS {
let error = pam_error(session.handle, "pam_open_session", opened);
let _ = session.close(opened);
Err(error)
} else {
session.opened = true;
let run = match session.identity.as_ref() {
Some(identity) => session::run_user_session(identity, &spec),
None => Err(Error::Worker("ticket has no account identity".into())),
};
let close = session.close(if run.is_ok() {
PAM_SUCCESS
} else {
PAM_SYSTEM_ERR
});
match (run, close) {
(Err(error), _) => Err(error),
(Ok(()), Err(error)) => Err(error),
(Ok(()), Ok(())) => Ok(()),
}
}
}
Some(session) => run_authenticated_session(session, spec),
None => Err(Error::Worker("unknown authentication ticket".into())),
};
let _ = response.send(result);
@@ -347,6 +334,86 @@ fn worker(requests: Receiver<Command>) {
}
}
fn run_authenticated_session(mut session: PamSession, mut spec: SessionSpec) -> Result<(), Error> {
if let Err(error) = configure_pam_session(session.handle, &spec) {
let _ = session.close(PAM_SYSTEM_ERR);
return Err(error);
}
// SAFETY: This worker owns the live handle until `PamSession::close`.
let opened = unsafe { pam_open_session(session.handle, 0) };
if opened != PAM_SUCCESS {
let error = pam_error(session.handle, "pam_open_session", opened);
let _ = session.close(opened);
return Err(error);
}
session.opened = true;
let run = apply_pam_environment(session.handle, &mut spec).and_then(|()| {
session.identity.as_ref().map_or_else(
|| Err(Error::Worker("ticket has no account identity".into())),
|identity| session::run_user_session(identity, &spec),
)
});
let close = session.close(if run.is_ok() {
PAM_SUCCESS
} else {
PAM_SYSTEM_ERR
});
match (run, close) {
(Err(error), _) | (Ok(()), Err(error)) => Err(error),
(Ok(()), Ok(())) => Ok(()),
}
}
fn configure_pam_session(handle: *mut PamHandle, spec: &SessionSpec) -> Result<(), Error> {
let tty = CString::new(spec.pam_tty())
.map_err(|_| Error::Session("PAM terminal name contains NUL".into()))?;
// SAFETY: The worker owns the live handle. `tty` is NUL-terminated, and PAM copies the value.
let result = unsafe { pam_set_item(handle, PAM_TTY, tty.as_ptr().cast()) };
if result != PAM_SUCCESS {
return Err(pam_error(handle, "pam_set_item(PAM_TTY)", result));
}
for (key, value) in spec.pam_environment() {
let assignment = CString::new(format!("{key}={value}"))
.map_err(|_| Error::Session("PAM session environment contains NUL".into()))?;
// SAFETY: The worker owns the live handle. PAM copies the NUL-terminated assignment.
let result = unsafe { pam_putenv(handle, assignment.as_ptr()) };
if result != PAM_SUCCESS {
return Err(pam_error(handle, "pam_putenv", result));
}
}
Ok(())
}
fn apply_pam_environment(handle: *mut PamHandle, spec: &mut SessionSpec) -> Result<(), Error> {
for name in PAM_CHILD_ENVIRONMENT_KEYS {
// SAFETY: The worker owns the live handle. Each name is a static C string.
let value = unsafe { pam_getenv(handle, name.as_ptr()) };
if value.is_null() {
continue;
}
// SAFETY: PAM owns this C string. Copy it before the next PAM call.
let value = unsafe { CStr::from_ptr(value) }
.to_str()
.map_err(|_| Error::Session("PAM session environment is not UTF-8".into()))?
.to_owned();
let key = name
.to_str()
.map_err(|_| Error::Session("PAM environment key is not UTF-8".into()))?;
if let Some((_, existing)) = spec
.environment
.iter_mut()
.find(|(existing, _)| existing == key)
{
*existing = value;
} else {
spec.environment.push((key.to_owned(), value));
}
}
Ok(())
}
fn authenticate(
username: &str,
password: Zeroizing<Vec<u8>>,
@@ -378,8 +445,7 @@ fn authenticate(
&mut handle,
)
};
// Reconstitute the allocation on every path after pam_start. PAM copies the
// conversation structure, but does not own appdata_ptr.
// SAFETY: `data` came from `Box::into_raw`. PAM borrows `appdata_ptr`.
let conversation = unsafe { Box::from_raw(data) };
if started != PAM_SUCCESS {
let error = pam_error(handle, "pam_start", started);
@@ -431,7 +497,7 @@ fn authenticate(
}
session.credentials_established = true;
// Authentication is complete. Do not retain the password for the session.
// Erase the password before the PAM session remains open.
session.conversation.password.zeroize();
let identity = session
.identity

View File

@@ -0,0 +1,133 @@
use std::env;
use latchd_auth::AuthService;
use latchd_core::{
accounts::Identity,
config::Config,
error::Error,
session::{self, SessionDescriptor, SessionKind},
};
fn fixture_service() -> String {
env::var("LATCHD_PAM_FIXTURE_SERVICE")
.expect("LATCHD_PAM_FIXTURE_SERVICE must point at the disposable PAM fixture")
}
fn session_fixture_service() -> String {
env::var("LATCHD_PAM_SESSION_FIXTURE_SERVICE")
.expect("LATCHD_PAM_SESSION_FIXTURE_SERVICE must point at the disposable PAM fixture")
}
fn assert_sanitized_pam_failure(result: Result<(), Error>, secret: &str) {
let error = result.expect_err("the fault-injection PAM fixture must reject the operation");
match error {
Error::Pam {
status, message, ..
} => {
assert_ne!(status, 0);
assert!(!message.contains(secret));
}
_ => panic!("fault-injection fixture returned a non-PAM error"),
}
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn authentication_fault_is_rejected_without_secret_leakage() {
let service = AuthService::new().expect("PAM worker should start");
let secret = "fixture-auth-secret";
let result = service.authenticate("root".into(), secret.as_bytes().to_vec(), fixture_service());
let error = match result {
Ok(_) => panic!("pam_deny fixture must reject authentication"),
Err(error) => error,
};
match error {
Error::Pam {
status, message, ..
} => {
assert_ne!(status, 0);
assert!(!message.contains(secret));
}
_ => panic!("fault-injection fixture returned a non-PAM error"),
}
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn password_change_fault_is_rejected_without_secret_leakage() {
let service = AuthService::new().expect("PAM worker should start");
assert_sanitized_pam_failure(
service.change_password(
"root".into(),
b"fixture-old-secret".to_vec(),
b"fixture-new-secret".to_vec(),
fixture_service(),
),
"fixture-new-secret",
);
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn selected_session_metadata_reaches_pam_before_open() {
let service = AuthService::new().expect("PAM worker should start");
let ticket = service
.authenticate("root".into(), Vec::new(), session_fixture_service())
.expect("permit fixture should authenticate root");
let identity = Identity {
username: "root".into(),
uid: 0,
gid: 0,
home: "/root".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "Niri fixture".into(),
argv: vec!["/bin/true".into()],
current_desktop: Some("niri".into()),
session_desktop: Some("niri".into()),
};
let spec = session::build_spec(&identity, &Config::default(), &descriptor)
.expect("fixture session should build");
service
.run_session(ticket, spec)
.expect("PAM must receive the selected graphical session metadata");
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn pam_runtime_environment_reaches_the_session_child() {
let service = AuthService::new().expect("PAM worker should start");
let ticket = service
.authenticate("root".into(), Vec::new(), session_fixture_service())
.expect("permit fixture should authenticate root");
let identity = Identity {
username: "root".into(),
uid: 0,
gid: 0,
home: "/root".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "PAM environment fixture".into(),
argv: vec![
"/bin/sh".into(),
"-c".into(),
"test \"$XDG_SESSION_ID\" = fixture-session && \
test \"$XDG_RUNTIME_DIR\" = /tmp/latchd-runtime-fixture && \
test \"$DBUS_SESSION_BUS_ADDRESS\" = unix:path=/tmp/latchd-runtime-fixture/bus"
.into(),
],
current_desktop: Some("niri".into()),
session_desktop: Some("niri".into()),
};
let spec = session::build_spec(&identity, &Config::default(), &descriptor)
.expect("fixture session should build");
service
.run_session(ticket, spec)
.expect("the child must receive runtime values produced by PAM");
}

View File

@@ -4,7 +4,6 @@ use std::{io, mem, os::raw::c_char, ptr};
use crate::error::Error;
/// A live user-process record in UTMPX.
pub struct UtmpxGuard {
record: libc::utmpx,
closed: bool,

View File

@@ -14,90 +14,61 @@ use crate::{
error::{Error, ResultExt},
};
/// An account that can be offered by the login screen.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
/// Canonical username.
pub username: String,
/// Numeric user ID.
pub uid: u32,
/// Primary group ID.
pub gid: u32,
/// Home directory.
pub home: PathBuf,
/// Login shell.
pub shell: PathBuf,
}
/// Effective account state determined from the login shell and shadow aging data.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountState {
/// Account can be offered for login.
Active,
/// Password is disabled by the shadow lock marker.
Locked,
/// Password must be changed before login can continue.
PasswordExpired,
/// Account expiration date has elapsed.
AccountExpired,
/// Password inactivity period has elapsed.
AccountInactive,
/// Login shell explicitly disallows interactive sessions.
NoLoginShell,
/// Shadow data was not available or could not be parsed.
Unknown,
}
/// Non-sensitive password and account aging information.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountStatus {
/// Effective state used by the chooser and diagnostics.
pub state: AccountState,
/// Day the password was last changed, if present.
pub password_changed_day: Option<i64>,
/// Day the password expires, if the policy specifies one.
pub password_expiry_day: Option<i64>,
/// Day the account expires, if specified.
pub account_expiry_day: Option<i64>,
/// Day after which an expired password disables the account.
pub inactive_after_day: Option<i64>,
}
impl AccountStatus {
/// Returns whether this account may be presented as an active login choice.
pub const fn is_login_allowed(self) -> bool {
matches!(self.state, AccountState::Active)
}
}
/// An identity paired with its non-sensitive account policy state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
/// Discovered NSS identity.
pub identity: Identity,
/// Current local account status.
pub status: AccountStatus,
}
/// A currently logged-in process obtained from UTMPX.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveSession {
/// Login name associated with the session.
pub username: String,
/// UTMPX line, such as `tty2` or `pts/0`.
pub terminal: String,
/// Session leader PID when reported by UTMPX.
pub pid: libc::pid_t,
/// Remote host, if supplied by the session producer.
pub host: Option<String>,
}
/// Runtime session-management backend detected on this host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionBackend {
/// systemd-logind runtime markers are available.
SystemdLogind,
/// UTMPX is available as a conservative fallback.
Utmpx,
}
@@ -113,15 +84,10 @@ struct ShadowEntry {
/// A non-authoritative account lockout summary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FaillockStatus {
/// Whether the faillock helper returned a usable result.
pub available: bool,
/// Number of observed failed attempts.
pub failures: u32,
/// Configured failure threshold, when discoverable.
pub max_tries: Option<u32>,
/// Remaining attempts before the advisory threshold.
pub remaining: Option<u32>,
/// Whether the advisory threshold has been reached.
pub locked_out: bool,
}
@@ -291,12 +257,8 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<Faillo
Ok(faillock_status_from_output(&output.stdout, max_tries))
}
/// Converts stable helper output into an advisory lockout status.
///
/// This parser accepts both the legacy `count timestamp` format and modern
/// rows containing a username prefix or timestamp columns. It intentionally
/// treats malformed output as zero failures instead of making authentication
/// fail open or fail closed.
/// Parses the legacy count format and current rows with usernames or timestamps.
/// Malformed output reports zero failures and does not control authentication.
pub fn faillock_status_from_output(output: &[u8], max_tries: Option<u32>) -> FaillockStatus {
let failures = parse_failure_count(output);
let remaining = max_tries.map(|limit| limit.saturating_sub(failures));

View File

@@ -1,8 +1,6 @@
//! 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.
//! Audit records exclude secrets, PAM messages, and command arguments.
use std::fmt;
@@ -11,11 +9,8 @@ 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,
}
@@ -32,13 +27,9 @@ impl fmt::Display for AuditAction {
/// 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,
}
@@ -56,21 +47,13 @@ impl fmt::Display for AuditOutcome {
/// 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,
}
@@ -92,18 +75,14 @@ impl fmt::Display for PamReason {
/// 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,
@@ -113,7 +92,6 @@ impl<'a> AuditEvent<'a> {
}
}
/// Creates a successful event.
pub const fn succeeded(action: AuditAction, account: &'a str) -> Self {
Self {
action,
@@ -123,7 +101,6 @@ impl<'a> AuditEvent<'a> {
}
}
/// Creates a failure event with a sanitized PAM category.
pub fn failed(action: AuditAction, account: &'a str, error: &Error) -> Self {
Self {
action,

View File

@@ -9,33 +9,20 @@ use serde::Deserialize;
use crate::error::{Error, ResultExt};
/// Validated application configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
/// Virtual terminal number used by the service.
pub tty: u8,
/// Main log file.
pub main_log_path: PathBuf,
/// Per-session log file.
pub client_log_path: PathBuf,
/// PAM service name.
pub pam_service: String,
/// Default shell used only when the account has no shell value.
pub system_shell: PathBuf,
/// Session PATH.
pub initial_path: String,
/// X11 settings.
pub x11: X11Config,
/// Wayland settings.
pub wayland: WaylandConfig,
/// Environment switcher settings.
pub environment_switcher: SwitcherConfig,
/// Power actions shown by the login screen.
pub power_controls: PowerControls,
/// Account enumeration and advisory lockout settings.
pub accounts: AccountsConfig,
/// TUI-only background, animation, and notification settings.
pub visual: VisualConfig,
}
@@ -58,21 +45,14 @@ impl Default for Config {
}
}
/// Account discovery and faillock settings.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AccountsConfig {
/// Minimum UID shown by automatic account discovery.
pub minimum_uid: u32,
/// Usernames excluded from the chooser.
pub excluded_users: Vec<String>,
/// Whether the chooser lists NSS accounts.
pub enumerate: bool,
/// Absolute faillock helper path.
pub faillock_path: PathBuf,
/// faillock configuration used to read the deny threshold.
pub faillock_config: PathBuf,
/// Shadow database used for local password and account aging checks.
pub shadow_path: PathBuf,
}
@@ -89,21 +69,14 @@ impl Default for AccountsConfig {
}
}
/// Visual effects used only by the terminal login screen.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct VisualConfig {
/// Optional root-owned image used as a low-contrast terminal wallpaper.
pub wallpaper_path: Option<PathBuf>,
/// Enable the wallpaper and procedural background layer.
pub background_enabled: bool,
/// Advance animated background and spinner frames.
pub animations: bool,
/// Show transient in-TUI notifications for user-visible events.
pub notifications: bool,
/// Disable motion while retaining static visual styling.
pub reduced_motion: bool,
/// Maximum wallpaper file size accepted before decoding.
pub max_wallpaper_bytes: u64,
}
@@ -120,23 +93,15 @@ impl Default for VisualConfig {
}
}
/// X11 session configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct X11Config {
/// X display name.
pub display: String,
/// X server executable.
pub server_path: PathBuf,
/// Xauthority executable.
pub xauth_path: PathBuf,
/// X server startup timeout.
pub server_timeout_secs: u16,
/// X server log.
pub server_log_path: PathBuf,
/// Desktop-entry directory.
pub sessions_path: PathBuf,
/// Custom script directory.
pub scripts_path: PathBuf,
}
@@ -154,13 +119,10 @@ impl Default for X11Config {
}
}
/// Wayland session configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WaylandConfig {
/// Desktop-entry directory.
pub sessions_path: PathBuf,
/// Custom script directory.
pub scripts_path: PathBuf,
}
@@ -173,14 +135,13 @@ impl Default for WaylandConfig {
}
}
/// Session switcher settings.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SwitcherConfig {
/// Include the fallback TTY shell in the chooser.
pub include_tty_shell: bool,
/// Maximum visible title width.
pub max_display_length: usize,
pub remember_last_session: bool,
pub last_session_path: PathBuf,
}
impl Default for SwitcherConfig {
@@ -188,29 +149,24 @@ impl Default for SwitcherConfig {
Self {
include_tty_shell: false,
max_display_length: 18,
remember_last_session: true,
last_session_path: PathBuf::from("/var/lib/latchd/last-session.toml"),
}
}
}
/// Power actions available to the login screen.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PowerControls {
/// Built-in or administrator-defined actions.
pub entries: Vec<PowerAction>,
}
/// A power action represented as an executable argv vector.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PowerAction {
/// Displayed key, such as `F1`.
pub key: String,
/// Displayed label.
pub hint: String,
/// Executable path.
pub program: PathBuf,
/// Arguments passed without shell interpretation.
#[serde(default)]
pub args: Vec<String>,
}
@@ -287,6 +243,13 @@ pub fn validate(config: &Config) -> Result<(), Error> {
"environment_switcher.max_display_length must be greater than zero".into(),
));
}
if config.environment_switcher.remember_last_session
&& !config.environment_switcher.last_session_path.is_absolute()
{
return Err(Error::Config(
"environment_switcher.last_session_path must be absolute".into(),
));
}
for (name, path) in [
("main_log_path", &config.main_log_path),
("client_log_path", &config.client_log_path),

View File

@@ -2,47 +2,32 @@ use std::io;
use thiserror::Error;
/// The application-wide error type.
#[derive(Debug, Error)]
pub enum Error {
/// A command-line or configuration value was invalid.
#[error("configuration: {0}")]
Config(String),
/// A filesystem operation failed.
#[error("{context}: {source}")]
Io {
/// The operation being performed.
context: String,
/// The underlying error.
#[source]
source: io::Error,
},
/// TOML could not be decoded.
#[error("parse {path}: {source}")]
Toml {
/// The file being parsed.
path: String,
/// The parser error.
#[source]
source: toml::de::Error,
},
/// PAM rejected or could not complete an operation.
#[error("PAM {operation} failed with status {status}: {message}")]
Pam {
/// The PAM operation.
operation: &'static str,
/// The numeric PAM status.
status: i32,
/// The human-readable PAM message.
message: String,
},
/// A session process failed.
#[error("session: {0}")]
Session(String),
/// The terminal could not be initialized or restored.
#[error("terminal: {0}")]
Terminal(String),
/// An authentication worker request was cancelled or disconnected.
#[error("authentication worker: {0}")]
Worker(String),
}
@@ -62,7 +47,6 @@ impl Error {
}
}
/// Adds a stable operation label to an I/O error.
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
Error::Io {
context: context.into(),
@@ -70,9 +54,7 @@ pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
}
}
/// Converts a failed result into an application error with context.
pub trait ResultExt<T> {
/// Adds context to an I/O operation.
fn io_context(self, context: impl Into<String>) -> Result<T, Error>;
}

View File

@@ -1,17 +1,11 @@
//! Core configuration, account, session, and accounting contracts for latchd.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
/// Linux UTMPX accounting primitives.
pub mod accounting;
/// NSS account discovery and faillock status.
pub mod accounts;
/// Redacted, structured security audit events.
pub mod audit;
/// Validated application configuration.
pub mod config;
/// Structured application errors.
pub mod error;
/// Session discovery and process lifecycle.
pub mod preferences;
pub mod session;

View File

@@ -0,0 +1,253 @@
//! Bounded, owner-checked persistence for last-used session choices.
use std::{
fs::{self, OpenOptions},
io::Write,
os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
path::Path,
time::{SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use crate::{
error::{Error, ResultExt, io_error},
session::{SessionDescriptor, SessionKind},
};
const MAX_ENTRIES: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LastUsedEntry {
pub username: String,
pub kind: SessionKind,
pub title: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LastUsedPreferences {
entries: Vec<LastUsedEntry>,
}
impl LastUsedPreferences {
/// Records a successful selection, moving the newest entry to the front.
pub fn remember(&mut self, username: &str, session: &SessionDescriptor) {
if username.is_empty() || session.title.is_empty() {
return;
}
self.entries.retain(|entry| entry.username != username);
self.entries.insert(
0,
LastUsedEntry {
username: username.to_owned(),
kind: session.kind,
title: session.title.clone(),
},
);
self.entries.truncate(MAX_ENTRIES);
}
/// Finds the exact previously selected session for an account.
pub fn preferred_index(&self, username: &str, sessions: &[SessionDescriptor]) -> Option<usize> {
let entry = self
.entries
.iter()
.find(|entry| entry.username == username)?;
sessions
.iter()
.position(|session| session.kind == entry.kind && session.title == entry.title)
}
pub fn entries(&self) -> &[LastUsedEntry] {
&self.entries
}
}
/// Loads preferences from an owner-checked TOML file.
pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
if !path.is_absolute() {
return Err(Error::Config("last-session path must be absolute".into()));
}
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(LastUsedPreferences::default());
}
Err(error) => return Err(io_error(format!("inspect {}", path.display()), error)),
};
if !trusted_file(&metadata) {
return Err(Error::Config(format!(
"last-session file must be an owner-only regular file: {}",
path.display()
)));
}
let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?;
let preferences =
toml::from_str::<LastUsedPreferences>(&raw).map_err(|source| Error::Toml {
path: path.display().to_string(),
source,
})?;
validate(&preferences, path)?;
Ok(preferences)
}
/// Atomically saves preferences with mode `0600` under a trusted directory.
pub fn save(path: &Path, preferences: &LastUsedPreferences) -> Result<(), Error> {
if !path.is_absolute() {
return Err(Error::Config("last-session path must be absolute".into()));
}
validate(preferences, path)?;
let parent = path.parent().ok_or_else(|| {
Error::Config(format!(
"last-session path has no parent: {}",
path.display()
))
})?;
fs::create_dir_all(parent).io_context(format!("create {}", parent.display()))?;
let parent_metadata =
fs::symlink_metadata(parent).io_context(format!("inspect {}", parent.display()))?;
if !trusted_directory(&parent_metadata) {
return Err(Error::Config(format!(
"last-session directory must be owner-owned and non-writable: {}",
parent.display()
)));
}
let content = toml::to_string_pretty(preferences)
.map_err(|error| Error::Config(format!("serialize last-session preferences: {error}")))?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let temporary = parent.join(format!(
".{}.tmp.{}.{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("last-session"),
std::process::id(),
nonce
));
let write_result = (|| -> Result<(), Error> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&temporary)
.io_context(format!("create {}", temporary.display()))?;
file.write_all(content.as_bytes())
.io_context(format!("write {}", temporary.display()))?;
file.sync_all()
.io_context(format!("sync {}", temporary.display()))?;
fs::rename(&temporary, path).io_context(format!("replace {}", path.display()))?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.io_context(format!("secure {}", path.display()))?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&temporary);
}
write_result
}
fn validate(preferences: &LastUsedPreferences, path: &Path) -> Result<(), Error> {
if preferences.entries.len() > MAX_ENTRIES {
return Err(Error::Config(format!(
"last-session file contains more than {MAX_ENTRIES} entries: {}",
path.display()
)));
}
if preferences
.entries
.iter()
.any(|entry| entry.username.is_empty() || entry.title.is_empty())
{
return Err(Error::Config(format!(
"last-session entries must have a username and title: {}",
path.display()
)));
}
Ok(())
}
fn trusted_file(metadata: &std::fs::Metadata) -> bool {
metadata.is_file() && metadata.uid() == effective_uid() && metadata.mode() & 0o077 == 0
}
fn trusted_directory(metadata: &std::fs::Metadata) -> bool {
metadata.is_dir() && metadata.uid() == effective_uid() && metadata.mode() & 0o022 == 0
}
fn effective_uid() -> u32 {
// SAFETY: geteuid has no pointer arguments or memory preconditions.
unsafe { libc::geteuid() }
}
#[cfg(test)]
mod tests {
use super::*;
fn descriptor(kind: SessionKind, title: &str) -> SessionDescriptor {
SessionDescriptor {
kind,
title: title.into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}
}
fn test_path() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"latchd-preferences-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos())
))
}
#[test]
fn latest_successful_session_is_preferred_per_account() {
let wayland = descriptor(SessionKind::Wayland, "Sway");
let tty = descriptor(SessionKind::Tty, "TTY shell");
let mut preferences = LastUsedPreferences::default();
preferences.remember("alice", &wayland);
assert_eq!(
preferences.preferred_index("alice", &[tty.clone(), wayland]),
Some(1)
);
preferences.remember("alice", &tty);
assert_eq!(
preferences.preferred_index("alice", &[tty, descriptor(SessionKind::Wayland, "Sway")]),
Some(0)
);
assert_eq!(preferences.entries()[0].username, "alice");
}
#[test]
fn preferences_round_trip_atomically_with_private_permissions() {
let directory = test_path();
let path = directory.join("last-session.toml");
let session = descriptor(SessionKind::X11, "GNOME");
let mut preferences = LastUsedPreferences::default();
preferences.remember("alice", &session);
save(&path, &preferences).expect("preference save should succeed");
let loaded = load(&path).expect("preference load should succeed");
assert_eq!(loaded, preferences);
let mode = fs::symlink_metadata(&path)
.expect("preference metadata should exist")
.mode();
assert_eq!(mode & 0o077, 0);
let _ = fs::remove_dir_all(directory);
}
#[test]
fn missing_preferences_are_a_safe_empty_store() {
let path = test_path().join("missing.toml");
assert_eq!(
load(&path).expect("missing preferences are optional"),
LastUsedPreferences::default()
);
}
}

View File

@@ -26,72 +26,71 @@ use crate::{
error::{Error, ResultExt, io_error},
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use signal_hook::{consts::SIGUSR1, flag};
/// A session family supported by latchd.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionKind {
/// A login shell on the current virtual terminal.
Tty,
/// An X11 desktop session.
X11,
/// A Wayland desktop session.
Wayland,
}
/// A discoverable desktop or script session.
#[derive(Debug, Clone)]
pub struct SessionDescriptor {
/// Session family.
pub kind: SessionKind,
/// User-facing title.
pub title: String,
/// Program and arguments, without shell interpretation.
pub argv: Vec<String>,
/// XDG current-desktop value.
pub current_desktop: Option<String>,
/// XDG session-desktop value.
pub session_desktop: Option<String>,
}
/// A fully materialized session request.
#[derive(Debug, Clone)]
pub struct SessionSpec {
/// Session family.
pub kind: SessionKind,
/// Session title for logs.
pub title: String,
/// Program and arguments.
pub argv: Vec<String>,
/// Explicit child environment.
pub environment: Vec<(String, String)>,
/// Working directory.
pub working_directory: PathBuf,
/// Private stdout/stderr log for graphical sessions.
pub client_log_path: PathBuf,
/// Login shell for TTY fallback.
pub shell: PathBuf,
/// Virtual terminal number.
pub tty: u8,
/// X11 configuration when this is an X11 session.
pub x11: Option<X11Runtime>,
}
/// Runtime X11 parameters passed to a session.
#[derive(Debug, Clone)]
pub struct X11Runtime {
/// X display name.
pub display: String,
/// X server command.
pub server_path: PathBuf,
/// Xauthority command.
pub xauth_path: PathBuf,
/// X server timeout.
pub timeout: Duration,
/// X server log path.
pub log_path: PathBuf,
}
const PAM_SESSION_ENVIRONMENT_KEYS: [&str; 5] = [
"XDG_SESSION_CLASS",
"XDG_SESSION_TYPE",
"XDG_SESSION_DESKTOP",
"XDG_SEAT",
"XDG_VTNR",
];
impl SessionSpec {
pub fn pam_tty(&self) -> String {
format!("tty{}", self.tty)
}
/// Returns the selected-session metadata that PAM and logind must see.
pub fn pam_environment(&self) -> impl Iterator<Item = (&str, &str)> {
self.environment
.iter()
.filter(|(key, _)| PAM_SESSION_ENVIRONMENT_KEYS.contains(&key.as_str()))
.map(|(key, value)| (key.as_str(), value.as_str()))
}
}
/// Discovers configured sessions in stable directory order.
pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
let mut sessions = Vec::new();
@@ -236,7 +235,6 @@ pub fn build_spec(
"XDG_RUNTIME_DIR",
format!("/run/user/{}", identity.uid),
);
insert_env(&mut environment, "XDG_SESSION_ID", "latchd".into());
insert_env(
&mut environment,
"XDG_CONFIG_HOME",
@@ -858,4 +856,40 @@ mod tests {
.all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY")
);
}
#[test]
fn selected_wayland_metadata_is_ready_for_pam() {
let identity = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "Niri".into(),
argv: vec!["/usr/bin/niri-session".into()],
current_desktop: Some("niri".into()),
session_desktop: Some("niri".into()),
};
let spec = build_spec(&identity, &Config::default(), &descriptor).expect("valid spec");
let pam_environment = spec
.pam_environment()
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(spec.pam_tty(), "tty2");
assert_eq!(pam_environment.get("XDG_SESSION_CLASS"), Some(&"user"));
assert_eq!(pam_environment.get("XDG_SESSION_TYPE"), Some(&"wayland"));
assert_eq!(pam_environment.get("XDG_SESSION_DESKTOP"), Some(&"niri"));
assert_eq!(pam_environment.get("XDG_SEAT"), Some(&"seat0"));
assert_eq!(pam_environment.get("XDG_VTNR"), Some(&"2"));
assert!(
spec.environment
.iter()
.all(|(key, _)| key != "XDG_SESSION_ID"),
"logind, not latchd, owns the session identifier"
);
}
}

View File

@@ -23,7 +23,6 @@ const MAX_ANIMATION_WIDTH: usize = 160;
const MAX_ANIMATION_HEIGHT: usize = 64;
const FRAME_INTERVAL: Duration = Duration::from_millis(120);
/// Background state owned by the TUI thread.
pub(crate) struct Background {
wallpaper: Option<RgbImage>,
automata: Automata,
@@ -34,7 +33,6 @@ pub(crate) struct Background {
}
impl Background {
/// Loads a bounded wallpaper image and prepares the procedural layer.
pub(crate) fn load(config: &VisualConfig) -> Result<Self, Error> {
let wallpaper = config
.wallpaper_path
@@ -191,8 +189,7 @@ struct Automata {
impl Automata {
fn resize(&mut self, width: usize, height: usize) {
// Keep procedural state bounded even when a terminal reports an
// unusually large window. Rendering remains clipped to this layer.
// Cap the state size. Some terminal emulators report extreme dimensions.
let width = width.min(MAX_ANIMATION_WIDTH);
let height = height.min(MAX_ANIMATION_HEIGHT);
if self.width == width && self.height == height {

View File

@@ -21,6 +21,7 @@ use latchd_core::{
audit::{AuditAction, AuditEvent},
config::Config,
error::{Error, ResultExt},
preferences::{self, LastUsedPreferences},
session::{self, SessionDescriptor},
};
@@ -41,11 +42,8 @@ fn unknown_account_status() -> AccountStatus {
}
}
/// The result of one completed login-screen interaction.
pub struct LoginSelection {
/// Authenticated PAM ticket.
pub ticket: AuthTicket,
/// Selected session descriptor.
pub session: SessionDescriptor,
}
@@ -130,6 +128,8 @@ struct App {
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
refreshing: bool,
high_contrast: bool,
preferences: LastUsedPreferences,
preference_path: Option<std::path::PathBuf>,
}
impl App {
@@ -181,6 +181,8 @@ impl App {
refresh_receiver: None,
refreshing: false,
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
preferences: LastUsedPreferences::default(),
preference_path: None,
}
}
@@ -258,6 +260,7 @@ impl App {
.get(self.selected)
.cloned()
.ok_or_else(|| Error::Session("no session selected".into()))?;
self.remember_selected_session(&selected);
Ok(Some(LoginSelection {
ticket,
session: selected,
@@ -334,6 +337,25 @@ impl App {
);
}
fn apply_preferred_session(&mut self) {
if let Some(index) = self
.preferences
.preferred_index(&self.username, &self.sessions)
{
self.selected = index;
}
}
fn remember_selected_session(&mut self, session: &SessionDescriptor) {
self.preferences.remember(&self.username, session);
let Some(path) = self.preference_path.clone() else {
return;
};
if let Err(error) = preferences::save(&path, &self.preferences) {
tracing::debug!(%error, "could not persist last-session preference");
}
}
fn poll_faillock(&mut self) {
let Some(receiver) = &self.faillock_receiver else {
return;
@@ -995,6 +1017,7 @@ impl App {
self.auth_blocked_until = None;
self.faillock_status = None;
self.status = format!("Selected account {}", self.username);
self.apply_preferred_session();
self.start_faillock_query();
self.notify(
&format!("Selected account {}", self.username),
@@ -1368,9 +1391,25 @@ pub fn run(
(Vec::new(), Vec::new())
};
let background = Background::load(&config.visual)?;
let preference_path = config
.environment_switcher
.remember_last_session
.then(|| config.environment_switcher.last_session_path.clone());
let preferences =
preference_path
.as_deref()
.map_or_else(LastUsedPreferences::default, |path| {
preferences::load(path).unwrap_or_else(|error| {
tracing::debug!(%error, "could not load last-session preferences");
LastUsedPreferences::default()
})
});
let mut app = App::new(config, auth, preview, sessions, accounts, background);
app.account_statuses = account_statuses;
app.account_index = (!app.accounts.is_empty()).then_some(0);
app.preferences = preferences;
app.preference_path = preference_path;
app.apply_preferred_session();
app.start_faillock_query();
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
}
@@ -1654,6 +1693,54 @@ mod tests {
assert!(rendered.contains("New password"));
}
#[test]
fn last_session_preference_follows_account_and_persists_success() {
let auth = AuthService::new().expect("PAM worker should start");
let sessions = vec![
SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
},
SessionDescriptor {
kind: session::SessionKind::Wayland,
title: "Sway".into(),
argv: vec!["sway".into()],
current_desktop: None,
session_desktop: None,
},
];
let mut app = App::new(
Config::default(),
auth,
true,
sessions,
vec![Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
}],
Background::load(&Config::default().visual).expect("default background should load"),
);
app.preferences.remember("alice", &app.sessions[1]);
app.apply_preferred_session();
assert_eq!(app.selected, 1);
let directory =
std::env::temp_dir().join(format!("latchd-tui-preferences-{}", std::process::id()));
app.preference_path = Some(directory.join("last-session.toml"));
let selected = app.sessions[1].clone();
app.remember_selected_session(&selected);
let loaded = preferences::load(&directory.join("last-session.toml"))
.expect("selected session should persist");
assert_eq!(loaded.preferred_index("alice", &app.sessions), Some(1));
let _ = std::fs::remove_dir_all(directory);
}
#[test]
fn filtering_is_smart_case_and_selects_a_matching_session() {
let auth = AuthService::new().expect("PAM worker should start");