feat: complete session preferences and PAM fixture gates
This commit is contained in:
@@ -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
|
||||
|
||||
133
crates/latchd-auth/tests/pam_fixture.rs
Normal file
133
crates/latchd-auth/tests/pam_fixture.rs
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user