src/world/bin/su/src/bin/jah.rs

237 lines
8.2 KiB
Rust

#![warn(clippy::all, clippy::pedantic)]
use clap::{Arg, ValueHint};
use libcrypt_rs::Crypt;
use su::Error;
use std::{
ffi::{CStr, CString},
fs::{DirBuilder, File, OpenOptions},
io::{self, Write},
os::unix::{
fs::DirBuilderExt,
prelude::{CommandExt, OpenOptionsExt},
},
path::PathBuf,
process::Command,
};
static PROGNAME: &str = "jah";
#[allow(clippy::similar_names)]
fn main() -> Result<(), Error> {
// Put the entire command into a let binding to a code block, ensuring that
// any values we create on the way to the final Command struct are freed
// before wee exec into the final command.
let mut cmd = {
// get_group_names internally creates a libc::pw struct. At least on some
// architectures with musl, this memory location gets re-used when we create
// another pw struct, this time for the target user. We can either use all
// owned variables or else just move this block to the beginning of the
// program in which case the borrows will all be valid.
let grnames = get_group_names()?;
// if we have a group named for this program and the user is a member
// of it, we'll skip validation
if !grnames.contains(&PROGNAME.to_string()) {
// members of "wheel" are always allowed with a password
if grnames.contains(&"wheel".to_string()) {
validate_pw()?;
} else {
// nobody else can use the program
return Err(Error::PermissionDenied);
}
}
let matches = cli().get_matches();
// theoretically this can never fail since we have a default value, but
// I'm not a fan of `unwrap()`
let Some(user) = matches.get_one::<String>("user") else {
return Err("no user given".into());
};
let Some(group) = matches.get_one::<String>("group") else {
return Err("no group given".into());
};
let pw = unsafe {
let pw = libc::getpwnam(CString::new(user.as_str())?.as_ptr());
if pw.is_null() {
return Err(Error::Other("getpwnam: null pointer".to_string()));
}
*pw
};
let gr = unsafe {
let gr = libc::getgrnam(CString::new(group.as_str())?.as_ptr());
if gr.is_null() {
return Err(Error::Other("getgrnam: null pointer".to_string()));
}
*gr
};
let home = unsafe { CStr::from_ptr(pw.pw_dir).to_str()? };
let uid = pw.pw_uid;
let gid = gr.gr_gid;
let Some(mut command) = matches.get_many::<String>("command") else {
return Err("no command given".into());
};
let Some(cmd) = command.next() else {
return Err("no command given".into());
};
let mut cmd = Command::new(cmd);
cmd.uid(uid).gid(gid).env("HOME", home);
command.for_each(|arg| {
cmd.arg(arg);
});
cmd
};
Err(cmd.exec().into())
}
fn get_group_names() -> Result<Vec<String>, Error> {
let mut gids: Vec<libc::gid_t> = vec![0; 1];
unsafe {
let num = libc::getgroups(0, gids.as_mut_ptr());
gids = vec![0; num.try_into()?];
libc::getgroups(num, gids.as_mut_ptr());
}
let mut names = vec![];
for id in gids {
let name = unsafe {
let gr = libc::getgrgid(id);
if gr.is_null() {
id.to_string()
} else {
let name = (*gr).gr_name;
// if we don't take ownership here the OS can (and will)
// reuse the memory
CStr::from_ptr(name).to_str()?.to_string()
}
};
names.push(name);
}
Ok(names)
}
fn validate_pw() -> Result<(), Error> {
// return a tuple from this unsafe block in order to group as much unsafe
// into a single block as possible
let (name, oldpass) = unsafe {
let uid = libc::getuid();
let pw = libc::getpwuid(uid);
if pw.is_null() {
return Err(Error::Other("null pointer".to_string()));
}
let name = (*pw).pw_name;
let spw = libc::getspnam(name);
if spw.is_null() {
return Err(Error::Other("null pointer".to_string()));
}
(
CStr::from_ptr(name).to_str()?,
CStr::from_ptr((*spw).sp_pwdp).to_str()?,
)
};
// If we've already validated the password in the past five minutes, skip
// doing it again
if check_ts(name) {
return Ok(());
}
let pass = rpassword::prompt_password(format!("[{PROGNAME}] password for {name}: "))?;
// deny empty passwords
if oldpass.starts_with('\0') {
return Err(Error::InvalidPassword);
}
let mut engine = Crypt::new();
// libcrypt gets the encryption salt from the old hashed password
engine.set_salt(oldpass.to_string())?;
engine.encrypt(pass)?;
if oldpass == engine.encrypted.as_str() {
set_ts(name)?;
Ok(())
} else {
Err(Error::IncorrectPassword)
}
}
// uses a timestamp file to see if we've authenticated in the past five minutes
// and if so skip re-authenticating
fn check_ts(name: &str) -> bool {
let ts_file: PathBuf = ["/run/jah/ts", name].iter().collect();
if ts_file.exists() {
// We'll go ahead and throw out any errors here using `ok()` and fall
// back to validating via password
if let Some(elapsed) = File::open(&ts_file)
.and_then(|x| x.metadata())
.and_then(|x| x.modified())
.ok()
.and_then(|x| x.elapsed().ok())
{
if elapsed.as_secs() < 300 {
return true;
}
}
}
false
}
// creates (if it doesn't exist) and sets a timestamp file to see if we've
// authenticated in the past five minutes
fn set_ts(name: &str) -> Result<(), io::Error> {
let mut ts_file = PathBuf::from("/run/jah/ts");
if !ts_file.exists() {
DirBuilder::new()
.recursive(true)
// deny access for all but root
.mode(0o700)
.create(&ts_file)?;
}
ts_file.push(name);
let mut fd = OpenOptions::new()
.write(true)
.truncate(true)
.create(true)
// deny access for all but root
.mode(0o600)
.open(&ts_file)?;
let _n = fd.write(&[])?;
Ok(())
}
pub fn cli() -> clap::Command {
clap::Command::new(PROGNAME)
.about("Run COMMAND as another user")
.author("Nathan Fisher")
.version(env!("CARGO_PKG_VERSION"))
.before_long_help(format!(
"The {PROGNAME} program allows to run a command as another user (default root). \
This command is kept intentionally simple for the sake of security and \
has no configuration file. Members of the group \"wheel\" (if it exists) \
can run any command after giving their password. Members of the special \
group \"{PROGNAME}\" (if it exists) can run any command without a password, \
provided they also belong to the \"wheel\" group."
))
.args([
Arg::new("user")
.help("The user that the command will run as")
.short('u')
.long("user")
.default_value("root")
.num_args(1),
Arg::new("group")
.help("The group that the command will run as")
.short('g')
.long("group")
.default_value("wheel")
.num_args(1),
// Waiting on https://github.com/rust-lang/rust/issues/90747
//Arg::new("groups")
// .help("A comma delimited list of supplementary groups to add to the process")
// .short('G')
// .long("groups")
// .value_delimiter(',')
// .num_args(1..)
// .required(false),
Arg::new("command")
.value_name("COMMAND")
.value_hint(ValueHint::CommandWithArguments)
.allow_hyphen_values(true)
.trailing_var_arg(true)
.num_args(1..)
.required(true),
])
}