Skip to main content

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}