use std::ffi::{CStr, CString, c_char, c_int, c_void}; use std::io::{BufRead, BufReader, BufWriter, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; use std::sync::{Arc, mpsc}; use std::time::Duration; use glib::gobject_ffi; use libloading::Library; use zeroize::{Zeroize, Zeroizing}; use crate::{AuthenticationRequest, Generation, PromptEvent}; enum SessionEvent { Request(String, bool), Info(String), Error(String), Completed(bool), } type Object = *mut c_void; struct Api { _agent: Library, _polkit: Library, user_new: unsafe extern "C" fn(c_int) -> Object, session_new: unsafe extern "C" fn(Object, *const c_char) -> Object, initiate: unsafe extern "C" fn(Object), response: unsafe extern "C" fn(Object, *const c_char), cancel: unsafe extern "C" fn(Object), } impl Api { fn load() -> Result { unsafe { let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?; let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?; let user_new = *polkit.get(b"polkit_unix_user_new\0").map_err(|e| e.to_string())?; let session_new = *agent.get(b"polkit_agent_session_new\0").map_err(|e| e.to_string())?; let initiate = *agent .get(b"polkit_agent_session_initiate\0") .map_err(|e| e.to_string())?; let response = *agent .get(b"polkit_agent_session_response\0") .map_err(|e| e.to_string())?; let cancel = *agent.get(b"polkit_agent_session_cancel\0").map_err(|e| e.to_string())?; Ok(Self { _agent: agent, _polkit: polkit, user_new, session_new, initiate, response, cancel, }) } } } pub fn check() -> Result<(), String> { Api::load().map(|_| ()) } pub(crate) fn username(uid: u32) -> String { let output = Command::new("getent").args(["passwd", &uid.to_string()]).output(); output .ok() .filter(|output| output.status.success()) .and_then(|output| String::from_utf8(output.stdout).ok()) .and_then(|entry| entry.split(':').next().map(str::to_owned)) .filter(|name| !name.is_empty()) .unwrap_or_else(|| uid.to_string()) } fn send(writer: &mut BufWriter, event: &PromptEvent) -> Result<(), String> { serde_json::to_writer(&mut *writer, event).map_err(|e| e.to_string())?; writer.write_all(b"\n").map_err(|e| e.to_string())?; writer.flush().map_err(|e| e.to_string()) } struct PromptProcess { child: Child, writer: BufWriter, input: mpsc::Receiver, } impl Drop for PromptProcess { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } fn prompt_process(request: AuthenticationRequest) -> Result { let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?) .arg("--prompt") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() .map_err(|e| e.to_string())?; let stdin = child.stdin.take().ok_or("Prompt has no input")?; let stdout = child.stdout.take().ok_or("Prompt has no output")?; let writer = BufWriter::new(stdin); let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { for line in BufReader::new(stdout).lines() { let Ok(mut line) = line else { break }; if let Ok(event) = serde_json::from_str(&line) { line.zeroize(); if tx.send(event).is_err() { break; } } else { line.zeroize(); } } }); let mut process = PromptProcess { child, writer, input: rx, }; send(&mut process.writer, &PromptEvent::Start(request))?; Ok(process) } unsafe extern "C" fn request_signal(_session: Object, prompt: *const c_char, echo: c_int, data: *mut c_void) { let tx = unsafe { &*(data as *const Sender) }; let prompt = unsafe { CStr::from_ptr(prompt) }.to_string_lossy().into_owned(); let _ = tx.send(SessionEvent::Request(prompt, echo != 0)); } unsafe extern "C" fn info(_session: Object, text: *const c_char, data: *mut c_void) { let tx = unsafe { &*(data as *const Sender) }; let text = unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned(); let _ = tx.send(SessionEvent::Info(text)); } unsafe extern "C" fn error(_session: Object, text: *const c_char, data: *mut c_void) { let tx = unsafe { &*(data as *const Sender) }; let text = unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned(); let _ = tx.send(SessionEvent::Error(text)); } unsafe extern "C" fn completed(_session: Object, success: c_int, data: *mut c_void) { let tx = unsafe { &*(data as *const Sender) }; let _ = tx.send(SessionEvent::Completed(success != 0)); } unsafe fn connect(session: Object, name: &'static CStr, callback: gobject_ffi::GCallback, data: *mut c_void) { unsafe { gobject_ffi::g_signal_connect_data( session.cast(), name.as_ptr(), callback, data, None, gobject_ffi::G_CONNECT_DEFAULT, ); } } #[derive(Debug, PartialEq, Eq)] enum SessionOutcome { Completed(bool), Cancelled, IdentityChanged { uid: u32, generation: Generation }, } struct SessionGuard { object: Object, signal_data: *mut Sender, cancel: unsafe extern "C" fn(Object), completed: bool, } impl Drop for SessionGuard { fn drop(&mut self) { unsafe { if !self.completed { (self.cancel)(self.object); } gobject_ffi::g_object_unref(self.object.cast()); drop(Box::from_raw(self.signal_data)); } } } fn permitted_selection(request: &AuthenticationRequest, uid: u32) -> bool { request.identities.iter().any(|identity| identity.uid == uid) } fn response_buffer(value: &str) -> Option>> { if value.as_bytes().contains(&0) { return None; } // Allocate room for the terminator up front so appending it cannot leave // a password copy in an old allocation. Drop wipes the entire FFI buffer. let mut response = Zeroizing::new(Vec::with_capacity(value.len() + 1)); response.extend_from_slice(value.as_bytes()); response.push(0); Some(response) } fn run_session( api: &Api, request: &AuthenticationRequest, cookie: &CString, context: &glib::MainContext, process: &mut PromptProcess, cancelled: &AtomicBool, ) -> Result { let generation = request.generation; send( &mut process.writer, &PromptEvent::IdentityChanged { generation, uid: request.selected_uid, }, )?; if generation.attempt > 0 { send( &mut process.writer, &PromptEvent::Error { generation, text: "Password not accepted. Try again.".into(), }, )?; } let identity = unsafe { (api.user_new)(request.selected_uid as c_int) }; if identity.is_null() { return Err("Could not create polkit identity".into()); } let session = unsafe { (api.session_new)(identity, cookie.as_ptr()) }; unsafe { gobject_ffi::g_object_unref(identity.cast()) }; if session.is_null() { return Err("Could not create polkit session".into()); } let (tx, rx) = mpsc::channel(); let signal_data = Box::into_raw(Box::new(tx)); let mut session_guard = SessionGuard { object: session, signal_data, cancel: api.cancel, completed: false, }; let signal_data = signal_data.cast(); unsafe { connect( session, c"request", Some(std::mem::transmute::< unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void), unsafe extern "C" fn(), >( request_signal as unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void), )), signal_data, ); connect( session, c"show-info", Some(std::mem::transmute::< unsafe extern "C" fn(Object, *const c_char, *mut c_void), unsafe extern "C" fn(), >( info as unsafe extern "C" fn(Object, *const c_char, *mut c_void) )), signal_data, ); connect( session, c"show-error", Some(std::mem::transmute::< unsafe extern "C" fn(Object, *const c_char, *mut c_void), unsafe extern "C" fn(), >( error as unsafe extern "C" fn(Object, *const c_char, *mut c_void) )), signal_data, ); connect( session, c"completed", Some(std::mem::transmute::< unsafe extern "C" fn(Object, c_int, *mut c_void), unsafe extern "C" fn(), >( completed as unsafe extern "C" fn(Object, c_int, *mut c_void) )), signal_data, ); (api.initiate)(session); } let mut outcome = None; let mut stopped = false; let mut selected_uid = None; while outcome.is_none() { while context.pending() { context.iteration(false); } while let Ok(event) = rx.try_recv() { match event { SessionEvent::Request(prompt, echo) => { send( &mut process.writer, &PromptEvent::Request { generation, prompt, echo, }, )?; } SessionEvent::Info(text) => send(&mut process.writer, &PromptEvent::Info { generation, text })?, SessionEvent::Error(text) => send(&mut process.writer, &PromptEvent::Error { generation, text })?, SessionEvent::Completed(success) => outcome = Some(success), } } if outcome.is_some() { break; } while let Ok(event) = process.input.try_recv() { match event { PromptEvent::Response { generation: response_generation, uid, mut value, } => { if uid == request.selected_uid && response_generation == generation && !stopped && selected_uid.is_none() && let Some(response) = response_buffer(&value) { value.zeroize(); unsafe { (api.response)(session, response.as_ptr().cast()) }; } value.zeroize(); } PromptEvent::SelectIdentity { generation: selection_generation, uid, } if permitted_selection(request, uid) && selection_generation.attempt == 0 && selection_generation.selection > generation.selection && selected_uid.is_none_or(|(_, pending): (u32, Generation)| { selection_generation.selection > pending.selection }) && (!stopped || selected_uid.is_some()) => { selected_uid = Some((uid, selection_generation)); } PromptEvent::Cancel => cancelled.store(true, Ordering::Release), _ => {} } } if !stopped && (selected_uid.is_some() || cancelled.load(Ordering::Acquire) || process.child.try_wait().map_err(|e| e.to_string())?.is_some()) { unsafe { (api.cancel)(session) }; stopped = true; } std::thread::sleep(Duration::from_millis(10)); } session_guard.completed = true; if cancelled.load(Ordering::Acquire) { Ok(SessionOutcome::Cancelled) } else if let Some((uid, generation)) = selected_uid { Ok(SessionOutcome::IdentityChanged { uid, generation }) } else if stopped { Ok(SessionOutcome::Cancelled) } else { Ok(SessionOutcome::Completed(outcome.unwrap())) } } pub fn authenticate( mut request: AuthenticationRequest, cookie: String, cancelled: Arc, ) -> Result { if cancelled.load(Ordering::Acquire) { return Ok(false); } let api = Api::load()?; let cookie = CString::new(cookie).map_err(|_| "Invalid authentication cookie")?; let context = glib::MainContext::new(); context .with_thread_default(|| { let mut process = prompt_process(request.clone())?; authenticate_sessions(&api, &mut request, &cookie, &context, &mut process, &cancelled) }) .map_err(|error| error.to_string())? } const MAX_AUTH_ATTEMPTS: usize = 3; fn authenticate_sessions( api: &Api, request: &mut AuthenticationRequest, cookie: &CString, context: &glib::MainContext, process: &mut PromptProcess, cancelled: &AtomicBool, ) -> Result { let mut failures = 0; loop { match run_session(api, request, cookie, context, process, cancelled) { Ok(SessionOutcome::Completed(true)) => break Ok(true), Ok(SessionOutcome::IdentityChanged { uid, generation }) if !cancelled.load(Ordering::Acquire) => { request.selected_uid = uid; request.generation = generation; } Ok(SessionOutcome::Completed(false)) if !cancelled.load(Ordering::Acquire) => { failures += 1; // Switching accounts never resets this request's attempt budget. if failures >= MAX_AUTH_ATTEMPTS { break Err(format!("Authentication failed after {MAX_AUTH_ATTEMPTS} attempts")); } request.generation.attempt += 1; } Ok(_) => break Ok(false), Err(error) => break Err(error), } } } #[cfg(test)] mod tests { use super::*; use crate::tests::request_fixture; static RESPONSES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); unsafe extern "C" fn fake_initiate(session: Object) { unsafe { gobject_ffi::g_signal_emit_by_name(session.cast(), c"request".as_ptr(), c"Password:".as_ptr(), 0 as c_int); } } unsafe extern "C" fn fake_cancel(session: Object) { unsafe { gobject_ffi::g_signal_emit_by_name(session.cast(), c"completed".as_ptr(), 0 as c_int); } } unsafe extern "C" fn fake_response(session: Object, _value: *const c_char) { RESPONSES.fetch_add(1, Ordering::SeqCst); unsafe { gobject_ffi::g_signal_emit_by_name(session.cast(), c"completed".as_ptr(), 1 as c_int); } } #[test] fn retries_stop_after_three_failures_even_when_the_account_changes() { let mut api = Api::load().unwrap(); api.initiate = fake_initiate; api.response = fake_failed_response; api.cancel = fake_cancel; let context = glib::MainContext::new(); context .with_thread_default(|| { let mut child = Command::new("cat") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .unwrap(); let writer = BufWriter::new(child.stdin.take().unwrap()); let output = child.stdout.take().unwrap(); let (tx, input) = mpsc::channel(); let starts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let counter = starts.clone(); let prompt = std::thread::spawn(move || { let mut uid = 1000; for line in BufReader::new(output).lines() { let Ok(line) = line else { break }; if let Ok(PromptEvent::Request { generation, .. }) = serde_json::from_str::(&line) { let count = counter.fetch_add(1, Ordering::SeqCst) + 1; let event = if count == 3 { uid = 1001; PromptEvent::SelectIdentity { uid, generation: Generation { selection: generation.selection + 1, attempt: 0, }, } } else { PromptEvent::Response { generation, uid, value: "incorrect".into(), } }; if tx.send(event).is_err() { break; } } } }); let mut process = PromptProcess { child, writer, input }; let mut request = request_fixture(); let result = authenticate_sessions( &api, &mut request, &CString::new("test-cookie").unwrap(), &context, &mut process, &AtomicBool::new(false), ); drop(process); prompt.join().unwrap(); assert_eq!(result, Err("Authentication failed after 3 attempts".into())); assert_eq!(starts.load(Ordering::SeqCst), 4); assert_eq!(request.selected_uid, 1001); }) .unwrap(); } unsafe extern "C" fn fake_failed_response(session: Object, _value: *const c_char) { unsafe { gobject_ffi::g_signal_emit_by_name(session.cast(), c"completed".as_ptr(), 0 as c_int); } } #[test] fn account_changes_cancel_old_sessions_and_reject_stale_passwords() { // Use actual GLib session objects and callbacks, with no PAM helper or // authority contacted. Only the session's initiate/response/cancel calls are fake. let mut api = Api::load().unwrap(); api.initiate = fake_initiate; api.response = fake_response; api.cancel = fake_cancel; let context = glib::MainContext::new(); context .with_thread_default(|| { let mut child = Command::new("sleep").arg("5").stdin(Stdio::piped()).spawn().unwrap(); let writer = BufWriter::new(child.stdin.take().unwrap()); let cookie = CString::new("test-cookie").unwrap(); let cancelled = AtomicBool::new(false); let mut request = request_fixture(); let (tx, input) = mpsc::channel(); let mut process = PromptProcess { child, writer, input }; tx.send(PromptEvent::SelectIdentity { uid: 1001, generation: Generation { selection: 1, attempt: 0, }, }) .unwrap(); tx.send(PromptEvent::Response { generation: Generation::default(), uid: 1000, value: "stale-response".into(), }) .unwrap(); assert_eq!( run_session(&api, &request, &cookie, &context, &mut process, &cancelled).unwrap(), SessionOutcome::IdentityChanged { uid: 1001, generation: Generation { selection: 1, attempt: 0 } } ); assert_eq!(RESPONSES.load(Ordering::SeqCst), 0); request.selected_uid = 1001; request.generation = Generation { selection: 1, attempt: 0, }; tx.send(PromptEvent::Response { generation: Generation::default(), uid: 1000, value: "stale-response".into(), }) .unwrap(); // Matching UID alone is insufficient: reject responses from a // previous account selection and from an earlier retry. tx.send(PromptEvent::Response { generation: Generation::default(), uid: 1001, value: "same-user-old-generation".into(), }) .unwrap(); request.generation.attempt = 1; tx.send(PromptEvent::Response { generation: Generation { attempt: 0, ..request.generation }, uid: 1001, value: "same-user-old-attempt".into(), }) .unwrap(); tx.send(PromptEvent::Response { generation: request.generation, uid: 1001, value: "new-response".into(), }) .unwrap(); assert_eq!( run_session(&api, &request, &cookie, &context, &mut process, &cancelled).unwrap(), SessionOutcome::Completed(true) ); assert_eq!(RESPONSES.load(Ordering::SeqCst), 1); tx.send(PromptEvent::Cancel).unwrap(); assert_eq!( run_session(&api, &request, &cookie, &context, &mut process, &cancelled).unwrap(), SessionOutcome::Cancelled ); }) .unwrap(); } #[test] fn account_switches_keep_the_latest_generation() { let mut api = Api::load().unwrap(); api.initiate = fake_initiate; api.cancel = fake_cancel; let context = glib::MainContext::new(); context .with_thread_default(|| { let mut child = Command::new("sleep").arg("5").stdin(Stdio::piped()).spawn().unwrap(); let writer = BufWriter::new(child.stdin.take().unwrap()); let (tx, input) = mpsc::channel(); let mut process = PromptProcess { child, writer, input }; for (selection, uid) in [(2, 1001), (3, 1000), (1, 1001), (2, 1001)] { tx.send(PromptEvent::SelectIdentity { generation: Generation { selection, attempt: 0 }, uid, }) .unwrap(); } assert_eq!( run_session( &api, &request_fixture(), &CString::new("test-cookie").unwrap(), &context, &mut process, &AtomicBool::new(false), ) .unwrap(), SessionOutcome::IdentityChanged { uid: 1000, generation: Generation { selection: 3, attempt: 0 } } ); }) .unwrap(); } #[test] fn ffi_response_is_nul_terminated_and_owned_by_a_zeroizing_buffer() { let response = response_buffer("test-password").unwrap(); assert_eq!( CStr::from_bytes_with_nul(&response).unwrap().to_bytes(), b"test-password" ); assert!(response_buffer("invalid\0password").is_none()); assert_eq!(response_buffer("").unwrap().as_slice(), &[0]); } #[test] fn account_switches_accept_only_offered_identities() { let request = request_fixture(); assert!(permitted_selection(&request, 1001)); assert!(permitted_selection(&request, 1000)); assert!(!permitted_selection(&request, 0)); assert!(!permitted_selection(&request, u32::MAX)); } }