agentmux_srv/backend/remote/
askpass.rs1use std::collections::HashMap;
31use std::path::PathBuf;
32use std::sync::{Mutex, OnceLock};
33
34use sha2::{Digest, Sha256};
35
36pub const ENV_SECRET: &str = "AGENTMUX_ASKPASS_SECRET";
38
39pub const META_KEY_AGENT_BLOCK: &str = "ptyshell:askpassagentblock";
43pub const META_KEY_AGENT: &str = "ptyshell:askpassagent";
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AskpassGrant {
48 pub agent_block_id: String,
51 pub agent: String,
53 pub connection: String,
55 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
65fn digest(secret: &str) -> [u8; 32] {
68 Sha256::digest(secret.as_bytes()).into()
69}
70
71pub 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
85pub 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
97pub fn revoke(secret: &str) {
99 registry()
100 .lock()
101 .unwrap_or_else(|e| e.into_inner())
102 .remove(&digest(secret));
103}
104
105pub struct Revoke(pub String);
108
109impl Drop for Revoke {
110 fn drop(&mut self) {
111 revoke(&self.0);
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum PromptKind {
118 YesNo,
122 Secret,
124 Info,
127}
128
129pub 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
146pub 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 ("SSH_ASKPASS_REQUIRE".into(), "force".into()),
159 (
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
170pub 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 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}