Skip to main content

max / alloy

9.9 KB · 217 lines History Blame Raw
1 //! The setuid helper's wire protocol.
2 //!
3 //! Plain text on stdin and stdout, one message per line. Taking `ask` as a
4 //! closure is what keeps the whole conversation testable against a scripted
5 //! helper, with no D-Bus and no terminal anywhere near it.
6
7 use std::io::{BufRead, BufReader, Write};
8 use std::path::Path;
9 use std::process::{Child, Stdio};
10 use std::sync::Arc;
11 use std::sync::atomic::{AtomicBool, Ordering};
12
13 use anyhow::{Context, Result, bail};
14
15 use super::Prompt;
16 use crate::cli::child_command;
17
18 /// Run the helper's conversation to its end.
19 ///
20 /// `ask` is how a question reaches the screen; it returns once the prompt has
21 /// been handed over, and the answer comes back through the prompt's own reply
22 /// channel. Taking it as a closure is what keeps this function testable against
23 /// a scripted helper with no D-Bus and no terminal anywhere near it.
24 ///
25 /// `withdrawn` is polkit's cancellation, shared with every prompt this hands
26 /// out. Checked between messages so a conversation whose command has gone away
27 /// stops rather than asking the next question in a sequence nobody is waiting
28 /// on the end of.
29 pub(super) fn converse(
30 helper: &Path,
31 user: &str,
32 cookie: &str,
33 action_id: &str,
34 message: &str,
35 withdrawn: &Arc<AtomicBool>,
36 ask: impl Fn(Prompt) -> Result<()>,
37 ) -> Result<()> {
38 // Wrapped in the guard on the same expression that spawns it, and that is
39 // load-bearing rather than style. Everything below this line can leave by
40 // `?`, and `std::process::Child` has no `Drop`: a helper let go of that way
41 // is neither killed nor reaped. Measured, not reasoned about — see
42 // `a_question_nobody_can_receive_still_closes_the_helper`, which failed
43 // with one process left behind before this existed.
44 let mut child = Helper(
45 child_command(helper)
46 .arg(user)
47 .stdin(Stdio::piped())
48 .stdout(Stdio::piped())
49 .stderr(Stdio::null())
50 .spawn()
51 .with_context(|| format!("could not run {}", helper.display()))?,
52 );
53
54 let mut stdin = child.0.stdin.take().context("the helper took no stdin")?;
55 let stdout = child
56 .0
57 .stdout
58 .take()
59 .context("the helper wrote no stdout")?;
60 let mut lines = BufReader::new(stdout).lines();
61
62 // The cookie first, on its own line. It is what polkit gave us and what the
63 // helper hands back to prove this conversation is the one polkit asked for.
64 //
65 // Checked for a newline for exactly the reason the answer is, further down,
66 // and it was the half that was not. The helper's protocol is one line per
67 // message, so a cookie carrying a newline ends the line early and everything
68 // after it is read as the *next* message — which at that point in the
69 // exchange is the response to the first PAM prompt. That is a password
70 // supplied by the caller without anyone being asked.
71 //
72 // Unreachable today and stated anyway, which is this file's habit
73 // everywhere else: polkitd generates the cookie, the bus lets nobody else
74 // call this interface (see [`Listener`]), and both of those are somebody
75 // else's file rather than an invariant this code holds. The same argument
76 // the `printable` calls are made under.
77 if cookie.contains('\n') {
78 bail!("polkit sent a cookie containing a newline");
79 }
80 writeln!(stdin, "{cookie}").context("the helper closed before the cookie")?;
81
82 while let Some(line) = lines.next().transpose().context("the helper stopped")? {
83 if withdrawn.load(Ordering::Relaxed) {
84 bail!("withdrawn");
85 }
86 match Directive::parse(&line) {
87 Some(Directive::Prompt { question, echo }) => {
88 let (prompt, answers) =
89 Prompt::new(action_id, message, &question, echo, Arc::clone(withdrawn));
90 ask(prompt)?;
91
92 // A closed channel is the screen going away mid-prompt, which
93 // is a dismissal rather than an answer.
94 let answer = answers.recv().unwrap_or(None);
95 let Some(answer) = answer else {
96 bail!("dismissed");
97 };
98 // Checked again here, and not only at the top of the loop.
99 // The loop's check is taken before the question goes up, so on
100 // its own it covers a withdrawal that arrives before anyone is
101 // asked and nothing after: a withdrawal landing while the
102 // person is typing would be seen by the screen, which takes the
103 // modal down, and by nothing here until the *next* message —
104 // and there is no next message, because the answer is written
105 // first. So an Enter that beats the screen's poll would put the
106 // password on the wire of a conversation polkit has abandoned,
107 // which is precisely the failure the flag exists to stop.
108 //
109 // What is left after this is the window between the load and
110 // the write, and it is not closed here on purpose. Closing it
111 // would mean the withdrawal and the write taking one lock, and
112 // the withdrawal arrives on
113 // [`cancel_authentication`](Listener::cancel_authentication),
114 // which zbus dispatches on the single executor thread this
115 // handler was made `async` to stop blocking. A write to a pipe
116 // whose reader is a setuid helper mid-PAM can block, so that
117 // lock would hand the console's freeze back for a race whose
118 // outcome is already indistinguishable: a withdrawal that lands
119 // after the bytes leave is one that lands after the helper has
120 // them, and no amount of locking on this side changes that.
121 if withdrawn.load(Ordering::Relaxed) {
122 bail!("withdrawn");
123 }
124 // The caller's obligation from [`Prompt::answer`], enforced
125 // where it is relied on. A newline inside the answer would end
126 // the line early and turn the rest into the next message of a
127 // protocol that is deciding whether to authorize something, so
128 // an answer carrying one is refused rather than written. The
129 // value is not named in the error: it is the password.
130 if answer.expose().contains(&b'\n') {
131 bail!("an answer cannot contain a newline");
132 }
133 // Written as bytes rather than through `writeln!`, because the
134 // answer is a `Secret` and a `Secret` is bytes: formatting it
135 // would mean a `String` copy of the password that nothing
136 // scrubs, which is the copy the type exists to avoid.
137 stdin
138 .write_all(answer.expose())
139 .and_then(|()| stdin.write_all(b"\n"))
140 .context("the helper closed mid-answer")?;
141 }
142 Some(Directive::Success) => return Ok(()),
143 Some(Directive::Failure) => bail!("not authorized"),
144 // PAM_ERROR_MSG and PAM_TEXT_INFO carry text for the user, and
145 // anything unrecognized is a helper newer than this code. Neither
146 // is a reason to abandon a conversation that is still going: the
147 // helper says SUCCESS or FAILURE either way, and that is what this
148 // waits for.
149 None => {}
150 }
151 }
152
153 bail!("the helper ended without saying whether it worked")
154 }
155
156 /// The helper, closed out however the conversation ends.
157 ///
158 /// A guard rather than a call at each exit, and the difference was a real leak
159 /// rather than a tidiness argument. [`converse`] has six places it can leave by
160 /// `?` — stdin, stdout, the cookie write, the read of each line, the write of
161 /// the answer, and the `ask` that hands the question to the screen — and
162 /// `std::process::Child` implements no `Drop`, so a helper let go of on any of
163 /// them is neither killed nor reaped.
164 ///
165 /// The `ask` one is not hypothetical. It is the path [`Agent::drop`]
166 /// deliberately creates: closing the prompt receiver turns a parked `send` into
167 /// an error so the conversation ends rather than holding the console open. That
168 /// case is "the console is quitting", and without this it leaves a setuid helper
169 /// inside `pam_authenticate` for a console that no longer exists.
170 ///
171 /// Killing rather than waiting politely, because every path here has already
172 /// decided the conversation is over and a helper mid-`pam_authenticate` can sit
173 /// for as long as its PAM stack wants. The wait is what stops it becoming a
174 /// zombie for the life of the console.
175 struct Helper(Child);
176
177 impl Drop for Helper {
178 fn drop(&mut self) {
179 let _ = self.0.kill();
180 let _ = self.0.wait();
181 }
182 }
183
184 /// One line of the helper's protocol.
185 #[derive(Debug, PartialEq, Eq)]
186 pub(super) enum Directive {
187 Prompt { question: String, echo: bool },
188 Success,
189 Failure,
190 }
191
192 impl Directive {
193 fn parse(line: &str) -> Option<Self> {
194 // Trailing whitespace is significant in the other direction: the prompt
195 // is usually `Password: ` and the trailing space is part of what a GUI
196 // agent would draw. It is trimmed here because the console draws its
197 // own label and the space would land in the middle of a rendered line.
198 let (verb, rest) = line.split_once(' ').unwrap_or((line, ""));
199 match verb {
200 "PAM_PROMPT_ECHO_OFF" => Some(Directive::Prompt {
201 question: rest.trim_end().to_string(),
202 echo: false,
203 }),
204 "PAM_PROMPT_ECHO_ON" => Some(Directive::Prompt {
205 question: rest.trim_end().to_string(),
206 echo: true,
207 }),
208 "SUCCESS" => Some(Directive::Success),
209 "FAILURE" => Some(Directive::Failure),
210 _ => None,
211 }
212 }
213 }
214
215 #[cfg(test)]
216 mod tests;
217