1#[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
20const MAX_TEXT: usize = 4096;
22
23pub(crate) const MAX_MESSAGES: usize = 64;
25
26pub(crate) struct Service(pub(crate) Rc<Shared>);
28
29#[derive(Clone, Debug, PartialEq)]
31pub enum PamState {
32 Idle,
34 Asking {
37 message: String,
39 visible: bool,
42 },
43 Busy,
45 Done(PamResult),
47}
48
49#[derive(Clone, Debug, PartialEq)]
51pub enum PamResult {
52 Success,
54 Failure,
56 Error {
58 kind: PamError,
60 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74pub enum PamError {
75 StartFailed,
79 TryAuthFailed,
85 InternalError,
88}
89
90#[derive(Clone, Debug, PartialEq)]
93pub enum PamMessage {
94 Info(String),
96 Error(String),
98}
99
100#[derive(Clone)]
108pub struct Pam {
109 inner: Rc<Inner>,
110}
111
112struct Inner {
113 shared: Option<Rc<Shared>>,
115 service: String,
116 state: Signal<PamState>,
118 messages: Signal<Vec<PamMessage>>,
119 attempt: RefCell<Option<Attempt>>,
120}
121
122struct Attempt {
124 source: RegistrationToken,
126 answers: mpsc::Sender<Secret>,
129 asking: bool,
131}
132
133pub(crate) enum Event {
135 Ask { message: String, visible: bool },
136 Tell(PamMessage),
137 Done(PamResult),
138}
139
140pub(crate) trait Backend: Send + Sync {
142 fn authenticate(&self, service: &str, user: &str, talk: &Talk) -> PamResult;
145}
146
147pub(crate) struct Talk {
149 events: calloop::channel::Sender<Event>,
150 answers: mpsc::Receiver<Secret>,
151}
152
153impl Talk {
154 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 pub(crate) fn tell(&self, kind: fn(String) -> PamMessage, text: &OsStr) {
165 let _ = self.events.send(Event::Tell(kind(cut(text))));
167 }
168}
169
170fn 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
178pub(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
194pub(crate) fn wipe(bytes: &mut Vec<u8>) {
196 bytes.clear();
197 bytes.resize(bytes.capacity(), 0);
198 std::hint::black_box(bytes);
200}
201
202pub(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 cx.owner().signal(EndOnDrop(Rc::clone(&inner)));
220 Pam { inner }
221}
222
223struct EndOnDrop(Rc<Inner>);
225
226impl Drop for EndOnDrop {
227 fn drop(&mut self) {
228 self.0.end();
229 }
230}
231
232impl Pam {
233 pub fn start(&self, user: Option<&str>) {
242 self.inner.abort();
243 self.inner.start(user.map(str::to_owned));
244 }
245
246 pub fn respond(&self, text: impl Into<String>) {
252 self.inner.respond(Secret(text.into().into_bytes()));
253 }
254
255 pub fn abort(&self) {
260 self.inner.abort();
261 }
262
263 pub fn state(&self) -> ReadSignal<PamState> {
265 self.inner.state.read_only()
266 }
267
268 pub fn messages(&self) -> ReadSignal<Vec<PamMessage>> {
271 self.inner.messages.read_only()
272 }
273}
274
275impl Inner {
276 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 let _ = talk.events.send(Event::Done(result));
314 });
315 if let Err(error) = spawned {
316 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 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 return tracing::warn!("PAM asked no question, so a response was dropped");
359 };
360 attempt.asking = false;
361 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 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
393fn 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
406fn 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
415pub(crate) struct LibPam;
417
418impl Backend for LibPam {
419 fn authenticate(&self, service: &str, user: &str, talk: &Talk) -> PamResult {
420 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 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
445fn tried(error: ErrorCode) -> PamResult {
447 let kind = match error {
448 ErrorCode::AuthenticationError => return PamResult::Failure,
449 ErrorCode::ConversationError => PamError::InternalError,
451 _ => PamError::TryAuthFailed,
452 };
453 PamResult::error(kind, error.to_string())
454}
455
456struct Adapter<'a>(&'a Talk);
460
461impl Adapter<'_> {
462 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 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 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
498fn 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}