use rightkit_child_process::{OwnedChild, OwnedCommand};
use std::io::{self, BufRead, Read, Write};
use std::process::Stdio;
use subtle::ConstantTimeEq;
pub const SECRET_BYTES: usize = 32;
pub const SECRET_HEX_LEN: usize = SECRET_BYTES * 2;
#[derive(Debug)]
pub enum SecretError {
Random,
Malformed,
Missing,
Io(io::Error),
}
impl std::fmt::Display for SecretError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SecretError::Random => f.write_str("the OS random source failed"),
SecretError::Malformed => {
write!(
f,
"the secret must be {SECRET_HEX_LEN} lowercase hex digits"
)
}
SecretError::Missing => f.write_str("stdin closed before a secret arrived"),
SecretError::Io(e) => write!(f, "reading the secret: {e}"),
}
}
}
impl std::error::Error for SecretError {}
#[derive(Clone)]
pub struct PrivateSecret {
hex: [u8; SECRET_HEX_LEN],
}
impl std::fmt::Debug for PrivateSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PrivateSecret(<redacted>)")
}
}
impl Drop for PrivateSecret {
fn drop(&mut self) {
for byte in self.hex.iter_mut() {
unsafe { std::ptr::write_volatile(byte, 0) };
}
std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
}
}
fn hex_digit(nibble: u8) -> u8 {
b"0123456789abcdef"[usize::from(nibble & 0x0f)]
}
impl PrivateSecret {
pub fn generate() -> Result<Self, SecretError> {
let mut raw = [0u8; SECRET_BYTES];
getrandom::fill(&mut raw).map_err(|_| SecretError::Random)?;
let mut hex = [0u8; SECRET_HEX_LEN];
for (index, byte) in raw.iter().enumerate() {
hex[index * 2] = hex_digit(byte >> 4);
hex[index * 2 + 1] = hex_digit(*byte);
}
for byte in raw.iter_mut() {
unsafe { std::ptr::write_volatile(byte, 0) };
}
Ok(Self { hex })
}
pub fn from_hex(text: &str) -> Result<Self, SecretError> {
let bytes = text.as_bytes();
if bytes.len() != SECRET_HEX_LEN
|| !bytes
.iter()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b))
{
return Err(SecretError::Malformed);
}
let mut hex = [0u8; SECRET_HEX_LEN];
hex.copy_from_slice(bytes);
Ok(Self { hex })
}
pub fn expose(&self) -> &str {
std::str::from_utf8(&self.hex).unwrap_or("")
}
pub fn verify(&self, presented: &str) -> bool {
let presented = presented.as_bytes();
let mut padded = [0u8; SECRET_HEX_LEN];
let take = presented.len().min(SECRET_HEX_LEN);
padded[..take].copy_from_slice(&presented[..take]);
let same = self.hex[..].ct_eq(&padded[..]);
let length_ok = (presented.len() as u64).ct_eq(&(SECRET_HEX_LEN as u64));
bool::from(same & length_ok)
}
pub fn read_from(reader: &mut impl BufRead) -> Result<Self, SecretError> {
let mut line = String::new();
let read = (&mut *reader)
.take((SECRET_HEX_LEN + 2) as u64)
.read_line(&mut line)
.map_err(SecretError::Io)?;
if read == 0 {
return Err(SecretError::Missing);
}
let result = Self::from_hex(line.trim_end_matches(['\n', '\r']));
unsafe {
for byte in line.as_bytes_mut() {
std::ptr::write_volatile(byte, b'0');
}
}
result
}
pub fn read_from_stdin() -> Result<Self, SecretError> {
Self::read_from(&mut io::stdin().lock())
}
}
pub fn watch_reader_eof<R, F>(mut reader: R, on_eof: F) -> std::thread::JoinHandle<()>
where
R: Read + Send + 'static,
F: FnOnce() + Send + 'static,
{
std::thread::spawn(move || {
let mut sink = [0u8; 256];
loop {
match reader.read(&mut sink) {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
on_eof();
})
}
pub fn watch_stdin_eof<F>(on_eof: F) -> std::thread::JoinHandle<()>
where
F: FnOnce() + Send + 'static,
{
watch_reader_eof(io::stdin(), on_eof)
}
pub fn exit_when_stdin_closes(code: i32) -> std::thread::JoinHandle<()> {
watch_stdin_eof(move || std::process::exit(code))
}
#[derive(Debug)]
pub struct PrivateChild {
stdin: Option<std::process::ChildStdin>,
secret: PrivateSecret,
child: OwnedChild,
}
impl PrivateChild {
pub fn spawn(command: OwnedCommand) -> io::Result<Self> {
let secret = PrivateSecret::generate().map_err(io::Error::other)?;
Self::spawn_with(command, secret)
}
pub fn spawn_with(command: OwnedCommand, secret: PrivateSecret) -> io::Result<Self> {
Self::spawn_with_extra(command, secret, None)
}
pub fn spawn_with_extra(
mut command: OwnedCommand,
secret: PrivateSecret,
extra_line: Option<&str>,
) -> io::Result<Self> {
if let Some(line) = extra_line {
check_line(line)?;
}
command.command_mut().stdin(Stdio::piped());
let mut child = command.spawn()?;
let Some(mut stdin) = child.take_stdin() else {
let _ = child.terminate_tree();
return Err(io::Error::other("the child's stdin was not piped"));
};
let mut written = stdin
.write_all(secret.expose().as_bytes())
.and_then(|()| stdin.write_all(b"\n"));
if let Some(line) = extra_line {
if written.is_ok() {
written = stdin
.write_all(line.as_bytes())
.and_then(|()| stdin.write_all(b"\n"));
}
}
let written = written.and_then(|()| stdin.flush());
if let Err(error) = written {
let _ = child.terminate_tree();
return Err(error);
}
Ok(Self {
stdin: Some(stdin),
secret,
child,
})
}
pub fn write_line(&mut self, line: &str) -> io::Result<()> {
check_line(line)?;
let Some(stdin) = self.stdin.as_mut() else {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"the child's stdin is closed",
));
};
stdin.write_all(line.as_bytes())?;
stdin.write_all(b"\n")?;
stdin.flush()
}
pub fn secret(&self) -> &PrivateSecret {
&self.secret
}
pub fn child(&self) -> &OwnedChild {
&self.child
}
pub fn child_mut(&mut self) -> &mut OwnedChild {
&mut self.child
}
pub fn close_stdin(&mut self) {
drop(self.stdin.take());
}
}
fn check_line(line: &str) -> io::Result<()> {
if line.contains('\n') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"a stdin line cannot contain a newline",
));
}
Ok(())
}
impl Drop for PrivateChild {
fn drop(&mut self) {
self.close_stdin();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_secrets_are_hex_and_distinct() {
let a = PrivateSecret::generate().unwrap();
let b = PrivateSecret::generate().unwrap();
assert_eq!(a.expose().len(), SECRET_HEX_LEN);
assert!(a.expose().bytes().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a.expose(), b.expose());
}
#[test]
fn verify_accepts_only_the_exact_secret() {
let secret = PrivateSecret::generate().unwrap();
assert!(secret.verify(secret.expose()));
assert!(!secret.verify(""));
assert!(!secret.verify("x"));
let mut wrong = secret.expose().to_string();
wrong.replace_range(0..1, if wrong.starts_with('0') { "1" } else { "0" });
assert!(!secret.verify(&wrong));
assert!(!secret.verify(&secret.expose()[..SECRET_HEX_LEN - 1]));
assert!(!secret.verify(&format!("{}0", secret.expose())));
assert!(!secret.verify(&"0".repeat(SECRET_HEX_LEN - 1)));
}
#[test]
fn debug_never_shows_the_secret() {
let secret = PrivateSecret::generate().unwrap();
assert!(!format!("{secret:?}").contains(secret.expose()));
}
#[test]
fn from_hex_rejects_malformed_text() {
assert!(PrivateSecret::from_hex("").is_err());
assert!(PrivateSecret::from_hex(&"A".repeat(SECRET_HEX_LEN)).is_err());
assert!(PrivateSecret::from_hex(&"g".repeat(SECRET_HEX_LEN)).is_err());
assert!(PrivateSecret::from_hex(&"a".repeat(SECRET_HEX_LEN)).is_ok());
}
#[test]
fn read_from_takes_the_first_line_only() {
let secret = PrivateSecret::generate().unwrap();
let input = format!("{}\nrest\n", secret.expose()).into_bytes();
let mut reader = io::Cursor::new(input);
let read = PrivateSecret::read_from(&mut reader).unwrap();
assert!(secret.verify(read.expose()));
}
#[test]
fn read_from_reports_missing_and_oversized_input() {
let mut empty = io::Cursor::new(Vec::<u8>::new());
assert!(matches!(
PrivateSecret::read_from(&mut empty),
Err(SecretError::Missing)
));
let mut long = io::Cursor::new("a".repeat(10_000).into_bytes());
assert!(matches!(
PrivateSecret::read_from(&mut long),
Err(SecretError::Malformed)
));
}
#[test]
fn eof_watcher_fires_when_the_reader_ends() {
let (tx, rx) = std::sync::mpsc::channel();
let handle = watch_reader_eof(io::Cursor::new(b"ignored bytes".to_vec()), move || {
tx.send(()).unwrap();
});
rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
handle.join().unwrap();
}
}