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}