Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement the pwfeedback option #945

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/common/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub struct Context {
pub process: Process,
// policy
pub use_pty: bool,
pub password_feedback: bool,
}

#[derive(Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -89,6 +90,7 @@ impl Context {
non_interactive: sudo_options.non_interactive,
process: Process::new(),
use_pty: true,
password_feedback: false,
})
}
}
Expand Down
1 change: 1 addition & 0 deletions src/defaults/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ defaults! {
match_group_by_gid = false #ignored
use_pty = true
visiblepw = false #ignored
pwfeedback = false
env_editor = true

passwd_tries = 3 [0..=1000]
Expand Down
7 changes: 6 additions & 1 deletion src/pam/converse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ pub struct CLIConverser {
pub(super) name: String,
pub(super) use_stdin: bool,
pub(super) no_interact: bool,
pub(super) password_feedback: bool,
}

use rpassword::Terminal;
Expand Down Expand Up @@ -157,7 +158,11 @@ impl SequentialConverser for CLIConverser {
}
let mut tty = self.open()?;
tty.prompt(&format!("[{}: authenticate] {msg}", self.name))?;
Ok(tty.read_password()?)
if self.password_feedback {
Ok(tty.read_password_with_feedback()?)
} else {
Ok(tty.read_password()?)
}
}

fn handle_error(&self, msg: &str) -> PamResult<()> {
Expand Down
2 changes: 2 additions & 0 deletions src/pam/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,11 +378,13 @@ impl PamContext<CLIConverser> {
name: &str,
use_stdin: bool,
no_interact: bool,
password_feedback: bool,
) -> PamContextBuilder<CLIConverser> {
PamContextBuilder::default().converser(CLIConverser {
name: name.to_owned(),
use_stdin,
no_interact,
password_feedback,
})
}
}
Expand Down
115 changes: 98 additions & 17 deletions src/pam/rpassword.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,45 +14,49 @@
/// (although much more robust than in the original code)
///
use std::io::{self, Error, ErrorKind, Read};
use std::os::fd::{AsRawFd, RawFd};
use std::os::fd::{AsFd, AsRawFd};
use std::{fs, mem};

use libc::{tcsetattr, termios, ECHO, ECHONL, TCSANOW};
use libc::{tcsetattr, termios, ECHO, ECHONL, ICANON, TCSANOW, VEOF, VERASE, VKILL};

use crate::cutils::cerr;

use super::securemem::PamBuffer;

pub struct HiddenInput {
struct HiddenInput {
tty: fs::File,
term_orig: termios,
}

impl HiddenInput {
fn new() -> io::Result<Option<HiddenInput>> {
fn new(feedback: bool) -> io::Result<Option<HiddenInput>> {
// control ourselves that we are really talking to a TTY
// mitigates: https://marc.info/?l=oss-security&m=168164424404224
let Ok(tty) = fs::File::open("/dev/tty") else {
// if we have nothing to show, we have nothing to hide
return Ok(None);
};
let fd = tty.as_raw_fd();

// Make two copies of the terminal settings. The first one will be modified
// and the second one will act as a backup for when we want to set the
// terminal back to its original state.
let mut term = safe_tcgetattr(fd)?;
let term_orig = safe_tcgetattr(fd)?;
let mut term = safe_tcgetattr(&tty)?;
let term_orig = safe_tcgetattr(&tty)?;

// Hide the password. This is what makes this function useful.
term.c_lflag &= !ECHO;

// But don't hide the NL character when the user hits ENTER.
term.c_lflag |= ECHONL;

if feedback {
// Disable canonical mode to read character by character when pwfeedback is enabled.
term.c_lflag &= !ICANON;
}

// Save the settings for now.
// SAFETY: we are passing tcsetattr a valid file descriptor and pointer-to-struct
cerr(unsafe { tcsetattr(fd, TCSANOW, &term) })?;
cerr(unsafe { tcsetattr(tty.as_raw_fd(), TCSANOW, &term) })?;

Ok(Some(HiddenInput { tty, term_orig }))
}
Expand All @@ -68,16 +72,16 @@ impl Drop for HiddenInput {
}
}

fn safe_tcgetattr(fd: RawFd) -> io::Result<termios> {
fn safe_tcgetattr(tty: impl AsFd) -> io::Result<termios> {
let mut term = mem::MaybeUninit::<termios>::uninit();
// SAFETY: we are passing tcgetattr a pointer to valid memory
cerr(unsafe { ::libc::tcgetattr(fd, term.as_mut_ptr()) })?;
cerr(unsafe { ::libc::tcgetattr(tty.as_fd().as_raw_fd(), term.as_mut_ptr()) })?;
// SAFETY: if the previous call was a success, `tcgetattr` has initialized `term`
Ok(unsafe { term.assume_init() })
}

/// Reads a password from the given file descriptor
fn read_unbuffered(source: &mut impl io::Read) -> io::Result<PamBuffer> {
fn read_unbuffered(source: &mut dyn io::Read) -> io::Result<PamBuffer> {
let mut password = PamBuffer::default();
let mut pwd_iter = password.iter_mut();

Expand All @@ -98,8 +102,73 @@ fn read_unbuffered(source: &mut impl io::Read) -> io::Result<PamBuffer> {
Ok(password)
}

fn erase_feedback(sink: &mut dyn io::Write, i: usize) {
const BACKSPACE: u8 = 0x08;
for _ in 0..i {
if sink.write(&[BACKSPACE, b' ', BACKSPACE]).is_err() {
return;
}
}
}

/// Reads a password from the given file descriptor while showing feedback to the user.
fn read_unbuffered_with_feedback(
source: &mut dyn io::Read,
sink: &mut dyn io::Write,
hide_input: &HiddenInput,
) -> io::Result<PamBuffer> {
let mut password = PamBuffer::default();
let mut pw_len = 0;

// invariant: the amount of nonzero-bytes in the buffer correspond
// with the amount of asterisks on the terminal (both tracked in `pw_len`)
for read_byte in source.bytes() {
let read_byte = read_byte?;

if read_byte == b'\n' || read_byte == b'\r' {
erase_feedback(sink, pw_len);
let _ = sink.write(b"\n");
break;
}

if read_byte == hide_input.term_orig.c_cc[VEOF] {
erase_feedback(sink, pw_len);
password.fill(0);
break;
}

if read_byte == hide_input.term_orig.c_cc[VERASE] {
if pw_len > 0 {
erase_feedback(sink, 1);
password[pw_len - 1] = 0;
pw_len -= 1;
}
} else if read_byte == hide_input.term_orig.c_cc[VKILL] {
erase_feedback(sink, pw_len);
password.fill(0);
pw_len = 0;
} else {
#[allow(clippy::collapsible_else_if)]
if let Some(dest) = password.get_mut(pw_len) {
*dest = read_byte;
pw_len += 1;
let _ = sink.write(b"*");
} else {
erase_feedback(sink, pw_len);

return Err(Error::new(
ErrorKind::OutOfMemory,
"incorrect password attempt",
));
}
}
}

Ok(password)
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice literal translation (and pretty old code in the ogsudo code base as well, it appears) but I'm tempted to try and clean that up a bit.


/// Write something and immediately flush
fn write_unbuffered(sink: &mut impl io::Write, text: &str) -> io::Result<()> {
fn write_unbuffered(sink: &mut dyn io::Write, text: &str) -> io::Result<()> {
sink.write_all(text.as_bytes())?;
sink.flush()
}
Expand Down Expand Up @@ -128,19 +197,31 @@ impl Terminal<'_> {

/// Reads input with TTY echo disabled
pub fn read_password(&mut self) -> io::Result<PamBuffer> {
let mut input = self.source();
let _hide_input = HiddenInput::new()?;
read_unbuffered(&mut input)
let input = self.source();
let _hide_input = HiddenInput::new(false)?;
read_unbuffered(input)
}

/// Reads input with TTY echo disabled, but do provide visual feedback while typing.
pub fn read_password_with_feedback(&mut self) -> io::Result<PamBuffer> {
if let Some(hide_input) = HiddenInput::new(true)? {
match self {
Terminal::StdIE(x, y) => read_unbuffered_with_feedback(x, y, &hide_input),
Terminal::Tty(x) => read_unbuffered_with_feedback(&mut &*x, &mut &*x, &hide_input),
}
} else {
read_unbuffered(self.source())
}
}

/// Reads input with TTY echo enabled
pub fn read_cleartext(&mut self) -> io::Result<PamBuffer> {
read_unbuffered(&mut self.source())
read_unbuffered(self.source())
}

/// Display information
pub fn prompt(&mut self, text: &str) -> io::Result<()> {
write_unbuffered(&mut self.sink(), text)
write_unbuffered(self.sink(), text)
}

// boilerplate reduction functions
Expand Down
2 changes: 1 addition & 1 deletion src/su/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fn authenticate(
"su"
};
let use_stdin = true;
let mut pam = PamContext::builder_cli("su", use_stdin, false)
let mut pam = PamContext::builder_cli("su", use_stdin, false, false)
.target_user(user)
.service_name(context)
.build()?;
Expand Down
4 changes: 4 additions & 0 deletions src/sudo/env/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ mod tests {
fn use_pty(&self) -> bool {
true
}

fn pwfeedback(&self) -> bool {
false
}
}

impl TestConfiguration {
Expand Down
1 change: 1 addition & 0 deletions src/sudo/env/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ fn create_test_context(sudo_options: &SudoRunOptions) -> Context {
process: Process::new(),
use_session_records: false,
use_pty: true,
password_feedback: false,
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/sudo/pam.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ impl PamAuthenticator<CLIConverser> {
matches!(context.launch, LaunchType::Shell),
context.stdin,
context.non_interactive,
context.password_feedback,
&context.current_user.name,
&context.current_user.name,
)
Expand Down Expand Up @@ -107,6 +108,7 @@ pub fn init_pam(
is_shell: bool,
use_stdin: bool,
non_interactive: bool,
password_feedback: bool,
auth_user: &str,
requesting_user: &str,
) -> PamResult<PamContext<CLIConverser>> {
Expand All @@ -116,7 +118,7 @@ pub fn init_pam(
} else {
"sudo"
};
let mut pam = PamContext::builder_cli("sudo", use_stdin, non_interactive)
let mut pam = PamContext::builder_cli("sudo", use_stdin, non_interactive, password_feedback)
.service_name(service_name)
.build()?;
pam.mark_silent(!is_shell && !is_login_shell);
Expand Down
4 changes: 4 additions & 0 deletions src/sudo/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ impl<Policy: PolicyPlugin, Auth: AuthPlugin> Pipeline<Policy, Auth> {
context.use_pty = false
}

if policy.pwfeedback() {
context.password_feedback = true;
}

Ok(())
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/sudoers/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub trait Policy {
fn secure_path(&self) -> Option<String>;

fn use_pty(&self) -> bool;
fn pwfeedback(&self) -> bool;
}

#[must_use]
Expand Down Expand Up @@ -90,6 +91,10 @@ impl Policy for Judgement {
fn use_pty(&self) -> bool {
self.settings.use_pty()
}

fn pwfeedback(&self) -> bool {
self.settings.pwfeedback()
}
}

pub trait PreJudgementPolicy {
Expand Down
Loading