Skip to main content

rightkit_daemon/
private.rs

1//! Private per-run child service: a secret passed over stdin, checked in constant time on every
2//! frame, and a child that exits when stdin closes.
3//!
4//! The pattern, for an app that runs its own engine as a child it alone may talk to:
5//!
6//! 1. The parent makes a per-run [`Suite`](crate::Suite) ([`Suite::per_run_temp`](crate::Suite::per_run_temp))
7//!    and a [`PrivateSecret`], starts the child with [`PrivateChild::spawn`] (stdin piped, the
8//!    secret written to it as the first line, never in an argument or an environment variable),
9//!    and tells the child the suite root by an argument.
10//! 2. The child reads the secret with [`PrivateSecret::read_from_stdin`], hosts its service with
11//!    [`ServiceConfig::private_secret`](crate::ServiceConfig::private_secret), and calls
12//!    [`exit_when_stdin_closes`] (or [`watch_stdin_eof`]) so it ends when the parent does, however
13//!    the parent ended (a closed pipe is the one signal that survives a crash or `SIGKILL`).
14//! 3. The parent connects with [`ClientConfig::secret`](crate::ClientConfig::secret). The `hello`
15//!    frame and every `request` frame then carry the secret as a top-level `secret` field; the
16//!    host refuses a frame without it (or with a wrong one) before a handler runs, answers
17//!    `unauthenticated`, and closes the connection.
18//!
19//! The secret is in addition to the transport's own same-user check, not a replacement: it keeps
20//! another process of the same user, which can open the socket, from being served by a child it
21//! did not start. It is 256 bits from the OS random source, compared in constant time over a
22//! fixed-length buffer, redacted in `Debug` and wiped from memory on drop (best effort: the
23//! text copies made to build a frame are not wiped).
24
25use rightkit_child_process::{OwnedChild, OwnedCommand};
26use std::io::{self, BufRead, Read, Write};
27use std::process::Stdio;
28use subtle::ConstantTimeEq;
29
30/// Length of the secret in bytes before hex encoding.
31pub const SECRET_BYTES: usize = 32;
32/// Length of the secret as sent: lowercase hex.
33pub const SECRET_HEX_LEN: usize = SECRET_BYTES * 2;
34
35/// Why a secret could not be produced or read.
36#[derive(Debug)]
37pub enum SecretError {
38    /// The OS random source failed.
39    Random,
40    /// The line was not exactly [`SECRET_HEX_LEN`] lowercase hex digits.
41    Malformed,
42    /// The stream ended before a secret line arrived.
43    Missing,
44    /// Reading failed.
45    Io(io::Error),
46}
47
48impl std::fmt::Display for SecretError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            SecretError::Random => f.write_str("the OS random source failed"),
52            SecretError::Malformed => {
53                write!(
54                    f,
55                    "the secret must be {SECRET_HEX_LEN} lowercase hex digits"
56                )
57            }
58            SecretError::Missing => f.write_str("stdin closed before a secret arrived"),
59            SecretError::Io(e) => write!(f, "reading the secret: {e}"),
60        }
61    }
62}
63impl std::error::Error for SecretError {}
64
65/// A per-run shared secret. Never printed; compare with [`verify`](Self::verify) only.
66#[derive(Clone)]
67pub struct PrivateSecret {
68    /// ASCII lowercase hex, always [`SECRET_HEX_LEN`] bytes.
69    hex: [u8; SECRET_HEX_LEN],
70}
71
72impl std::fmt::Debug for PrivateSecret {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.write_str("PrivateSecret(<redacted>)")
75    }
76}
77
78impl Drop for PrivateSecret {
79    fn drop(&mut self) {
80        for byte in self.hex.iter_mut() {
81            // SAFETY: a valid, aligned, exclusive reference to one byte.
82            unsafe { std::ptr::write_volatile(byte, 0) };
83        }
84        std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
85    }
86}
87
88fn hex_digit(nibble: u8) -> u8 {
89    b"0123456789abcdef"[usize::from(nibble & 0x0f)]
90}
91
92impl PrivateSecret {
93    /// A fresh secret from the OS random source.
94    pub fn generate() -> Result<Self, SecretError> {
95        let mut raw = [0u8; SECRET_BYTES];
96        getrandom::fill(&mut raw).map_err(|_| SecretError::Random)?;
97        let mut hex = [0u8; SECRET_HEX_LEN];
98        for (index, byte) in raw.iter().enumerate() {
99            hex[index * 2] = hex_digit(byte >> 4);
100            hex[index * 2 + 1] = hex_digit(*byte);
101        }
102        for byte in raw.iter_mut() {
103            // SAFETY: as in `Drop`.
104            unsafe { std::ptr::write_volatile(byte, 0) };
105        }
106        Ok(Self { hex })
107    }
108
109    /// Parse the text form (exactly [`SECRET_HEX_LEN`] lowercase hex digits; no surrounding
110    /// whitespace).
111    pub fn from_hex(text: &str) -> Result<Self, SecretError> {
112        let bytes = text.as_bytes();
113        if bytes.len() != SECRET_HEX_LEN
114            || !bytes
115                .iter()
116                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b))
117        {
118            return Err(SecretError::Malformed);
119        }
120        let mut hex = [0u8; SECRET_HEX_LEN];
121        hex.copy_from_slice(bytes);
122        Ok(Self { hex })
123    }
124
125    /// The text form, for putting in a frame or on a pipe. Do not log it.
126    pub fn expose(&self) -> &str {
127        // Only ASCII hex digits are ever stored.
128        std::str::from_utf8(&self.hex).unwrap_or("")
129    }
130
131    /// Constant-time check of a presented secret. The comparison always runs over the full
132    /// fixed-length buffer, and a presented value of the wrong length fails without an early
133    /// return, so neither content nor length-prefix match leaks through timing.
134    pub fn verify(&self, presented: &str) -> bool {
135        let presented = presented.as_bytes();
136        let mut padded = [0u8; SECRET_HEX_LEN];
137        let take = presented.len().min(SECRET_HEX_LEN);
138        padded[..take].copy_from_slice(&presented[..take]);
139        let same = self.hex[..].ct_eq(&padded[..]);
140        let length_ok = (presented.len() as u64).ct_eq(&(SECRET_HEX_LEN as u64));
141        bool::from(same & length_ok)
142    }
143
144    /// Read the secret from the first line of `reader`, at most [`SECRET_HEX_LEN`] plus a line
145    /// ending long. For the child's stdin; the generic form exists for tests.
146    pub fn read_from(reader: &mut impl BufRead) -> Result<Self, SecretError> {
147        let mut line = String::new();
148        let read = (&mut *reader)
149            .take((SECRET_HEX_LEN + 2) as u64)
150            .read_line(&mut line)
151            .map_err(SecretError::Io)?;
152        if read == 0 {
153            return Err(SecretError::Missing);
154        }
155        let result = Self::from_hex(line.trim_end_matches(['\n', '\r']));
156        // Best effort: do not leave the secret text in the temporary line.
157        // SAFETY: overwriting every byte of the string with ASCII zero keeps it valid UTF-8.
158        unsafe {
159            for byte in line.as_bytes_mut() {
160                std::ptr::write_volatile(byte, b'0');
161            }
162        }
163        result
164    }
165
166    /// Read the secret from this process's stdin. Call it once, first, before anything else
167    /// reads stdin; later reads (see [`watch_stdin_eof`]) continue after the secret line.
168    pub fn read_from_stdin() -> Result<Self, SecretError> {
169        Self::read_from(&mut io::stdin().lock())
170    }
171}
172
173/// Call `on_eof` on a background thread when `reader` reaches end of file or fails. Anything the
174/// parent writes after the secret is read and dropped.
175pub fn watch_reader_eof<R, F>(mut reader: R, on_eof: F) -> std::thread::JoinHandle<()>
176where
177    R: Read + Send + 'static,
178    F: FnOnce() + Send + 'static,
179{
180    std::thread::spawn(move || {
181        let mut sink = [0u8; 256];
182        loop {
183            match reader.read(&mut sink) {
184                Ok(0) | Err(_) => break,
185                Ok(_) => {}
186            }
187        }
188        on_eof();
189    })
190}
191
192/// Call `on_eof` when this process's stdin closes: the parent exited or let go of the pipe.
193/// Call after [`PrivateSecret::read_from_stdin`].
194pub fn watch_stdin_eof<F>(on_eof: F) -> std::thread::JoinHandle<()>
195where
196    F: FnOnce() + Send + 'static,
197{
198    watch_reader_eof(io::stdin(), on_eof)
199}
200
201/// End this process with `code` when stdin closes. The child's whole shutdown policy, for a
202/// service with nothing to flush; a service that must flush uses [`watch_stdin_eof`] and its own
203/// exit path.
204pub fn exit_when_stdin_closes(code: i32) -> std::thread::JoinHandle<()> {
205    watch_stdin_eof(move || std::process::exit(code))
206}
207
208/// A child started with a [`PrivateSecret`] on its stdin. Dropping it closes the pipe (the
209/// child's cue to exit) and then the owned child is terminated by its own `Drop`.
210#[derive(Debug)]
211pub struct PrivateChild {
212    stdin: Option<std::process::ChildStdin>,
213    secret: PrivateSecret,
214    child: OwnedChild,
215}
216
217impl PrivateChild {
218    /// Start `command` with a fresh secret. Stdin is replaced with a pipe; the secret is
219    /// written as the first line and the pipe stays open for the child's lifetime. Put nothing
220    /// secret in the arguments or environment.
221    pub fn spawn(command: OwnedCommand) -> io::Result<Self> {
222        let secret = PrivateSecret::generate().map_err(io::Error::other)?;
223        Self::spawn_with(command, secret)
224    }
225
226    /// [`spawn`](Self::spawn) with a secret the caller made.
227    pub fn spawn_with(command: OwnedCommand, secret: PrivateSecret) -> io::Result<Self> {
228        Self::spawn_with_extra(command, secret, None)
229    }
230
231    /// [`spawn_with`](Self::spawn_with) for a child whose handshake is more than the secret: the
232    /// secret line, then `extra_line` (for example a path or a second key), both written before
233    /// this returns. `extra_line` must not contain a newline; a line that does is refused with
234    /// `InvalidInput` before the child starts.
235    pub fn spawn_with_extra(
236        mut command: OwnedCommand,
237        secret: PrivateSecret,
238        extra_line: Option<&str>,
239    ) -> io::Result<Self> {
240        if let Some(line) = extra_line {
241            check_line(line)?;
242        }
243        command.command_mut().stdin(Stdio::piped());
244        let mut child = command.spawn()?;
245        let Some(mut stdin) = child.take_stdin() else {
246            let _ = child.terminate_tree();
247            return Err(io::Error::other("the child's stdin was not piped"));
248        };
249        let mut written = stdin
250            .write_all(secret.expose().as_bytes())
251            .and_then(|()| stdin.write_all(b"\n"));
252        if let Some(line) = extra_line {
253            if written.is_ok() {
254                written = stdin
255                    .write_all(line.as_bytes())
256                    .and_then(|()| stdin.write_all(b"\n"));
257            }
258        }
259        let written = written.and_then(|()| stdin.flush());
260        if let Err(error) = written {
261            let _ = child.terminate_tree();
262            return Err(error);
263        }
264        Ok(Self {
265            stdin: Some(stdin),
266            secret,
267            child,
268        })
269    }
270
271    /// Write one more line to the child's stdin after the handshake and flush it. Fails with
272    /// `BrokenPipe` once the stdin is closed. `line` must not contain a newline.
273    pub fn write_line(&mut self, line: &str) -> io::Result<()> {
274        check_line(line)?;
275        let Some(stdin) = self.stdin.as_mut() else {
276            return Err(io::Error::new(
277                io::ErrorKind::BrokenPipe,
278                "the child's stdin is closed",
279            ));
280        };
281        stdin.write_all(line.as_bytes())?;
282        stdin.write_all(b"\n")?;
283        stdin.flush()
284    }
285
286    /// The secret to give a [`ClientConfig`](crate::ClientConfig).
287    pub fn secret(&self) -> &PrivateSecret {
288        &self.secret
289    }
290
291    /// The owned child, for waiting, termination and the rest of its lifecycle.
292    pub fn child(&self) -> &OwnedChild {
293        &self.child
294    }
295
296    /// Mutable access to the owned child.
297    pub fn child_mut(&mut self) -> &mut OwnedChild {
298        &mut self.child
299    }
300
301    /// Close the child's stdin: the polite request to exit. Idempotent.
302    pub fn close_stdin(&mut self) {
303        drop(self.stdin.take());
304    }
305}
306
307/// A line sent on stdin must hold no newline: a newline would split one handshake line in two.
308fn check_line(line: &str) -> io::Result<()> {
309    if line.contains('\n') {
310        return Err(io::Error::new(
311            io::ErrorKind::InvalidInput,
312            "a stdin line cannot contain a newline",
313        ));
314    }
315    Ok(())
316}
317
318impl Drop for PrivateChild {
319    fn drop(&mut self) {
320        // Field order would drop the pipe first anyway; do it explicitly so the child hears
321        // the polite signal before the owned child's terminate-on-drop.
322        self.close_stdin();
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn generated_secrets_are_hex_and_distinct() {
332        let a = PrivateSecret::generate().unwrap();
333        let b = PrivateSecret::generate().unwrap();
334        assert_eq!(a.expose().len(), SECRET_HEX_LEN);
335        assert!(a.expose().bytes().all(|c| c.is_ascii_hexdigit()));
336        assert_ne!(a.expose(), b.expose());
337    }
338
339    #[test]
340    fn verify_accepts_only_the_exact_secret() {
341        let secret = PrivateSecret::generate().unwrap();
342        assert!(secret.verify(secret.expose()));
343        assert!(!secret.verify(""));
344        assert!(!secret.verify("x"));
345        let mut wrong = secret.expose().to_string();
346        wrong.replace_range(0..1, if wrong.starts_with('0') { "1" } else { "0" });
347        assert!(!secret.verify(&wrong));
348        // A truncation and an extension of the right secret both fail.
349        assert!(!secret.verify(&secret.expose()[..SECRET_HEX_LEN - 1]));
350        assert!(!secret.verify(&format!("{}0", secret.expose())));
351        // Zero padding must not match a secret of zeros shorter than the buffer.
352        assert!(!secret.verify(&"0".repeat(SECRET_HEX_LEN - 1)));
353    }
354
355    #[test]
356    fn debug_never_shows_the_secret() {
357        let secret = PrivateSecret::generate().unwrap();
358        assert!(!format!("{secret:?}").contains(secret.expose()));
359    }
360
361    #[test]
362    fn from_hex_rejects_malformed_text() {
363        assert!(PrivateSecret::from_hex("").is_err());
364        assert!(PrivateSecret::from_hex(&"A".repeat(SECRET_HEX_LEN)).is_err());
365        assert!(PrivateSecret::from_hex(&"g".repeat(SECRET_HEX_LEN)).is_err());
366        assert!(PrivateSecret::from_hex(&"a".repeat(SECRET_HEX_LEN)).is_ok());
367    }
368
369    #[test]
370    fn read_from_takes_the_first_line_only() {
371        let secret = PrivateSecret::generate().unwrap();
372        let input = format!("{}\nrest\n", secret.expose()).into_bytes();
373        let mut reader = io::Cursor::new(input);
374        let read = PrivateSecret::read_from(&mut reader).unwrap();
375        assert!(secret.verify(read.expose()));
376    }
377
378    #[test]
379    fn read_from_reports_missing_and_oversized_input() {
380        let mut empty = io::Cursor::new(Vec::<u8>::new());
381        assert!(matches!(
382            PrivateSecret::read_from(&mut empty),
383            Err(SecretError::Missing)
384        ));
385        let mut long = io::Cursor::new("a".repeat(10_000).into_bytes());
386        assert!(matches!(
387            PrivateSecret::read_from(&mut long),
388            Err(SecretError::Malformed)
389        ));
390    }
391
392    #[test]
393    fn eof_watcher_fires_when_the_reader_ends() {
394        let (tx, rx) = std::sync::mpsc::channel();
395        let handle = watch_reader_eof(io::Cursor::new(b"ignored bytes".to_vec()), move || {
396            tx.send(()).unwrap();
397        });
398        rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
399        handle.join().unwrap();
400    }
401}