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 }