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