gitway_lib/agent/askpass.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! Interactive confirmation prompts for the SSH agent daemon.
4//!
5//! When a key was added with `--confirm` (SSH agent protocol's
6//! `SSH_AGENT_CONSTRAIN_CONFIRM`), the daemon must ask the user before
7//! each sign request. OpenSSH handles this by invoking the program
8//! named in `$SSH_ASKPASS` with `SSH_ASKPASS_PROMPT=confirm` in its
9//! environment; that program renders a yes/no dialog and signals the
10//! user's choice through its exit status — `0` means approved,
11//! anything else means denied.
12//!
13//! This module mirrors that contract. It is the server-side companion
14//! to `try_askpass` in `gitway-cli/src/main.rs`, which does the
15//! client-side passphrase flow. Same security invariants apply:
16//!
17//! * `SSH_ASKPASS` must be an absolute path — a relative value could
18//! be resolved via `PATH` to a binary the user did not intend to
19//! run.
20//! * The file must not be world-writable on Unix — any local user
21//! could otherwise overwrite it between the check and `execve(2)`
22//! to spy on sign prompts.
23//! * Askpass invocations run with a hard timeout so a wedged dialog
24//! cannot pin the `Session` lock indefinitely.
25//!
26//! The [`confirm`] entry point is fail-safe: any error (missing
27//! askpass, security violation, spawn failure, timeout) resolves to a
28//! denial, which the daemon then translates into `AgentError::Failure`
29//! back to the client.
30
31use std::ffi::OsString;
32use std::path::{Path, PathBuf};
33use std::time::Duration;
34
35use tokio::process::Command;
36use tokio::time::timeout;
37
38use crate::GitwayError;
39
40/// Hard cap for how long the daemon will wait on an askpass reply.
41///
42/// Long enough for a user to notice the dialog, walk to the keyboard,
43/// and click a button; short enough that a wedged askpass (frozen
44/// GUI, disconnected display) cannot hold the keystore lock forever.
45/// OpenSSH has no equivalent cap — `ssh_askpass` blocks until the
46/// child process exits — but our daemon cooperatively serves other
47/// clients in the meantime, so bounding the wait matters here.
48const ASKPASS_TIMEOUT: Duration = Duration::from_secs(60);
49
50/// Prompts the user to approve a sign request. Returns `true` when
51/// the askpass program exits `0`, `false` in every other case.
52///
53/// The outcome is logged at info level on denial and warn level on
54/// internal error, so operators running the daemon under systemd or
55/// a log aggregator can tell "user said no" apart from "askpass is
56/// misconfigured".
57///
58/// # Environment
59///
60/// Reads `SSH_ASKPASS` — if unset, returns `false` after logging a
61/// warning. Writes `SSH_ASKPASS_PROMPT=confirm` into the child's
62/// environment so the askpass program renders a yes/no dialog rather
63/// than a passphrase field.
64pub async fn confirm(prompt: &str) -> bool {
65 let Some(askpass_raw) = std::env::var_os("SSH_ASKPASS") else {
66 log::warn!(
67 "gitway-agent: sign request for confirm-required key rejected — \
68 SSH_ASKPASS is not set"
69 );
70 return false;
71 };
72 match confirm_with(&askpass_raw, prompt).await {
73 Ok(true) => true,
74 Ok(false) => {
75 log::info!("gitway-agent: user denied sign request via askpass");
76 false
77 }
78 Err(e) => {
79 log::warn!("gitway-agent: askpass confirm failed: {e}");
80 false
81 }
82 }
83}
84
85/// Spawns `askpass` with the given prompt and returns whether it
86/// exited `0`. Exposed as a separate function so tests can drive the
87/// confirmation path with a known-good script without having to mutate
88/// the process environment.
89///
90/// # Errors
91///
92/// Returns [`GitwayError`] when the path fails security validation
93/// (not absolute, world-writable), the spawn itself fails, or the
94/// child does not exit within [`ASKPASS_TIMEOUT`].
95pub async fn confirm_with(askpass: &OsString, prompt: &str) -> Result<bool, GitwayError> {
96 let path = PathBuf::from(askpass);
97 validate_security(&path)?;
98
99 let mut cmd = Command::new(&path);
100 cmd.arg(prompt)
101 .env("SSH_ASKPASS_PROMPT", "confirm")
102 .stdin(std::process::Stdio::null())
103 // Askpass implementations commonly print nothing on stdout for
104 // confirm-mode calls; we do not read it either way. Silence
105 // both streams so a chatty askpass cannot leak prompts into
106 // whatever log sink the daemon's stderr is pointed at.
107 .stdout(std::process::Stdio::null())
108 .stderr(std::process::Stdio::null());
109
110 let status = match timeout(ASKPASS_TIMEOUT, cmd.status()).await {
111 Ok(Ok(s)) => s,
112 Ok(Err(e)) => {
113 return Err(GitwayError::signing(format!(
114 "askpass spawn failed for {}: {e}",
115 path.display()
116 )));
117 }
118 Err(_elapsed) => {
119 return Err(GitwayError::signing(format!(
120 "askpass {} did not respond within {:?}",
121 path.display(),
122 ASKPASS_TIMEOUT
123 )));
124 }
125 };
126
127 Ok(status.success())
128}
129
130/// Rejects askpass paths that are unsafe to `execve` — relative paths
131/// (PATH injection) and (on Unix) world-writable files (local
132/// tampering). Both checks mirror the client-side `try_askpass` so
133/// operators only need to learn the rules once.
134///
135/// On Windows the world-writable check is dropped because the Unix
136/// `other` bit does not map cleanly onto NTFS ACLs; confirming the
137/// path is absolute + verifying metadata is readable is the portable
138/// subset of the Unix contract we can still enforce. Windows users
139/// wanting stricter checks should place their askpass binary in a
140/// directory their account has exclusive write access to.
141fn validate_security(askpass: &Path) -> Result<(), GitwayError> {
142 if !askpass.is_absolute() {
143 return Err(GitwayError::invalid_config(format!(
144 "SSH_ASKPASS {} must be an absolute path",
145 askpass.display()
146 )));
147 }
148 let meta = std::fs::metadata(askpass).map_err(|e| {
149 GitwayError::invalid_config(format!(
150 "SSH_ASKPASS {} cannot be stat()ed: {e}",
151 askpass.display()
152 ))
153 })?;
154 #[cfg(unix)]
155 {
156 use std::os::unix::fs::PermissionsExt as _;
157 // 0o002 is the write bit for "other". Any askpass readable
158 // to the user but writable by anyone on the system is an
159 // exploit waiting to happen.
160 if meta.permissions().mode() & 0o002 != 0 {
161 return Err(GitwayError::invalid_config(format!(
162 "SSH_ASKPASS {} is world-writable and cannot be trusted",
163 askpass.display()
164 )));
165 }
166 }
167 #[cfg(not(unix))]
168 {
169 // `metadata` already succeeded, so the path exists and is
170 // readable — that's the portable part of the check.
171 let _ = meta;
172 }
173 Ok(())
174}
175
176// Askpass is a cross-platform surface but the test fixtures here shell
177// out to a POSIX `/bin/sh` script and assert Unix mode bits. Gate the
178// whole submodule on `cfg(unix)` so Windows CI builds `gitway-lib` cleanly.
179#[cfg(all(test, unix))]
180mod tests {
181 use super::*;
182 use std::fs;
183 use std::os::unix::fs::PermissionsExt as _;
184 use tempfile::TempDir;
185
186 /// Builds an executable shell script under `dir` that simply
187 /// `exit`s with the given status, and returns its path.
188 fn fixture(dir: &TempDir, name: &str, exit_code: i32) -> OsString {
189 let path = dir.path().join(name);
190 fs::write(&path, format!("#!/bin/sh\nexit {exit_code}\n")).unwrap();
191 fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
192 path.into_os_string()
193 }
194
195 #[tokio::test]
196 async fn approves_when_askpass_exits_zero() {
197 let dir = TempDir::new().unwrap();
198 let yes = fixture(&dir, "yes", 0);
199 let approved = confirm_with(&yes, "allow?").await.unwrap();
200 assert!(approved);
201 }
202
203 #[tokio::test]
204 async fn denies_when_askpass_exits_nonzero() {
205 let dir = TempDir::new().unwrap();
206 let no = fixture(&dir, "no", 1);
207 let approved = confirm_with(&no, "allow?").await.unwrap();
208 assert!(!approved);
209 }
210
211 #[tokio::test]
212 async fn rejects_relative_path() {
213 let raw = OsString::from("relative-askpass.sh");
214 let err = confirm_with(&raw, "allow?").await.unwrap_err();
215 assert!(
216 err.to_string().contains("absolute"),
217 "unexpected error: {err}"
218 );
219 }
220
221 #[tokio::test]
222 async fn rejects_world_writable_askpass() {
223 let dir = TempDir::new().unwrap();
224 let yes = fixture(&dir, "leaky", 0);
225 fs::set_permissions(Path::new(&yes), fs::Permissions::from_mode(0o757)).unwrap();
226 let err = confirm_with(&yes, "allow?").await.unwrap_err();
227 assert!(
228 err.to_string().contains("world-writable"),
229 "unexpected error: {err}"
230 );
231 }
232
233 #[tokio::test]
234 async fn reports_missing_askpass() {
235 let raw = OsString::from("/definitely/does/not/exist/askpass.sh");
236 let err = confirm_with(&raw, "allow?").await.unwrap_err();
237 // Either `stat()ed` (our wrapper) or a downstream OS-level
238 // error message; both are acceptable.
239 let msg = err.to_string();
240 assert!(
241 msg.contains("stat()ed") || msg.contains("No such"),
242 "unexpected error: {msg}"
243 );
244 }
245}