use std::{borrow::Cow, ffi::CStr};
use bon::Builder;
use log::{debug, error, info, warn};
use nonstick2::{
AuthnFlags, ConversationAdapter, ErrorCode, LibPamTransaction, Result as PamResult,
TransactionBuilder, conv::Demux, libpam::SessionActive,
};
use pcre2::bytes::RegexBuilder;
#[cfg(feature = "timeout")]
use crate::timeout;
use crate::{
Cli,
error::{SrError, SrResult},
};
use rar_common::{
Cred,
database::options::{SAuthentication, STimeout},
};
use self::rpassword::Terminal;
#[allow(dead_code, reason = "This file is part of sudo-rs.")]
mod cutils;
#[allow(dead_code, reason = "This file is part of sudo-rs.")]
mod rpassword;
#[allow(dead_code, reason = "This file is part of sudo-rs.")]
mod securemem;
const PAM_SERVICE: &str = env!("RAR_PAM_SERVICE");
pub(crate) const PAM_PROMPT: &str = "Password: ";
#[derive(Builder)]
pub struct SrConversationHandler<'a> {
#[builder(into)]
username: Option<Cow<'a, str>>,
#[builder(default = "Password: ", into)]
prompt: Cow<'a, str>,
#[builder(default)]
use_stdin: bool,
#[builder(default)]
no_interact: bool,
}
impl SrConversationHandler<'_> {
fn open(&self) -> std::io::Result<Terminal<'_>> {
if self.use_stdin {
Ok(Terminal::open_stdie())
} else {
Terminal::open_tty()
}
}
fn is_pam_password_prompt(&self, prompt: &impl AsRef<str>) -> bool {
let pam_prompt = prompt.as_ref();
RegexBuilder::new()
.build("^Password: ?$")
.expect("Invalid regex, implementation error")
.is_match(pam_prompt.as_bytes())
.is_ok_and(|f| f)
|| self.username.as_ref().is_some_and(|username| {
RegexBuilder::new()
.build(&format!("^{}'s Password: ?$", pcre2::escape(username)))
.expect("invalid regex, username should be sanitized")
.is_match(pam_prompt.as_bytes())
.is_ok_and(|f| f)
})
}
}
impl Default for SrConversationHandler<'_> {
fn default() -> Self {
SrConversationHandler {
prompt: "Password: ".into(),
username: None,
use_stdin: false,
no_interact: false,
}
}
}
impl ConversationAdapter for SrConversationHandler<'_> {
fn prompt(&self, prompt: impl AsRef<std::ffi::OsStr>) -> PamResult<std::ffi::OsString> {
if self.no_interact {
return Err(ErrorCode::ConversationError);
}
let mut term = self.open().map_err(|_| ErrorCode::ConversationError)?;
term.prompt(&prompt.as_ref().to_string_lossy().to_string())
.map_err(|_| ErrorCode::ConversationError)?;
let read = term.read_cleartext().map_err(|_| ErrorCode::BufferError)?;
drop(term);
Ok(std::ffi::OsString::from(
String::from_utf8_lossy(&read).to_string(),
))
}
fn masked_prompt(&self, prompt: impl AsRef<std::ffi::OsStr>) -> PamResult<std::ffi::OsString> {
if self.no_interact {
return Err(ErrorCode::ConversationError);
}
let pam_prompt = if self.is_pam_password_prompt(&prompt.as_ref().to_string_lossy()) {
self.prompt.clone()
} else {
prompt.as_ref().to_string_lossy()
};
let mut term = self.open().map_err(|_| ErrorCode::ConversationError)?;
term.prompt(&pam_prompt)
.map_err(|_| ErrorCode::ConversationError)?;
let read = term.read_password().map_err(|_| ErrorCode::BufferError)?;
drop(term);
let os_str = CStr::from_bytes_until_nul(&read)
.map_err(|_| ErrorCode::BufferError)?
.to_str()
.map_err(|_| ErrorCode::BufferError)?;
Ok(std::ffi::OsString::from(os_str))
}
fn error_msg(&self, message: impl AsRef<std::ffi::OsStr>) {
error!("{}", message.as_ref().to_string_lossy());
eprintln!("{}", message.as_ref().to_string_lossy());
}
fn info_msg(&self, message: impl AsRef<std::ffi::OsStr>) {
info!("{}", message.as_ref().to_string_lossy());
println!("{}", message.as_ref().to_string_lossy());
}
}
pub(super) fn start_session<'a>(
authentication: SAuthentication,
#[cfg_attr(not(feature = "timeout"), allow(unused_variables))] timeout: &STimeout,
user: &Cred,
cli: &Cli,
) -> SrResult<LibPamTransaction<Demux<SrConversationHandler<'a>>, SessionActive>> {
let need_password_auth = if authentication.is_skip() {
warn!("Skipping password authentication (PAM session-only mode).");
false
} else {
#[cfg(feature = "timeout")]
{
!timeout::is_valid(user, user, timeout)
}
#[cfg(not(feature = "timeout"))]
{
true
}
};
debug!("need password authentication: {need_password_auth}");
let conv = SrConversationHandler::builder()
.maybe_prompt(cli.prompt.as_ref().map(|s| Cow::Owned(s.clone())))
.use_stdin(cli.stdin)
.build();
let txn = TransactionBuilder::new_with_service(PAM_SERVICE)
.username(&user.user.name)
.build(conv.into_conversation())
.map_err(|e| {
error!("Failed to create PAM transaction: {e}");
SrError::SystemError
})?;
let txn = if need_password_auth {
txn.authenticate(AuthnFlags::SILENT)
.map_err(|e| {
error!("Authentication failed: {}", e.1);
SrError::AuthenticationFailed
})?
.account_management(AuthnFlags::SILENT)
.map_err(|e| {
error!("Authentication failed: {}", e.1);
SrError::AuthenticationFailed
})?
} else {
txn.account_management(AuthnFlags::SILENT).map_err(|e| {
error!("Account management failed: {}", e.1);
SrError::AuthenticationFailed
})?
};
let txn = txn
.establish_credentials()
.map_err(|e| {
error!("Failed to establish credentials: {}", e.1);
SrError::SystemError
})?
.open_session()
.map_err(|e| {
error!("Failed to open session: {}", e.1);
SrError::SystemError
})?;
#[cfg(feature = "timeout")]
{
timeout::update_cookie(user, user, timeout).map_err(|e| {
error!("Failed to update timeout cookie: {e}");
SrError::SystemError
})?;
}
Ok(txn)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use nix::{libc::dev_t, unistd::Pid};
use rar_common::{
Cred,
database::options::{SAuthentication, STimeout, TimestampType},
};
use serde_json::Map;
use std::ffi::OsStr;
use test_log::test;
fn create_test_user() -> Cred {
Cred::builder()
.maybe_tty(Some(0 as dev_t))
.ppid(Pid::from_raw(1))
.groups()
.unwrap()
.user()
.unwrap()
.curdir()
.unwrap()
.build()
}
fn create_test_timeout() -> STimeout {
STimeout {
type_field: Some(TimestampType::TTY),
duration: Some(Duration::seconds(300)), max_usage: Some(3),
extra_fields: Map::default(),
}
}
fn pam_ready(user: &Cred) -> bool {
let conv = SrConversationHandler::builder().no_interact(true).build();
let Ok(txn) = TransactionBuilder::new_with_service(PAM_SERVICE)
.username(&user.user.name)
.build(conv.into_conversation())
else {
return false;
};
txn.account_management(AuthnFlags::SILENT).is_ok()
}
#[test]
fn test_sr_conversation_handler_new() {
let handler = SrConversationHandler::builder()
.prompt("Test prompt: ")
.build();
assert_eq!(handler.prompt, "Test prompt: ");
assert!(handler.username.is_none());
assert!(!handler.use_stdin);
assert!(!handler.no_interact);
}
#[test]
fn test_sr_conversation_handler_default() {
let handler = SrConversationHandler::default();
assert_eq!(handler.prompt, "Password: ");
assert!(handler.username.is_none());
assert!(!handler.use_stdin);
assert!(!handler.no_interact);
}
#[test]
fn test_is_pam_password_prompt_basic() {
let handler = SrConversationHandler::default();
assert!(handler.is_pam_password_prompt(&"Password:"));
assert!(handler.is_pam_password_prompt(&"Password: "));
assert!(!handler.is_pam_password_prompt(&"Enter password:"));
assert!(!handler.is_pam_password_prompt(&"Password required:"));
assert!(!handler.is_pam_password_prompt(&""));
}
#[test]
fn test_is_pam_password_prompt_with_username() {
let handler = SrConversationHandler::builder()
.username("testuser")
.build();
assert!(handler.is_pam_password_prompt(&"testuser's Password:"));
assert!(handler.is_pam_password_prompt(&"testuser's Password: "));
assert!(!handler.is_pam_password_prompt(&"otheruser's Password:"));
assert!(!handler.is_pam_password_prompt(&"testuser Password:")); }
#[test]
fn test_conversation_handler_error_msg() {
let handler = SrConversationHandler::default();
handler.error_msg(OsStr::new("Test error message"));
}
#[test]
fn test_conversation_handler_info_msg() {
let handler = SrConversationHandler::default();
handler.info_msg(OsStr::new("Test info message"));
}
#[test]
fn test_check_auth_skip_authentication() {
let _ = env_logger::builder().is_test(true).try_init();
let authentication = SAuthentication::Skip;
let timeout = create_test_timeout();
let user = create_test_user();
if !pam_ready(&user) {
eprintln!("Skipping: PAM backend not available in this test environment");
return;
}
let cli = Cli::builder().prompt("Password: ").build();
let result = start_session(authentication, &timeout, &user, &cli);
assert!(result.is_ok());
}
#[test]
fn test_check_auth_required_but_valid_timeout() {
if env!("RAR_PAM_SERVICE") == "dosr" {
println!(
"Skipping test_check_auth_required_but_valid_timeout because RAR_PAM_SERVICE is set to original dosr"
);
return;
}
let authentication = SAuthentication::Perform;
let timeout = create_test_timeout();
let user = create_test_user();
let cli = Cli::builder().prompt("Password: ").build();
let _ = start_session(authentication, &timeout, &user, &cli);
}
#[test]
fn test_conversation_handler_no_interact_flag() {
let handler = SrConversationHandler::builder().no_interact(true).build();
let prompt_result = handler.prompt(OsStr::new("Test prompt"));
assert!(matches!(prompt_result, Err(ErrorCode::ConversationError)));
let masked_prompt_result = handler.masked_prompt(OsStr::new("Password:"));
assert!(matches!(
masked_prompt_result,
Err(ErrorCode::ConversationError)
));
}
#[test]
fn test_password_prompt_replacement() {
let custom_prompt = "Enter your secret: ";
let handler = SrConversationHandler::builder()
.prompt(custom_prompt)
.build();
assert_eq!(handler.prompt, custom_prompt);
assert!(handler.is_pam_password_prompt(&"Password:"));
assert!(handler.is_pam_password_prompt(&"Password: "));
}
#[test]
fn test_regex_patterns_edge_cases() {
let handler = SrConversationHandler::builder()
.username("user.with.dots")
.build();
assert!(handler.is_pam_password_prompt(&"user.with.dots's Password:"));
assert!(!handler.is_pam_password_prompt(&"password:"));
assert!(!handler.is_pam_password_prompt(&"PASSWORD:"));
assert!(!handler.is_pam_password_prompt(&"Password: ")); assert!(!handler.is_pam_password_prompt(&" Password:")); }
#[test]
fn test_conversation_handler_fields() {
let handler = SrConversationHandler::builder()
.prompt("Custom: ")
.use_stdin(true)
.no_interact(true)
.username("alice")
.build();
assert!(handler.use_stdin);
assert!(handler.no_interact);
assert_eq!(handler.username.as_ref().unwrap(), "alice");
assert_eq!(handler.prompt, "Custom: ");
}
#[test]
fn test_timeout_types() {
let timeout_ppid = STimeout {
type_field: Some(TimestampType::PPID),
duration: Some(Duration::seconds(300)),
max_usage: Some(1),
extra_fields: Map::default(),
};
let timeout_tty = STimeout {
type_field: Some(TimestampType::TTY),
duration: Some(Duration::seconds(600)),
max_usage: Some(5),
extra_fields: Map::default(),
};
let user = create_test_user();
if !pam_ready(&user) {
eprintln!("Skipping: PAM backend not available in this test environment");
return;
}
let auth = SAuthentication::Skip;
let cli = Cli::builder().prompt("Password: ").build();
assert!(start_session(auth, &timeout_ppid, &user, &cli).is_ok());
assert!(start_session(auth, &timeout_tty, &user, &cli).is_ok());
}
}