Skip to main content

crabshell_services/
pam.rs

1//! PAM authentication, as Quickshell's `PamContext`, for lock screens.
2
3#[cfg(test)]
4mod tests;
5
6use std::cell::RefCell;
7use std::ffi::{OsStr, OsString};
8use std::os::unix::ffi::OsStrExt;
9use std::rc::Rc;
10use std::sync::{Arc, Once, mpsc};
11
12use calloop::RegistrationToken;
13use calloop::channel::Event as Arrived;
14use crabshell_reactive::{ReadSignal, Signal, untrack};
15use crabshell_ui::Cx;
16use nonstick::{AuthnFlags, ConversationAdapter, ErrorCode, Transaction, TransactionBuilder};
17
18use crate::Shared;
19
20/// The longest question or message passed on, in bytes. The rest is cut.
21const MAX_TEXT: usize = 4096;
22
23/// The most messages one attempt keeps; older ones go.
24pub(crate) const MAX_MESSAGES: usize = 64;
25
26/// What `cx.pam` needs from the shell: the loop and the backend.
27pub(crate) struct Service(pub(crate) Rc<Shared>);
28
29/// Where an authentication is, from [`Pam::state`].
30#[derive(Clone, Debug, PartialEq)]
31pub enum PamState {
32  /// Nothing started, or the last attempt was aborted.
33  Idle,
34  /// PAM asks `message`, such as `Password: `, and waits for
35  /// [`Pam::respond`].
36  Asking {
37    /// The question, cut to 4 KiB.
38    message: String,
39    /// Whether the answer may show as it is typed: `false` for a
40    /// password.
41    visible: bool,
42  },
43  /// PAM is working, as between an answer and the next question.
44  Busy,
45  /// The attempt ended.
46  Done(PamResult),
47}
48
49/// How an attempt ended.
50#[derive(Clone, Debug, PartialEq)]
51pub enum PamResult {
52  /// The user is who they said.
53  Success,
54  /// PAM refused the answers, as for a wrong password.
55  Failure,
56  /// Something else went wrong, such as an unknown service or user. The
57  /// text says what.
58  Error(String),
59}
60
61/// A line PAM sent during an attempt, from [`Pam::messages`], cut to
62/// 4 KiB.
63#[derive(Clone, Debug, PartialEq)]
64pub enum PamMessage {
65  /// Something to show, such as how to use a fingerprint reader.
66  Info(String),
67  /// Something that went wrong, such as a locked account.
68  Error(String),
69}
70
71/// Authentication through a PAM service, from `cx.pam(service)`, as a lock
72/// screen does. A clone is cheap and shares it.
73///
74/// libpam blocks, so each attempt runs on a thread of its own, and the
75/// loop hears from it only while it runs: an idle `Pam` wakes nothing. When
76/// the context that made it goes, an attempt still running is refused its
77/// answer, so its thread ends.
78#[derive(Clone)]
79pub struct Pam {
80  inner: Rc<Inner>,
81}
82
83struct Inner {
84  /// `None` when the shell provided no services.
85  shared: Option<Rc<Shared>>,
86  service: String,
87  /// These live under the asking context's owner.
88  state: Signal<PamState>,
89  messages: Signal<Vec<PamMessage>>,
90  attempt: RefCell<Option<Attempt>>,
91}
92
93/// The attempt running now.
94struct Attempt {
95  /// The loop source its thread's events arrive on.
96  source: RegistrationToken,
97  /// Where answers go. Dropping it refuses the question its thread waits
98  /// on.
99  answers: mpsc::Sender<Secret>,
100  /// A question waits for its answer.
101  asking: bool,
102}
103
104/// What an attempt's thread tells the loop.
105pub(crate) enum Event {
106  Ask { message: String, visible: bool },
107  Tell(PamMessage),
108  Done(PamResult),
109}
110
111/// What authenticates, off the loop's thread: libpam, or a test's script.
112pub(crate) trait Backend: Send + Sync {
113  /// Authenticates `user` for `service`, asking and telling through
114  /// `talk`.
115  fn authenticate(&self, service: &str, user: &str, talk: &Talk) -> PamResult;
116}
117
118/// An attempt's end of the conversation, on its thread.
119pub(crate) struct Talk {
120  events: calloop::channel::Sender<Event>,
121  answers: mpsc::Receiver<Secret>,
122}
123
124impl Talk {
125  /// Asks the config `question`, cut to 4 KiB, and waits for the answer.
126  /// `None` when it was refused: the attempt was aborted, its context
127  /// went, or the shell is gone.
128  pub(crate) fn ask(&self, question: &OsStr, visible: bool) -> Option<Secret> {
129    let message = cut(question);
130    self.events.send(Event::Ask { message, visible }).ok()?;
131    self.answers.recv().ok()
132  }
133
134  /// Passes `text`, cut to 4 KiB, on as a message.
135  pub(crate) fn tell(&self, kind: fn(String) -> PamMessage, text: &OsStr) {
136    // Refused once the attempt is gone, when nobody reads it.
137    let _ = self.events.send(Event::Tell(kind(cut(text))));
138  }
139}
140
141/// `text` as UTF-8 with bad bytes replaced, cut to [`MAX_TEXT`] bytes on
142/// a character's boundary.
143fn cut(text: &OsStr) -> String {
144  let mut text = text.to_string_lossy().into_owned();
145  text.truncate(text.floor_char_boundary(MAX_TEXT));
146  text
147}
148
149/// An answer on its way to libpam. Its bytes are zeroed when it drops, and
150/// it has no `Debug`, so it can't be logged.
151pub(crate) struct Secret(Vec<u8>);
152
153impl Secret {
154  pub(crate) fn bytes(&self) -> &[u8] {
155    &self.0
156  }
157}
158
159impl Drop for Secret {
160  fn drop(&mut self) {
161    wipe(&mut self.0);
162  }
163}
164
165/// Zeroes every byte `bytes` holds room for.
166pub(crate) fn wipe(bytes: &mut Vec<u8>) {
167  bytes.clear();
168  bytes.resize(bytes.capacity(), 0);
169  // So the compiler can't drop the writes as dead before the free.
170  std::hint::black_box(bytes);
171}
172
173/// `cx.pam(service)`.
174pub(crate) fn pam(cx: &Cx, service: &str) -> Pam {
175  let shared = cx.service::<Service>().map(|service| Rc::clone(&service.0));
176  if shared.is_none() {
177    static LOGGED: Once = Once::new();
178    LOGGED.call_once(|| {
179      tracing::warn!("cx.pam() needs the services the shell provides; it never authenticates");
180    });
181  }
182  let inner = Rc::new(Inner {
183    shared,
184    service: service.to_owned(),
185    state: cx.signal(PamState::Idle),
186    messages: cx.signal(Vec::new()),
187    attempt: RefCell::default(),
188  });
189  // Dropped with the asking context's owner, which ends the attempt.
190  cx.owner().signal(EndOnDrop(Rc::clone(&inner)));
191  Pam { inner }
192}
193
194/// Ends the attempt when the asking context's owner drops it.
195struct EndOnDrop(Rc<Inner>);
196
197impl Drop for EndOnDrop {
198  fn drop(&mut self) {
199    self.0.end();
200  }
201}
202
203impl Pam {
204  /// Starts authenticating `user`, or with `None` the user the shell runs
205  /// as, found by its uid. An attempt already running is aborted first.
206  ///
207  /// Ending an attempt while PAM waits for an answer, by aborting it,
208  /// starting another or dropping its context, counts as a failed login
209  /// where `pam_faillock` counts failures, and enough of them lock the
210  /// account. So take the password first, start on Enter, and answer as
211  /// soon as PAM asks.
212  pub fn start(&self, user: Option<&str>) {
213    self.inner.abort();
214    self.inner.start(user.map(str::to_owned));
215  }
216
217  /// Answers the question asked, and the state turns
218  /// [`Busy`](PamState::Busy). The answer is never logged, and its bytes
219  /// are zeroed once handed on, but the copy made for libpam is freed
220  /// unzeroed. With no question waiting, it is dropped, and that is
221  /// logged.
222  pub fn respond(&self, text: impl Into<String>) {
223    self.inner.respond(Secret(text.into().into_bytes()));
224  }
225
226  /// Stops the attempt running: its question is refused, its thread ends
227  /// and the state turns [`Idle`](PamState::Idle). With none running,
228  /// nothing changes. A refused question counts as a failed login, as
229  /// [`start`](Self::start) says.
230  pub fn abort(&self) {
231    self.inner.abort();
232  }
233
234  /// Where the attempt is.
235  pub fn state(&self) -> ReadSignal<PamState> {
236    self.inner.state.read_only()
237  }
238
239  /// The info and error lines PAM sent during the attempt, oldest first.
240  /// Each start empties it.
241  pub fn messages(&self) -> ReadSignal<Vec<PamMessage>> {
242    self.inner.messages.read_only()
243  }
244}
245
246impl Inner {
247  /// Whether the asking context is still there. Untracked, so an effect
248  /// that starts an attempt doesn't run again for it.
249  fn alive(&self) -> bool {
250    untrack(|| self.state.try_with(|_| ()).is_some())
251  }
252
253  fn start(self: &Rc<Self>, user: Option<String>) {
254    let Some(shared) = &self.shared else {
255      return;
256    };
257    let Some(event_loop) = shared.event_loop() else {
258      return;
259    };
260    if !self.alive() {
261      return;
262    }
263    let (events, channel) = calloop::channel::channel();
264    let (answers, inbox) = mpsc::channel();
265    let inner = Rc::clone(self);
266    let arrived = Box::new(move |event| inner.arrived(event));
267    let Some(source) = event_loop.insert_pam_channel(channel, arrived) else {
268      return;
269    };
270    let backend = Arc::clone(&shared.config.pam);
271    let service = self.service.clone();
272    let talk = Talk {
273      events,
274      answers: inbox,
275    };
276    let spawned = std::thread::Builder::new()
277      .name(String::from("crabshell-pam"))
278      .spawn(move || {
279        let result = match user.map_or_else(own_user, Ok) {
280          Ok(user) => backend.authenticate(&service, &user, &talk),
281          Err(error) => PamResult::Error(error),
282        };
283        // Refused when the attempt was aborted meanwhile.
284        let _ = talk.events.send(Event::Done(result));
285      });
286    if let Err(error) = spawned {
287      // Its channel closed with it, which ends the attempt.
288      tracing::error!(%error, "PAM couldn't start a thread");
289    }
290    *self.attempt.borrow_mut() = Some(Attempt {
291      source,
292      answers,
293      asking: false,
294    });
295    set(self.messages, Vec::new());
296    set(self.state, PamState::Busy);
297  }
298
299  fn arrived(&self, event: Arrived<Event>) {
300    match event {
301      Arrived::Msg(Event::Ask { message, visible }) => {
302        if let Some(attempt) = self.attempt.borrow_mut().as_mut() {
303          attempt.asking = true;
304        }
305        set(self.state, PamState::Asking { message, visible });
306      }
307      Arrived::Msg(Event::Tell(message)) => {
308        self.messages.try_update(|messages| {
309          if messages.len() == MAX_MESSAGES {
310            messages.remove(0);
311          }
312          messages.push(message);
313        });
314      }
315      Arrived::Msg(Event::Done(result)) => self.finish(result),
316      // The thread ended before its result, as when it panicked.
317      Arrived::Closed => self.finish(PamResult::Error(String::from(
318        "the attempt ended without a result",
319      ))),
320    }
321  }
322
323  fn respond(&self, secret: Secret) {
324    {
325      let mut attempt = self.attempt.borrow_mut();
326      let Some(attempt) = attempt.as_mut().filter(|attempt| attempt.asking) else {
327        // `secret` is zeroed as it drops.
328        return tracing::warn!("PAM asked no question, so a response was dropped");
329      };
330      attempt.asking = false;
331      // Fails only once the thread is gone, and drops `secret`, zeroed.
332      let _ = attempt.answers.send(secret);
333    }
334    set(self.state, PamState::Busy);
335  }
336
337  fn finish(&self, result: PamResult) {
338    if self.end() {
339      set(self.state, PamState::Done(result));
340    }
341  }
342
343  fn abort(&self) {
344    if self.end() {
345      set(self.state, PamState::Idle);
346    }
347  }
348
349  /// Ends the attempt running, if one is: its source leaves the loop, and
350  /// dropping its sender refuses the question its thread waits on. Whether
351  /// one ran.
352  fn end(&self) -> bool {
353    let Some(attempt) = self.attempt.borrow_mut().take() else {
354      return false;
355    };
356    if let Some(event_loop) = self.shared.as_ref().and_then(|shared| shared.event_loop()) {
357      event_loop.remove(attempt.source);
358    }
359    true
360  }
361}
362
363/// The name of the user the shell runs as, found by its uid, since `$USER`
364/// is whatever the shell's parent set.
365// shortcut: reads /etc/passwd only, so a user known only through NSS, as
366// from LDAP or systemd-homed, needs `start(Some(name))`; upgrade when a
367// safe getpwuid is in the dependency tree.
368fn own_user() -> Result<String, String> {
369  let uid = rustix::process::getuid().as_raw();
370  let passwd =
371    std::fs::read("/etc/passwd").map_err(|error| format!("can't read /etc/passwd: {error}"))?;
372  user_name(&String::from_utf8_lossy(&passwd), uid)
373    .ok_or_else(|| format!("no user with uid {uid} in /etc/passwd"))
374}
375
376/// The name on the line of `passwd` whose uid, its third field, is `uid`.
377fn user_name(passwd: &str, uid: u32) -> Option<String> {
378  passwd.lines().find_map(|line| {
379    let mut fields = line.split(':');
380    let name = fields.next()?;
381    (fields.nth(1)?.parse::<u32>() == Ok(uid)).then(|| name.to_owned())
382  })
383}
384
385/// The system's libpam.
386pub(crate) struct LibPam;
387
388impl Backend for LibPam {
389  fn authenticate(&self, service: &str, user: &str, talk: &Talk) -> PamResult {
390    // libpam takes C strings, and nonstick panics on a NUL.
391    if service.contains('\0') || user.contains('\0') {
392      return PamResult::Error(String::from("a service or user name with a NUL"));
393    }
394    let built = TransactionBuilder::new_with_service(service)
395      .username(user)
396      .build(Adapter(talk).into_conversation());
397    let mut transaction = match built {
398      Ok(transaction) => transaction,
399      Err(error) => return PamResult::Error(error.to_string()),
400    };
401    match transaction.authenticate(AuthnFlags::empty()) {
402      Ok(()) => {
403        // The account step is where `pam_faillock` forgets past failures.
404        // Its refusal, as for an expired password, is only logged.
405        if let Err(error) = transaction.account_management(AuthnFlags::empty()) {
406          tracing::warn!(%error, "PAM's account check refused after authenticating");
407        }
408        PamResult::Success
409      }
410      Err(ErrorCode::AuthenticationError) => PamResult::Failure,
411      Err(error) => PamResult::Error(error.to_string()),
412    }
413  }
414}
415
416/// libpam's questions and messages, through an attempt's [`Talk`]. It runs
417/// inside libpam's callback, where a panic aborts the shell, so nothing in
418/// it panics.
419struct Adapter<'a>(&'a Talk);
420
421impl Adapter<'_> {
422  /// The config's answer to `question`, copied for libpam. Ours is zeroed
423  /// as it drops.
424  fn answer(&self, question: &OsStr, visible: bool) -> nonstick::Result<OsString> {
425    let secret = self
426      .0
427      .ask(question, visible)
428      .ok_or(ErrorCode::ConversationError)?;
429    // nonstick panics on a NUL, and libpam would read only up to it.
430    if secret.bytes().contains(&0) {
431      tracing::warn!("PAM was refused a response with a NUL in it");
432      return Err(ErrorCode::ConversationError);
433    }
434    // shortcut: nonstick frees this copy without zeroing it; zero it too if
435    // nonstick ever takes answers in a buffer it wipes.
436    Ok(OsStr::from_bytes(secret.bytes()).to_owned())
437  }
438}
439
440impl ConversationAdapter for Adapter<'_> {
441  fn prompt(&self, request: impl AsRef<OsStr>) -> nonstick::Result<OsString> {
442    self.answer(request.as_ref(), true)
443  }
444
445  fn masked_prompt(&self, request: impl AsRef<OsStr>) -> nonstick::Result<OsString> {
446    self.answer(request.as_ref(), false)
447  }
448
449  fn error_msg(&self, message: impl AsRef<OsStr>) {
450    self.0.tell(PamMessage::Error, message.as_ref());
451  }
452
453  fn info_msg(&self, message: impl AsRef<OsStr>) {
454    self.0.tell(PamMessage::Info, message.as_ref());
455  }
456}
457
458/// Sets `signal` to `value` unless it holds it already, so nothing wakes
459/// for a value that didn't change.
460fn set<T: PartialEq + 'static>(signal: Signal<T>, value: T) {
461  if untrack(|| signal.try_with(|old| *old != value)) == Some(true) {
462    signal.try_set(value);
463  }
464}