feat: handle PAM password policy changes
This commit is contained in:
@@ -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)) };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user