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(String),
59}
60
61#[derive(Clone, Debug, PartialEq)]
64pub enum PamMessage {
65 Info(String),
67 Error(String),
69}
70
71#[derive(Clone)]
79pub struct Pam {
80 inner: Rc<Inner>,
81}
82
83struct Inner {
84 shared: Option<Rc<Shared>>,
86 service: String,
87 state: Signal<PamState>,
89 messages: Signal<Vec<PamMessage>>,
90 attempt: RefCell<Option<Attempt>>,
91}
92
93struct Attempt {
95 source: RegistrationToken,
97 answers: mpsc::Sender<Secret>,
100 asking: bool,
102}
103
104pub(crate) enum Event {
106 Ask { message: String, visible: bool },
107 Tell(PamMessage),
108 Done(PamResult),
109}
110
111pub(crate) trait Backend: Send + Sync {
113 fn authenticate(&self, service: &str, user: &str, talk: &Talk) -> PamResult;
116}
117
118pub(crate) struct Talk {
120 events: calloop::channel::Sender<Event>,
121 answers: mpsc::Receiver<Secret>,
122}
123
124impl Talk {
125 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 pub(crate) fn tell(&self, kind: fn(String) -> PamMessage, text: &OsStr) {
136 let _ = self.events.send(Event::Tell(kind(cut(text))));
138 }
139}
140
141fn 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
149pub(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
165pub(crate) fn wipe(bytes: &mut Vec<u8>) {
167 bytes.clear();
168 bytes.resize(bytes.capacity(), 0);
169 std::hint::black_box(bytes);
171}
172
173pub(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 cx.owner().signal(EndOnDrop(Rc::clone(&inner)));
191 Pam { inner }
192}
193
194struct EndOnDrop(Rc<Inner>);
196
197impl Drop for EndOnDrop {
198 fn drop(&mut self) {
199 self.0.end();
200 }
201}
202
203impl Pam {
204 pub fn start(&self, user: Option<&str>) {
213 self.inner.abort();
214 self.inner.start(user.map(str::to_owned));
215 }
216
217 pub fn respond(&self, text: impl Into<String>) {
223 self.inner.respond(Secret(text.into().into_bytes()));
224 }
225
226 pub fn abort(&self) {
231 self.inner.abort();
232 }
233
234 pub fn state(&self) -> ReadSignal<PamState> {
236 self.inner.state.read_only()
237 }
238
239 pub fn messages(&self) -> ReadSignal<Vec<PamMessage>> {
242 self.inner.messages.read_only()
243 }
244}
245
246impl Inner {
247 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 let _ = talk.events.send(Event::Done(result));
285 });
286 if let Err(error) = spawned {
287 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 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 return tracing::warn!("PAM asked no question, so a response was dropped");
329 };
330 attempt.asking = false;
331 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 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
363fn 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
376fn 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
385pub(crate) struct LibPam;
387
388impl Backend for LibPam {
389 fn authenticate(&self, service: &str, user: &str, talk: &Talk) -> PamResult {
390 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 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
416struct Adapter<'a>(&'a Talk);
420
421impl Adapter<'_> {
422 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 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 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
458fn 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}