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");
}