git.lucas.co / cce-lock
session locker (ext-session-lock + PAM)
git clone https://git.lucas.co/cce-lock.git

src/auth.rs (9.8K)

  1 //! PAM credential checking for the locker.
  2 //!
  3 //! Deliberately the smallest thing that can answer "is this the person who
  4 //! owns this session": `pam_authenticate` followed by `pam_acct_mgmt`, and
  5 //! nothing else. In particular there is **no `pam_open_session`** — the
  6 //! session the locker is guarding already exists, and opening a second one
  7 //! from here would run the session stack's side effects (pam_gnome_keyring's
  8 //! `auto_start` forks out of this multi-threaded Vulkan process) for no
  9 //! reason. The greeter hit exactly that and froze on "Authenticating..." with
 10 //! the password already accepted; here the equivalent freeze would leave the
 11 //! screen locked.
 12 //!
 13 //! The verdict is deliberately a plain `bool` chosen in ONE place
 14 //! ([`Verdict::is_success`]), so there is no path to an unlock that did not
 15 //! come from both PAM calls returning SUCCESS.
 16 
 17 use std::ffi::{CStr, CString};
 18 
 19 /// PAM service name — `/etc/pam.d/cce-lock`, shipped in this crate's `pam/`
 20 /// dir and installed by `ccebuild install-system`.
 21 ///
 22 /// A constant, never an environment variable or argument: a locker whose PAM
 23 /// stack can be chosen by its caller is a locker anyone with a shell can point
 24 /// at a permissive service.
 25 pub const PAM_SERVICE: &str = "cce-lock";
 26 
 27 /// What the worker thread reports back to the UI.
 28 pub enum AuthEvent {
 29     /// PAM accepted the credentials. The ONLY value that may unlock.
 30     Success,
 31     /// PAM rejected them, or errored. `msg` is for the user, not a log line.
 32     Failure { msg: String },
 33     /// A `TEXT_INFO` / `ERROR_MSG` from the stack while it ran — a faillock
 34     /// delay notice, an expiry warning.
 35     Info { msg: String },
 36 }
 37 
 38 /// Everything the conversation function is allowed to see.
 39 struct ConvData {
 40     username: String,
 41     password: String,
 42     sender: std::sync::mpsc::Sender<AuthEvent>,
 43 }
 44 
 45 /// The PAM conversation: answer the password prompt from the buffer, echo the
 46 /// username back for an echoing prompt, and forward anything informational.
 47 ///
 48 /// `extern "C"`, so it must not unwind: every fallible conversion below is
 49 /// total.
 50 extern "C" fn converse(
 51     num_msg: libc::c_int,
 52     msg: *mut *mut pam_sys::PamMessage,
 53     out_resp: *mut *mut pam_sys::PamResponse,
 54     appdata_ptr: *mut libc::c_void,
 55 ) -> libc::c_int {
 56     if appdata_ptr.is_null() || num_msg <= 0 {
 57         return pam_sys::PamReturnCode::CONV_ERR as libc::c_int;
 58     }
 59     let data = unsafe { &*(appdata_ptr as *const ConvData) };
 60 
 61     let resp = unsafe {
 62         libc::calloc(num_msg as usize, std::mem::size_of::<pam_sys::PamResponse>())
 63             as *mut pam_sys::PamResponse
 64     };
 65     if resp.is_null() {
 66         return pam_sys::PamReturnCode::BUF_ERR as libc::c_int;
 67     }
 68 
 69     for i in 0..num_msg as isize {
 70         unsafe {
 71             let m = &**msg.offset(i);
 72             let r = &mut *resp.offset(i);
 73             let style = m.msg_style;
 74             if style == pam_sys::PamMessageStyle::PROMPT_ECHO_OFF as libc::c_int {
 75                 // unwrap_or_default, not unwrap: an interior NUL in the typed
 76                 // password would otherwise panic across this extern "C"
 77                 // boundary, which aborts the process — and aborting the
 78                 // locker leaves the session locked with no way in. An empty
 79                 // response just fails the attempt.
 80                 let pass = CString::new(data.password.clone()).unwrap_or_default();
 81                 r.resp = libc::strdup(pass.as_ptr());
 82             } else if style == pam_sys::PamMessageStyle::PROMPT_ECHO_ON as libc::c_int {
 83                 let user = CString::new(data.username.clone()).unwrap_or_default();
 84                 r.resp = libc::strdup(user.as_ptr());
 85             } else if !m.msg.is_null() {
 86                 let text = CStr::from_ptr(m.msg).to_string_lossy().into_owned();
 87                 let _ = data.sender.send(AuthEvent::Info { msg: text });
 88             }
 89         }
 90     }
 91 
 92     unsafe { *out_resp = resp };
 93     pam_sys::PamReturnCode::SUCCESS as libc::c_int
 94 }
 95 
 96 /// A PAM transaction, ended on drop so no handle outlives an attempt.
 97 struct Transaction {
 98     handle: *mut pam_sys::PamHandle,
 99     last: pam_sys::PamReturnCode,
100     // Held alive for as long as PAM holds the pointer into it.
101     _data: Box<ConvData>,
102 }
103 
104 impl Transaction {
105     fn start(username: &str, password: &str, sender: std::sync::mpsc::Sender<AuthEvent>) -> Result<Self, pam_sys::PamReturnCode> {
106         let data = Box::new(ConvData {
107             username: username.to_string(),
108             password: password.to_string(),
109             sender,
110         });
111         let conv = pam_sys::PamConversation {
112             conv: Some(converse),
113             data_ptr: &*data as *const ConvData as *mut libc::c_void,
114         };
115         let mut handle: *mut pam_sys::PamHandle = std::ptr::null_mut();
116         let rc = pam_sys::start(PAM_SERVICE, Some(username), &conv, &mut handle);
117         if rc != pam_sys::PamReturnCode::SUCCESS {
118             return Err(rc);
119         }
120         Ok(Self { handle, last: pam_sys::PamReturnCode::SUCCESS, _data: data })
121     }
122 }
123 
124 impl Drop for Transaction {
125     fn drop(&mut self) {
126         if !self.handle.is_null() {
127             unsafe { pam_sys::end(&mut *self.handle, self.last) };
128         }
129     }
130 }
131 
132 /// The single place an unlock can be authorized.
133 pub struct Verdict {
134     authenticate: pam_sys::PamReturnCode,
135     acct_mgmt: pam_sys::PamReturnCode,
136 }
137 
138 impl Verdict {
139     /// True only when BOTH PAM calls returned SUCCESS. Every other outcome —
140     /// a rejection, an expired account, an internal PAM error, a stack that
141     /// could not be started — is a failure, because a locker that opens on
142     /// anything it does not understand is not a lock.
143     pub fn is_success(&self) -> bool {
144         self.authenticate == pam_sys::PamReturnCode::SUCCESS
145             && self.acct_mgmt == pam_sys::PamReturnCode::SUCCESS
146     }
147 
148     /// What to show the user. PAM's own codes are not phrased for a lock
149     /// screen, and echoing them leaks stack detail to whoever is standing
150     /// there, so a wrong password gets one plain sentence.
151     pub fn message(&self) -> String {
152         if self.authenticate == pam_sys::PamReturnCode::AUTH_ERR {
153             "Incorrect password".to_string()
154         } else if self.authenticate != pam_sys::PamReturnCode::SUCCESS {
155             format!("Authentication failed ({:?})", self.authenticate)
156         } else {
157             format!("Account unavailable ({:?})", self.acct_mgmt)
158         }
159     }
160 }
161 
162 /// Check `password` against `username`'s credentials. Blocks — PAM stacks
163 /// sleep on failure (pam_faillock) — so callers run this on a worker thread.
164 pub fn check(username: &str, password: &str, sender: std::sync::mpsc::Sender<AuthEvent>) -> Verdict {
165     let mut tx = match Transaction::start(username, password, sender) {
166         Ok(tx) => tx,
167         Err(rc) => {
168             log::error!("pam_start({}) failed: {:?}", PAM_SERVICE, rc);
169             // Not an unlock: a stack that will not start cannot vouch for
170             // anyone. `preflight` exists so this is caught before locking.
171             return Verdict { authenticate: rc, acct_mgmt: rc };
172         }
173     };
174 
175     let authenticate = unsafe { pam_sys::authenticate(&mut *tx.handle, pam_sys::PamFlag::NONE) };
176     tx.last = authenticate;
177     if authenticate != pam_sys::PamReturnCode::SUCCESS {
178         return Verdict { authenticate, acct_mgmt: authenticate };
179     }
180 
181     let acct_mgmt = unsafe { pam_sys::acct_mgmt(&mut *tx.handle, pam_sys::PamFlag::NONE) };
182     tx.last = acct_mgmt;
183     Verdict { authenticate, acct_mgmt }
184 }
185 
186 /// Prove the PAM stack can be started BEFORE the session is locked.
187 ///
188 /// This is the difference between "the lock did not engage" and "you cannot
189 /// get back in". Without `/etc/pam.d/cce-lock` installed, `pam_start` fails
190 /// and every attempt would be rejected — with the screen already locked and
191 /// the only way out a TTY and a kill. So the locker refuses to lock at all
192 /// unless this passes.
193 pub fn preflight(username: &str) -> Result<(), String> {
194     // `pam_start` does NOT fail on a missing service file: libpam falls back
195     // to /etc/pam.d/other, which is pam_deny on Arch, so the start succeeds
196     // and every password is then rejected. That locked the live session out
197     // on 2026-09-19. The file itself has to be looked for.
198     let installed = ["/etc/pam.d", "/usr/lib/pam.d"]
199         .iter()
200         .any(|dir| std::path::Path::new(dir).join(PAM_SERVICE).is_file());
201     if !installed {
202         return Err(format!(
203             "PAM service file /etc/pam.d/{} is not installed — every password \
204              would be rejected by the `other` fallback. Run: ccebuild install-system",
205             PAM_SERVICE
206         ));
207     }
208 
209     let (tx, _rx) = std::sync::mpsc::channel();
210     match Transaction::start(username, "", tx) {
211         Ok(_) => Ok(()),
212         Err(rc) => Err(format!(
213             "PAM service {:?} unavailable ({:?}) — is /etc/pam.d/{} installed? \
214              Run: ccebuild install-system",
215             PAM_SERVICE, rc, PAM_SERVICE
216         )),
217     }
218 }
219 
220 #[cfg(test)]
221 mod tests {
222     use super::*;
223 
224     /// The one gate. If this ever admits a non-SUCCESS pair, the lock opens
225     /// on a failed credential check.
226     #[test]
227     fn only_success_on_both_calls_unlocks() {
228         let ok = pam_sys::PamReturnCode::SUCCESS;
229         let bad = pam_sys::PamReturnCode::AUTH_ERR;
230         let other = pam_sys::PamReturnCode::ABORT;
231 
232         assert!(Verdict { authenticate: ok, acct_mgmt: ok }.is_success());
233         assert!(!Verdict { authenticate: bad, acct_mgmt: ok }.is_success());
234         assert!(!Verdict { authenticate: ok, acct_mgmt: bad }.is_success());
235         assert!(!Verdict { authenticate: other, acct_mgmt: other }.is_success());
236     }
237 
238     #[test]
239     fn a_wrong_password_says_so_without_leaking_the_stack() {
240         let v = Verdict {
241             authenticate: pam_sys::PamReturnCode::AUTH_ERR,
242             acct_mgmt: pam_sys::PamReturnCode::AUTH_ERR,
243         };
244         assert_eq!(v.message(), "Incorrect password");
245     }
246 }