Skip to main content

agentmux_srv/backend/remote/
askpass.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The SSH askpass bridge (spec ยง5.3 of
5//! SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md).
6//!
7//! When an agent runs something over SSH there is no terminal for `ssh` to ask
8//! a password, a passphrase or a new host key in, and the agent must never see
9//! or answer one. So that `ssh` runs with `SSH_ASKPASS` pointing at
10//! `agentmux-bashwrap` and `SSH_ASKPASS_REQUIRE=force`: every prompt goes to
11//! bashwrap, which asks srv, which shows it to the user in a dialog, and the
12//! answer goes straight back to `ssh`.
13//!
14//! Who may ask is the security question: every agent holds srv's auth key, so
15//! a route open to that key alone would let an agent pop a fake "password"
16//! dialog and read what the user types. Each such `ssh` therefore gets its own
17//! secret, in its own environment and nowhere else; the askpass route answers
18//! only a live secret, and the secret decides which agent and host the
19//! dialog names. It is revoked when that `ssh` exits.
20//!
21//! What the secret does not do: prove the prompt *text* is ssh's. A process
22//! running as the same OS user can read another process's environment
23//! (`/proc/<pid>/environ` on Linux) and so post a prompt of its own under a
24//! live secret. That is the residual AgentMux accepts elsewhere (a same-user
25//! process reading other processes' memory is machine compromise; such a
26//! process could read `~/.ssh` directly), so the dialog says plainly that the
27//! prompt is relayed and unverified, and asks the user to answer only when
28//! they expect ssh to be asking.
29
30use std::collections::HashMap;
31use std::path::PathBuf;
32use std::sync::{Mutex, OnceLock};
33
34use sha2::{Digest, Sha256};
35
36/// Where bashwrap finds the secret; set only on the `ssh` it belongs to.
37pub const ENV_SECRET: &str = "AGENTMUX_ASKPASS_SECRET";
38
39/// Block meta on an agent's SSH PtyShell: the agent's pane and name. Its `ssh`
40/// then gets an askpass secret (`blockcontroller::shell::lifecycle`), so its
41/// prompts go to the user, never to the terminal the agent reads.
42pub const META_KEY_AGENT_BLOCK: &str = "ptyshell:askpassagentblock";
43pub const META_KEY_AGENT: &str = "ptyshell:askpassagent";
44
45/// What a secret was issued for.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AskpassGrant {
48    /// The agent's pane (or the user's, [`Self::user_pane`]), whose window
49    /// shows the dialog.
50    pub agent_block_id: String,
51    /// The agent, named in the dialog.
52    pub agent: String,
53    /// The connection `ssh` is opening, named in the dialog.
54    pub connection: String,
55    /// The user's own pane (a durable terminal, whose `ssh` has no terminal
56    /// to prompt in), not an agent's: the dialog says so.
57    pub user_pane: bool,
58}
59
60fn registry() -> &'static Mutex<HashMap<[u8; 32], AskpassGrant>> {
61    static REG: OnceLock<Mutex<HashMap<[u8; 32], AskpassGrant>>> = OnceLock::new();
62    REG.get_or_init(|| Mutex::new(HashMap::new()))
63}
64
65/// Stored by digest, so a timing difference in the lookup says nothing about
66/// how much of a guessed secret matches a real one.
67fn digest(secret: &str) -> [u8; 32] {
68    Sha256::digest(secret.as_bytes()).into()
69}
70
71/// Issue a secret for one `ssh` run.
72pub fn grant(grant: AskpassGrant) -> String {
73    let secret = format!(
74        "amxa_{}{}",
75        uuid::Uuid::new_v4().simple(),
76        uuid::Uuid::new_v4().simple()
77    );
78    registry()
79        .lock()
80        .unwrap_or_else(|e| e.into_inner())
81        .insert(digest(&secret), grant);
82    secret
83}
84
85/// What `secret` was issued for, while its `ssh` runs.
86pub fn lookup(secret: &str) -> Option<AskpassGrant> {
87    if secret.is_empty() {
88        return None;
89    }
90    registry()
91        .lock()
92        .unwrap_or_else(|e| e.into_inner())
93        .get(&digest(secret))
94        .cloned()
95}
96
97/// End a secret: its `ssh` exited.
98pub fn revoke(secret: &str) {
99    registry()
100        .lock()
101        .unwrap_or_else(|e| e.into_inner())
102        .remove(&digest(secret));
103}
104
105/// Revokes its secret when dropped: held by whatever waits for the `ssh`, so
106/// every way that `ssh` ends (exit, failed spawn, stop) ends the secret too.
107pub struct Revoke(pub String);
108
109impl Drop for Revoke {
110    fn drop(&mut self) {
111        revoke(&self.0);
112    }
113}
114
115/// How a prompt is answered.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum PromptKind {
118    /// A question answered yes or no: a new host key ("Are you sure you want
119    /// to continue connecting (yes/no/[fingerprint])?"), or confirming a key's
120    /// use. `ssh` takes the word typed.
121    YesNo,
122    /// A password, passphrase or one-time code: hidden, never logged.
123    Secret,
124    /// A notice ssh does not wait on (`SSH_ASKPASS_PROMPT=none`), such as
125    /// "Confirm user presence for key ...": touch the security key.
126    Info,
127}
128
129/// What kind of answer `prompt` wants. `hint` is ssh's own
130/// `SSH_ASKPASS_PROMPT` (OpenSSH 8.4+): `confirm` is yes or no, `none` a notice;
131/// without it, a `(yes/no` in the text is yes or no and anything else secret.
132pub fn classify(prompt: &str, hint: &str) -> PromptKind {
133    match hint.trim() {
134        "confirm" => return PromptKind::YesNo,
135        "none" => return PromptKind::Info,
136        _ => {}
137    }
138    let p = prompt.to_ascii_lowercase();
139    if p.contains("(yes/no") || p.contains("[yes/no") {
140        PromptKind::YesNo
141    } else {
142        PromptKind::Secret
143    }
144}
145
146/// The environment the `ssh` gets: askpass for every prompt, with its secret,
147/// and how bashwrap reaches srv.
148pub fn ssh_env(
149    secret: &str,
150    program: &std::path::Path,
151    local_url: &str,
152    auth_key: &str,
153) -> Vec<(String, String)> {
154    vec![
155        ("SSH_ASKPASS".into(), program.to_string_lossy().into_owned()),
156        // Askpass even where a terminal exists (OpenSSH 8.4+, every platform
157        // AgentMux supports), so nothing ever prompts where an agent reads.
158        ("SSH_ASKPASS_REQUIRE".into(), "force".into()),
159        // Older ssh on Linux uses askpass only with a display set.
160        (
161            "DISPLAY".into(),
162            std::env::var("DISPLAY").unwrap_or_else(|_| ":0".into()),
163        ),
164        (ENV_SECRET.into(), secret.into()),
165        ("AGENTMUX_LOCAL_URL".into(), local_url.into()),
166        ("AGENTMUX_AUTH_KEY".into(), auth_key.into()),
167    ]
168}
169
170/// The askpass program, `agentmux-bashwrap`: the bundled one, or (a dev build)
171/// the one beside srv, or the first on `PATH`.
172pub fn program() -> Option<PathBuf> {
173    let name = if cfg!(windows) {
174        "agentmux-bashwrap.exe"
175    } else {
176        "agentmux-bashwrap"
177    };
178    let beside_srv = std::env::current_exe()
179        .ok()
180        .and_then(|e| e.parent().map(|d| d.join(name)));
181    crate::backend::tool_store::bundled_tools_dir()
182        .map(|d| d.join(name))
183        .into_iter()
184        .chain(beside_srv)
185        .find(|p| p.is_file())
186        .or_else(|| which::which("agentmux-bashwrap").ok())
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn g(conn: &str) -> AskpassGrant {
194        AskpassGrant {
195            agent_block_id: "blk".into(),
196            agent: "korp".into(),
197            connection: conn.into(),
198            user_pane: false,
199        }
200    }
201
202    #[test]
203    fn a_secret_answers_while_its_ssh_runs_and_not_after() {
204        let secret = grant(g("area54"));
205        assert!(secret.starts_with("amxa_") && secret.len() > 60);
206        assert_eq!(lookup(&secret), Some(g("area54")));
207        let other = grant(g("nas"));
208        assert_ne!(secret, other, "one secret per run");
209        revoke(&secret);
210        assert_eq!(lookup(&secret), None);
211        assert_eq!(
212            lookup(&other),
213            Some(g("nas")),
214            "revoking one leaves the others"
215        );
216        assert_eq!(lookup(""), None);
217        assert_eq!(lookup("amxa_guess"), None);
218        drop(Revoke(other.clone()));
219        assert_eq!(
220            lookup(&other),
221            None,
222            "the guard revokes when its ssh's waiter ends"
223        );
224    }
225
226    #[test]
227    fn a_host_key_question_is_yes_or_no_and_everything_else_is_secret() {
228        assert_eq!(
229            classify(
230                "Are you sure you want to continue connecting (yes/no/[fingerprint])? ",
231                ""
232            ),
233            PromptKind::YesNo
234        );
235        assert_eq!(
236            classify("Allow use of key id_ed25519? [yes/no]", ""),
237            PromptKind::YesNo
238        );
239        assert_eq!(classify("asaf@area54's password: ", ""), PromptKind::Secret);
240        assert_eq!(
241            classify("Enter passphrase for key '/home/u/.ssh/id_ed25519': ", ""),
242            PromptKind::Secret
243        );
244        assert_eq!(classify("Verification code: ", ""), PromptKind::Secret);
245        // ssh's own word decides when it gives one.
246        assert_eq!(
247            classify("Allow use of key id_ed25519?", "confirm"),
248            PromptKind::YesNo
249        );
250        assert_eq!(
251            classify("Confirm user presence for key ECDSA-SK", "none"),
252            PromptKind::Info
253        );
254    }
255
256    #[test]
257    fn the_ssh_env_forces_askpass_and_carries_only_its_secret() {
258        let env = ssh_env(
259            "amxa_x",
260            std::path::Path::new("/opt/agentmux-bashwrap"),
261            "http://127.0.0.1:1",
262            "k",
263        );
264        let get = |k: &str| env.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str());
265        assert_eq!(get("SSH_ASKPASS"), Some("/opt/agentmux-bashwrap"));
266        assert_eq!(get("SSH_ASKPASS_REQUIRE"), Some("force"));
267        assert_eq!(get(ENV_SECRET), Some("amxa_x"));
268        assert!(get("DISPLAY").is_some());
269    }
270}