#[cfg(test)]
mod tests;
use std::cell::RefCell;
use std::ffi::{OsStr, OsString};
use std::os::unix::ffi::OsStrExt;
use std::rc::Rc;
use std::sync::{Arc, Once, mpsc};
use calloop::RegistrationToken;
use calloop::channel::Event as Arrived;
use crabshell_reactive::{ReadSignal, Signal, untrack};
use crabshell_ui::Cx;
use nonstick::{AuthnFlags, ConversationAdapter, ErrorCode, Transaction, TransactionBuilder};
use crate::Shared;
const MAX_TEXT: usize = 4096;
pub(crate) const MAX_MESSAGES: usize = 64;
pub(crate) struct Service(pub(crate) Rc<Shared>);
#[derive(Clone, Debug, PartialEq)]
pub enum PamState {
Idle,
Asking {
message: String,
visible: bool,
},
Busy,
Done(PamResult),
}
#[derive(Clone, Debug, PartialEq)]
pub enum PamResult {
Success,
Failure,
Error(String),
}
#[derive(Clone, Debug, PartialEq)]
pub enum PamMessage {
Info(String),
Error(String),
}
#[derive(Clone)]
pub struct Pam {
inner: Rc<Inner>,
}
struct Inner {
shared: Option<Rc<Shared>>,
service: String,
state: Signal<PamState>,
messages: Signal<Vec<PamMessage>>,
attempt: RefCell<Option<Attempt>>,
}
struct Attempt {
source: RegistrationToken,
answers: mpsc::Sender<Secret>,
asking: bool,
}
pub(crate) enum Event {
Ask { message: String, visible: bool },
Tell(PamMessage),
Done(PamResult),
}
pub(crate) trait Backend: Send + Sync {
fn authenticate(&self, service: &str, user: &str, talk: &Talk) -> PamResult;
}
pub(crate) struct Talk {
events: calloop::channel::Sender<Event>,
answers: mpsc::Receiver<Secret>,
}
impl Talk {
pub(crate) fn ask(&self, question: &OsStr, visible: bool) -> Option<Secret> {
let message = cut(question);
self.events.send(Event::Ask { message, visible }).ok()?;
self.answers.recv().ok()
}
pub(crate) fn tell(&self, kind: fn(String) -> PamMessage, text: &OsStr) {
let _ = self.events.send(Event::Tell(kind(cut(text))));
}
}
fn cut(text: &OsStr) -> String {
let mut text = text.to_string_lossy().into_owned();
text.truncate(text.floor_char_boundary(MAX_TEXT));
text
}
pub(crate) struct Secret(Vec<u8>);
impl Secret {
pub(crate) fn bytes(&self) -> &[u8] {
&self.0
}
}
impl Drop for Secret {
fn drop(&mut self) {
wipe(&mut self.0);
}
}
pub(crate) fn wipe(bytes: &mut Vec<u8>) {
bytes.clear();
bytes.resize(bytes.capacity(), 0);
std::hint::black_box(bytes);
}
pub(crate) fn pam(cx: &Cx, service: &str) -> Pam {
let shared = cx.service::<Service>().map(|service| Rc::clone(&service.0));
if shared.is_none() {
static LOGGED: Once = Once::new();
LOGGED.call_once(|| {
tracing::warn!("cx.pam() needs the services the shell provides; it never authenticates");
});
}
let inner = Rc::new(Inner {
shared,
service: service.to_owned(),
state: cx.signal(PamState::Idle),
messages: cx.signal(Vec::new()),
attempt: RefCell::default(),
});
cx.owner().signal(EndOnDrop(Rc::clone(&inner)));
Pam { inner }
}
struct EndOnDrop(Rc<Inner>);
impl Drop for EndOnDrop {
fn drop(&mut self) {
self.0.end();
}
}
impl Pam {
pub fn start(&self, user: Option<&str>) {
self.inner.abort();
self.inner.start(user.map(str::to_owned));
}
pub fn respond(&self, text: impl Into<String>) {
self.inner.respond(Secret(text.into().into_bytes()));
}
pub fn abort(&self) {
self.inner.abort();
}
pub fn state(&self) -> ReadSignal<PamState> {
self.inner.state.read_only()
}
pub fn messages(&self) -> ReadSignal<Vec<PamMessage>> {
self.inner.messages.read_only()
}
}
impl Inner {
fn alive(&self) -> bool {
untrack(|| self.state.try_with(|_| ()).is_some())
}
fn start(self: &Rc<Self>, user: Option<String>) {
let Some(shared) = &self.shared else {
return;
};
let Some(event_loop) = shared.event_loop() else {
return;
};
if !self.alive() {
return;
}
let (events, channel) = calloop::channel::channel();
let (answers, inbox) = mpsc::channel();
let inner = Rc::clone(self);
let arrived = Box::new(move |event| inner.arrived(event));
let Some(source) = event_loop.insert_pam_channel(channel, arrived) else {
return;
};
let backend = Arc::clone(&shared.config.pam);
let service = self.service.clone();
let talk = Talk {
events,
answers: inbox,
};
let spawned = std::thread::Builder::new()
.name(String::from("crabshell-pam"))
.spawn(move || {
let result = match user.map_or_else(own_user, Ok) {
Ok(user) => backend.authenticate(&service, &user, &talk),
Err(error) => PamResult::Error(error),
};
let _ = talk.events.send(Event::Done(result));
});
if let Err(error) = spawned {
tracing::error!(%error, "PAM couldn't start a thread");
}
*self.attempt.borrow_mut() = Some(Attempt {
source,
answers,
asking: false,
});
set(self.messages, Vec::new());
set(self.state, PamState::Busy);
}
fn arrived(&self, event: Arrived<Event>) {
match event {
Arrived::Msg(Event::Ask { message, visible }) => {
if let Some(attempt) = self.attempt.borrow_mut().as_mut() {
attempt.asking = true;
}
set(self.state, PamState::Asking { message, visible });
}
Arrived::Msg(Event::Tell(message)) => {
self.messages.try_update(|messages| {
if messages.len() == MAX_MESSAGES {
messages.remove(0);
}
messages.push(message);
});
}
Arrived::Msg(Event::Done(result)) => self.finish(result),
Arrived::Closed => self.finish(PamResult::Error(String::from(
"the attempt ended without a result",
))),
}
}
fn respond(&self, secret: Secret) {
{
let mut attempt = self.attempt.borrow_mut();
let Some(attempt) = attempt.as_mut().filter(|attempt| attempt.asking) else {
return tracing::warn!("PAM asked no question, so a response was dropped");
};
attempt.asking = false;
let _ = attempt.answers.send(secret);
}
set(self.state, PamState::Busy);
}
fn finish(&self, result: PamResult) {
if self.end() {
set(self.state, PamState::Done(result));
}
}
fn abort(&self) {
if self.end() {
set(self.state, PamState::Idle);
}
}
fn end(&self) -> bool {
let Some(attempt) = self.attempt.borrow_mut().take() else {
return false;
};
if let Some(event_loop) = self.shared.as_ref().and_then(|shared| shared.event_loop()) {
event_loop.remove(attempt.source);
}
true
}
}
fn own_user() -> Result<String, String> {
let uid = rustix::process::getuid().as_raw();
let passwd =
std::fs::read("/etc/passwd").map_err(|error| format!("can't read /etc/passwd: {error}"))?;
user_name(&String::from_utf8_lossy(&passwd), uid)
.ok_or_else(|| format!("no user with uid {uid} in /etc/passwd"))
}
fn user_name(passwd: &str, uid: u32) -> Option<String> {
passwd.lines().find_map(|line| {
let mut fields = line.split(':');
let name = fields.next()?;
(fields.nth(1)?.parse::<u32>() == Ok(uid)).then(|| name.to_owned())
})
}
pub(crate) struct LibPam;
impl Backend for LibPam {
fn authenticate(&self, service: &str, user: &str, talk: &Talk) -> PamResult {
if service.contains('\0') || user.contains('\0') {
return PamResult::Error(String::from("a service or user name with a NUL"));
}
let built = TransactionBuilder::new_with_service(service)
.username(user)
.build(Adapter(talk).into_conversation());
let mut transaction = match built {
Ok(transaction) => transaction,
Err(error) => return PamResult::Error(error.to_string()),
};
match transaction.authenticate(AuthnFlags::empty()) {
Ok(()) => {
if let Err(error) = transaction.account_management(AuthnFlags::empty()) {
tracing::warn!(%error, "PAM's account check refused after authenticating");
}
PamResult::Success
}
Err(ErrorCode::AuthenticationError) => PamResult::Failure,
Err(error) => PamResult::Error(error.to_string()),
}
}
}
struct Adapter<'a>(&'a Talk);
impl Adapter<'_> {
fn answer(&self, question: &OsStr, visible: bool) -> nonstick::Result<OsString> {
let secret = self
.0
.ask(question, visible)
.ok_or(ErrorCode::ConversationError)?;
if secret.bytes().contains(&0) {
tracing::warn!("PAM was refused a response with a NUL in it");
return Err(ErrorCode::ConversationError);
}
Ok(OsStr::from_bytes(secret.bytes()).to_owned())
}
}
impl ConversationAdapter for Adapter<'_> {
fn prompt(&self, request: impl AsRef<OsStr>) -> nonstick::Result<OsString> {
self.answer(request.as_ref(), true)
}
fn masked_prompt(&self, request: impl AsRef<OsStr>) -> nonstick::Result<OsString> {
self.answer(request.as_ref(), false)
}
fn error_msg(&self, message: impl AsRef<OsStr>) {
self.0.tell(PamMessage::Error, message.as_ref());
}
fn info_msg(&self, message: impl AsRef<OsStr>) {
self.0.tell(PamMessage::Info, message.as_ref());
}
}
fn set<T: PartialEq + 'static>(signal: Signal<T>, value: T) {
if untrack(|| signal.try_with(|old| *old != value)) == Some(true) {
signal.try_set(value);
}
}