feat: handle PAM password policy changes

This commit is contained in:
2026-09-26 01:50:02 +05:30
parent 9492dbd7c9
commit 83345a1842
12 changed files with 697 additions and 57 deletions

View File

@@ -27,6 +27,7 @@ const PAM_SYSTEM_ERR: c_int = 4;
const PAM_BUF_ERR: c_int = 5;
const PAM_CONV_ERR: c_int = 19;
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_ON: c_int = 2;
@@ -76,6 +77,8 @@ unsafe extern "C" {
) -> c_int;
#[link_name = "pam_authenticate"]
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"]
fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_setcred"]
@@ -119,6 +122,13 @@ enum Command {
service: String,
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 {
id: u64,
spec: SessionSpec,
@@ -170,6 +180,33 @@ impl AuthService {
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.
pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> {
let (response, result) = sync_channel(0);
@@ -196,6 +233,8 @@ impl AuthService {
struct ConversationData {
username: CString,
password: Zeroizing<Vec<u8>>,
new_password: Option<Zeroizing<Vec<u8>>>,
new_password_prompts: usize,
}
impl Drop for ConversationData {
@@ -261,6 +300,16 @@ fn worker(requests: Receiver<Command>) {
authenticate(&username, password, &service, &mut next_id, &mut sessions);
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 } => {
let result = match sessions.remove(&id) {
Some(mut session) => {
@@ -312,6 +361,8 @@ fn authenticate(
let conversation = Box::new(ConversationData {
username: username_c.clone(),
password,
new_password: None,
new_password_prompts: 0,
});
let data = Box::into_raw(conversation);
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> {
let name = CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?;
let passwd = unsafe { libc::getpwnam(name.as_ptr()) };
@@ -456,7 +572,7 @@ unsafe extern "C" fn converse(
if output.is_null() {
return PAM_BUF_ERR;
}
let context = &*(data.cast::<ConversationData>());
let context = &mut *(data.cast::<ConversationData>());
for index in 0..count {
let message = *messages.add(index);
if message.is_null() {
@@ -464,7 +580,18 @@ unsafe extern "C" fn converse(
return PAM_CONV_ERR;
}
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_ERROR_MSG | PAM_TEXT_INFO => None,
_ => {
@@ -524,6 +651,8 @@ mod tests {
let context = Box::new(ConversationData {
username: CString::new("alice").unwrap(),
password: Zeroizing::new(b"secret".to_vec()),
new_password: None,
new_password_prompts: 0,
});
let pointer = Box::into_raw(context);
let message = PamMessage {
@@ -545,4 +674,36 @@ mod tests {
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)) };
}
}