Skip to main content

purple_ssh/
askpass.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::time::SystemTime;
4
5use anyhow::{Context, Result};
6use log::{debug, error, warn};
7
8use crate::ssh_config::model::SshConfigFile;
9
10/// A password source option for the picker overlay.
11pub struct PasswordSourceOption {
12    pub label: &'static str,
13    pub value: &'static str,
14    pub hint: &'static str,
15}
16
17pub const PASSWORD_SOURCES: &[PasswordSourceOption] = &[
18    PasswordSourceOption {
19        label: "OS Keychain",
20        value: "keychain",
21        hint: "keychain",
22    },
23    PasswordSourceOption {
24        label: "1Password",
25        value: "op://",
26        hint: "op://Vault/Item/field",
27    },
28    PasswordSourceOption {
29        label: "Bitwarden",
30        value: "bw:",
31        hint: "bw:item-name",
32    },
33    PasswordSourceOption {
34        label: "pass",
35        value: "pass:",
36        hint: "pass:path/to/entry",
37    },
38    // Vault KV secrets engine (key/value store). Distinct from the Vault SSH
39    // secrets engine used for signed SSH certificates, which has its own
40    // "Vault SSH role" field on the host form.
41    PasswordSourceOption {
42        label: "HashiCorp Vault KV",
43        value: "vault:",
44        hint: "vault:secret/path#field",
45    },
46    PasswordSourceOption {
47        label: "Proton Pass",
48        value: "proton:",
49        hint: "proton:Vault/Item/field",
50    },
51    PasswordSourceOption {
52        label: "Custom command",
53        value: "cmd:",
54        hint: "cmd %a %h",
55    },
56    PasswordSourceOption {
57        label: "None",
58        value: "",
59        hint: "(remove)",
60    },
61];
62
63/// Handle an SSH_ASKPASS invocation. Called when purple is invoked as an askpass program.
64/// Reads the password source from the host's `# purple:askpass` comment and retrieves it.
65pub fn handle(env: &crate::runtime::env::Env) -> Result<()> {
66    // Initialize file-only logging for askpass subprocess
67    // verbose is determined by PURPLE_LOG env var only (no CLI flag in subprocess)
68    crate::logging::init(false, false, env);
69
70    let alias = env.var("PURPLE_HOST_ALIAS").unwrap_or_default().to_string();
71    let config_path = env
72        .var("PURPLE_CONFIG_PATH")
73        .unwrap_or_default()
74        .to_string();
75
76    // Check the prompt (argv[1]) to skip passphrase and host key verification prompts
77    let prompt = std::env::args().nth(1).unwrap_or_default();
78
79    // A notice that a security key wants a touch. ssh expects no answer and
80    // stops this helper once the key responded.
81    if env.var("SSH_ASKPASS_PROMPT") == Some("none") {
82        if !crate::askpass_env::runs_in_background(env) {
83            crate::security_key::show_on_tty(&prompt);
84        }
85        debug!("[purple] Askpass passed on a notice for {alias}");
86        return Ok(());
87    }
88
89    // A security key PIN is never a password: no source and no typed
90    // password may answer it.
91    if let Some(pin_prompt) = crate::security_key::parse_pin_prompt(&prompt) {
92        return handle_pin_prompt(env, &alias, &prompt, pin_prompt);
93    }
94
95    let prompt_lower = prompt.to_ascii_lowercase();
96    if prompt_lower.contains("passphrase")
97        || prompt_lower.contains("yes/no")
98        || prompt_lower.contains("(yes/no/")
99    {
100        // Not a password prompt. Exit with error so SSH falls back to interactive.
101        std::process::exit(1);
102    }
103
104    if alias.is_empty() || config_path.is_empty() {
105        std::process::exit(1);
106    }
107
108    // Parse config first so we can resolve the prompt's host to the right entry.
109    // With ProxyJump, ssh fires askpass for each hop. The prompt argv[1] tells us
110    // which hop is being authenticated; PURPLE_HOST_ALIAS only knows the final
111    // target. Resolving the prompt host to its own alias scopes the keychain
112    // lookup to the correct entry per hop.
113    let config = SshConfigFile::parse_with_env(&PathBuf::from(&config_path), env)
114        .context("Failed to parse SSH config")?;
115
116    // Restrict prompt-based resolution to PURPLE_HOST_ALIAS and the hosts
117    // reachable via its ProxyJump chain. Without this scope, a malicious
118    // server could send a keyboard-interactive prompt formatted like a
119    // password prompt for an unrelated host (`attacker@victim's password:`)
120    // and exfiltrate that host's credential. Chain membership ensures we
121    // only ever supply credentials for hosts the user has wired into this
122    // connection.
123    let chain = build_proxy_chain(&config, &alias);
124    let prompt_host = parse_password_prompt_host(&prompt);
125    let prompt_hop = prompt_host.and_then(|h| find_alias_for_host(&config, h, &chain));
126    let resolved_alias = prompt_hop.clone().unwrap_or_else(|| alias.clone());
127
128    // The marker is keyed on the resolved alias so retries on one ProxyJump
129    // hop do not block askpass on the next hop.
130    let marker = marker_path(env.paths(), &resolved_alias);
131
132    // Whether this prompt may be answered at all. Decided once, then applied
133    // to the one-shot password and to a configured source alike, so both
134    // channels release a credential on the same evidence.
135    //
136    // A prompt already naming a hop inside the chain settles itself. For the
137    // rest, ssh answers the route question exactly, including for shapes the
138    // model does not parse: a `Match` block, a directive above the first
139    // `Host` line, a canonicalized name. Its reading is the one that counts,
140    // because it is ssh that opens the connection. Asking costs a subprocess
141    // that runs the user's own `Match exec` commands, so it stays behind the
142    // cheap answer.
143    let attributable = prompt_hop.is_some()
144        || match ssh_resolves_a_proxy(env, &config_path, &alias) {
145            Some(proxied) => !proxied,
146            // ssh could not be asked. All that is left is the model, which
147            // does not carry every shape ssh reads. A prompt naming a host
148            // the model cannot place is refused on that uncertainty rather
149            // than answered on a route that may be incomplete.
150            None => prompt_host.is_none() && !chain_uses_jump_host(&config, &chain),
151        };
152
153    // One-shot password typed in the TUI for this one run, scoped to the
154    // alias the parent named so a hop authenticating on the way to the
155    // target never receives the target's password.
156    //
157    // No marker is armed here. Every command that carries the one-shot pair
158    // also carries `-o NumberOfPasswordPrompts=1`, so ssh asks once per run
159    // and a rejected password costs one login attempt. Arming a marker would
160    // instead refuse the second ssh of a two-step operation, such as the
161    // remote home lookup followed by its listing.
162    if let (Some(oneshot_alias), Some(secret)) = (
163        env.var(crate::askpass_env::ONESHOT_ALIAS_VAR),
164        env.var(crate::askpass_env::ONESHOT_SECRET_VAR),
165    ) && oneshot_alias == resolved_alias
166        && attributable
167    {
168        debug!("[purple] Askpass one-shot password used for {resolved_alias}");
169        print!("{}", secret);
170        return Ok(());
171    }
172
173    // A prompt that cannot be placed inside this connection's own chain gets
174    // nothing, from either channel. Exiting before the retry marker leaves no
175    // trace of an attempt, because none was made.
176    //
177    // A marker of its own does go out, under the alias the parent named. The
178    // TUI reads it to tell this case apart from an ordinary refusal: a
179    // password typed here would meet the same wall, so it asks for none and
180    // explains instead.
181    if !attributable {
182        debug!(
183            "[purple] Askpass withheld for {resolved_alias}: prompt_host={:?} placed_in_chain=false chain_size={}",
184            prompt_host,
185            chain.len()
186        );
187        if let Some(path) = withheld_marker_path(env.paths(), &alias) {
188            arm_marker(&path);
189        }
190        std::process::exit(1);
191    }
192
193    // Retry detection: if we've been called recently for this resolved alias,
194    // the password was wrong. Exit with error so SSH falls back to interactive.
195    //
196    // A background run skips this. ssh there carries
197    // `-o NumberOfPasswordPrompts=1`, so it asks once per hop and the loop
198    // the marker exists to break cannot form. Keeping it would instead
199    // refuse the second ssh of a two-step operation, such as the remote home
200    // lookup followed by its listing.
201    let single_attempt = env
202        .var(crate::askpass_env::SINGLE_ATTEMPT_VAR)
203        .is_some_and(|v| v == "1");
204    if let Some(marker_path) = &marker
205        && !single_attempt
206    {
207        if is_recent_marker(marker_path) {
208            debug!("[purple] Askpass retry detected for {resolved_alias}");
209            let _ = std::fs::remove_file(marker_path);
210            std::process::exit(1);
211        }
212        arm_marker(marker_path);
213    }
214
215    let source = find_askpass_source(&config, env.paths(), &resolved_alias);
216
217    let source = match source {
218        Some(s) => s,
219        None => std::process::exit(1),
220    };
221
222    debug!("[purple] Askpass invoked for alias={resolved_alias} source={source}");
223
224    let hostname = find_hostname(&config, &resolved_alias);
225    match retrieve_password(env, &source, &resolved_alias, &hostname) {
226        Ok(password) => {
227            debug!("[purple] Askpass retrieved password for {resolved_alias} via {source}");
228            print!("{}", password);
229            Ok(())
230        }
231        Err(err) => {
232            warn!("[external] Password retrieval failed via {source}");
233            debug!("[external] Password retrieval detail: {err}");
234            if let Some(m) = &marker {
235                let _ = std::fs::remove_file(m);
236            }
237            std::process::exit(1);
238        }
239    }
240}
241
242/// Answer ssh's security key PIN prompt. A background run hands out the PIN
243/// the TUI put in its environment, at most once per ssh process and never
244/// after the key refused it. Without one it tells the TUI and answers
245/// nothing, which costs the key no attempt. A terminal run asks on the tty.
246fn handle_pin_prompt(
247    env: &crate::runtime::env::Env,
248    alias: &str,
249    prompt: &str,
250    pin_prompt: crate::security_key::PinPrompt<'_>,
251) -> Result<()> {
252    use crate::security_key as sk;
253    let key_path = pin_prompt.key_path;
254    if !crate::askpass_env::runs_in_background(env) {
255        match sk::read_pin_from_tty(prompt) {
256            Ok(Some(pin)) => {
257                debug!("[purple] Askpass read the security key PIN on the terminal for {key_path}");
258                print!("{}", pin);
259                return Ok(());
260            }
261            Ok(None) => debug!("[purple] Askpass security key PIN canceled for {key_path}"),
262            Err(e) => debug!("[purple] Askpass could not ask for the security key PIN: {e}"),
263        }
264        std::process::exit(1);
265    }
266    let paths = env.paths();
267    let ssh_pid = parent_pid();
268    if let Some(pin) = sk::pin_from_env(env, key_path) {
269        if let Some(reason) = sk::refusal_for(paths, key_path) {
270            debug!("[purple] Askpass withheld the PIN for {key_path}: refused before ({reason:?})");
271        } else if sk::handed_out(paths, alias, ssh_pid, key_path) {
272            // The same ssh asking twice means its first try failed. A second
273            // hand-out would cost the key one more attempt.
274            debug!("[purple] Askpass withheld the PIN for {key_path}: this ssh already had it");
275            std::process::exit(1);
276        } else if sk::claim_hand_out(paths, alias, ssh_pid, key_path) {
277            print!("{}", pin);
278            return Ok(());
279        } else {
280            debug!("[purple] Askpass withheld the PIN for {key_path}: refused while it waited");
281        }
282    }
283    if let Some(flow) = sk::flow_from_env(env)
284        && !alias.is_empty()
285    {
286        sk::arm_pin_request(paths, flow, alias, key_path);
287    }
288    std::process::exit(1);
289}
290
291/// The ssh process that started this helper.
292fn parent_pid() -> u32 {
293    #[cfg(unix)]
294    {
295        std::os::unix::process::parent_id()
296    }
297    #[cfg(not(unix))]
298    {
299        0
300    }
301}
302
303/// Extract the host being authenticated from an OpenSSH password prompt.
304/// OpenSSH builds prompts as `<user>@<host>'s password:` (see `userauth_passwd`
305/// in openssh-portable). IPv6 hosts are rendered with brackets (`user@[::1]`),
306/// which we strip so the result matches a plain `HostName` entry. Returns
307/// `None` for keyboard-interactive prompts or any other format we cannot parse,
308/// so the caller falls back to PURPLE_HOST_ALIAS.
309fn parse_password_prompt_host(prompt: &str) -> Option<&str> {
310    let idx = prompt.find("'s password")?;
311    let head = &prompt[..idx];
312    let (_, host) = head.rsplit_once('@')?;
313    let host = host.trim();
314    let host = host
315        .strip_prefix('[')
316        .and_then(|s| s.strip_suffix(']'))
317        .unwrap_or(host);
318    if host.is_empty() { None } else { Some(host) }
319}
320
321/// Find the alias whose entry matches `host` by alias or hostname, restricted
322/// to entries in `permitted`. Alias match takes priority over hostname match
323/// in a single pass. Used to map the SSH prompt's host (which may be a bastion
324/// in a ProxyJump chain) back to the entry that owns its `# purple:askpass`
325/// config. The `permitted` scope blocks malicious-server attempts to coax a
326/// credential lookup for an unrelated host in `~/.ssh/config`.
327fn find_alias_for_host(
328    config: &SshConfigFile,
329    host: &str,
330    permitted: &HashSet<String>,
331) -> Option<String> {
332    let mut by_hostname: Option<String> = None;
333    for entry in config.host_entries() {
334        if !permitted.contains(&entry.alias) {
335            continue;
336        }
337        if entry.alias.eq_ignore_ascii_case(host) {
338            return Some(entry.alias.clone());
339        }
340        if by_hostname.is_none() && entry.hostname.eq_ignore_ascii_case(host) {
341            by_hostname = Some(entry.alias.clone());
342        }
343    }
344    by_hostname
345}
346
347/// True when any host in `chain` routes through a jump host, written either
348/// as `ProxyJump` or as a `ProxyCommand`. Neither form is guaranteed to put
349/// the bastion in the chain: a `ProxyJump` naming a host with no `Host` block
350/// of its own contributes no alias, and a `ProxyCommand` names its bastion
351/// inside a command line purple does not parse. So chain size alone cannot
352/// tell a direct connection from one behind a bastion. This can.
353fn chain_uses_jump_host(config: &SshConfigFile, chain: &HashSet<String>) -> bool {
354    config.host_entries().iter().any(|e| {
355        chain.contains(&e.alias) && (!e.proxy_jump.trim().is_empty() || e.has_proxy_command)
356    })
357}
358
359/// How long `ssh -G` gets before its answer is given up on. Reading a config
360/// is instant, but `CanonicalizeHostname` makes it resolve names and a
361/// `Match exec` block runs a command of the user's own, either of which can
362/// stall. ssh is waiting on this askpass while that happens, so the wait is
363/// bounded and the model answers instead.
364const PROXY_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
365
366/// Ask ssh whether it resolves a route through another machine for `alias`.
367/// `ssh -G` prints the configuration it will actually use, so this covers
368/// every shape the model does not carry: a `Match` block, a directive above
369/// the first `Host` line, a canonicalized name. It omits both keywords
370/// entirely when nothing proxies, and `ProxyCommand none` prints no line at
371/// all. `None` when ssh could not be run, took too long or refused the
372/// alias, which leaves the answer to the caller.
373fn ssh_resolves_a_proxy(
374    env: &crate::runtime::env::Env,
375    config_path: &str,
376    alias: &str,
377) -> Option<bool> {
378    let mut child = env
379        .command("ssh")
380        .arg("-G")
381        .arg("-F")
382        .arg(config_path)
383        .arg("--")
384        .arg(alias)
385        .stdin(std::process::Stdio::null())
386        .stdout(std::process::Stdio::piped())
387        .stderr(std::process::Stdio::null())
388        .spawn()
389        .ok()?;
390    // Read on a thread rather than after the wait, so a chatty config cannot
391    // fill the pipe and park both sides. EOF on the pipe means ssh is done.
392    let Some(mut stdout) = child.stdout.take() else {
393        let _ = child.kill();
394        let _ = child.wait();
395        return None;
396    };
397    let (tx, rx) = std::sync::mpsc::channel();
398    std::thread::spawn(move || {
399        let mut text = String::new();
400        let read = std::io::Read::read_to_string(&mut stdout, &mut text);
401        let _ = tx.send(read.ok().map(|_| text));
402    });
403    let Ok(Some(text)) = rx.recv_timeout(PROXY_PROBE_TIMEOUT) else {
404        debug!("[purple] Askpass proxy probe gave no answer for {alias}");
405        let _ = child.kill();
406        let _ = child.wait();
407        return None;
408    };
409    if !child.wait().ok()?.success() {
410        return None;
411    }
412    Some(
413        text.lines()
414            .any(|l| l.starts_with("proxycommand ") || l.starts_with("proxyjump ")),
415    )
416}
417
418/// Build the set of aliases reachable from `target` via its ProxyJump chain,
419/// including `target` itself. ProxyJump values can be comma-separated and
420/// formatted `[user@]host[:port]`, including bracketed IPv6 hosts. Cycles are
421/// broken by the visited-set; entries that reference unknown hosts contribute
422/// nothing to the chain.
423fn build_proxy_chain(config: &SshConfigFile, target: &str) -> HashSet<String> {
424    let entries = config.host_entries();
425    let mut chain: HashSet<String> = HashSet::new();
426    let mut queue: Vec<String> = vec![target.to_string()];
427    while let Some(current) = queue.pop() {
428        if !chain.insert(current.clone()) {
429            continue;
430        }
431        let Some(entry) = entries.iter().find(|e| e.alias == current) else {
432            continue;
433        };
434        if entry.proxy_jump.is_empty() {
435            continue;
436        }
437        for jump in entry.proxy_jump.split(',') {
438            let host = parse_proxy_jump_host(jump);
439            if host.is_empty() {
440                continue;
441            }
442            for e in &entries {
443                if e.alias.eq_ignore_ascii_case(host) || e.hostname.eq_ignore_ascii_case(host) {
444                    queue.push(e.alias.clone());
445                }
446            }
447        }
448    }
449    chain
450}
451
452/// Extract the host portion from a single ProxyJump entry of the form
453/// `[user@]host[:port]`. Handles bracketed IPv6 hosts (`[::1]:22`).
454fn parse_proxy_jump_host(jump: &str) -> &str {
455    let trimmed = jump.trim();
456    let after_user = trimmed.rsplit_once('@').map(|(_, h)| h).unwrap_or(trimmed);
457    if let Some(rest) = after_user.strip_prefix('[')
458        && let Some(end) = rest.find(']')
459    {
460        return &rest[..end];
461    }
462    after_user.split(':').next().unwrap_or(after_user)
463}
464
465/// Find the askpass source for a host. Checks per-host config, then global default.
466fn find_askpass_source(
467    config: &SshConfigFile,
468    paths: Option<&crate::runtime::env::Paths>,
469    alias: &str,
470) -> Option<String> {
471    // Per-host source
472    for entry in config.host_entries() {
473        if entry.alias == alias
474            && let Some(ref source) = entry.askpass
475        {
476            return Some(source.clone());
477        }
478    }
479    // Global default from preferences file
480    load_askpass_default_direct(paths)
481}
482
483/// Read askpass default directly from ~/.purple/preferences without depending on the
484/// preferences module (which requires crate::app and isn't available in askpass subprocess).
485fn load_askpass_default_direct(paths: Option<&crate::runtime::env::Paths>) -> Option<String> {
486    let path = paths?.preferences();
487    let content = std::fs::read_to_string(path).ok()?;
488    for line in content.lines() {
489        let line = line.trim();
490        if line.starts_with('#') || line.is_empty() {
491            continue;
492        }
493        if let Some((k, v)) = line.split_once('=')
494            && k.trim() == "askpass"
495        {
496            let val = v.trim();
497            if !val.is_empty() {
498                return Some(val.to_string());
499            }
500        }
501    }
502    None
503}
504
505/// Find the hostname for an alias (for %h substitution).
506fn find_hostname(config: &SshConfigFile, alias: &str) -> String {
507    for entry in config.host_entries() {
508        if entry.alias == alias {
509            return entry.hostname.clone();
510        }
511    }
512    alias.to_string()
513}
514
515/// Retrieve a password from the given source.
516fn retrieve_password(
517    env: &crate::runtime::env::Env,
518    source: &str,
519    alias: &str,
520    hostname: &str,
521) -> Result<String> {
522    if source == "keychain" {
523        return retrieve_from_keychain(env, alias);
524    }
525    if let Some(uri) = source.strip_prefix("op://") {
526        return retrieve_from_1password(env, &format!("op://{}", uri));
527    }
528    if let Some(entry) = source.strip_prefix("pass:") {
529        return retrieve_from_pass(env, entry);
530    }
531    if let Some(item_id) = source.strip_prefix("bw:") {
532        return retrieve_from_bitwarden(env, item_id);
533    }
534    if let Some(rest) = source.strip_prefix("vault:") {
535        return retrieve_from_vault(env, rest);
536    }
537    if let Some(spec) = source.strip_prefix("proton:") {
538        return retrieve_from_proton_pass(env, spec);
539    }
540    // Custom command (with or without cmd: prefix)
541    let cmd = source.strip_prefix("cmd:").unwrap_or(source);
542    retrieve_from_command(env, cmd, alias, hostname)
543}
544
545/// Retrieve from OS keychain (macOS: Keychain, Linux: secret-tool).
546fn retrieve_from_keychain(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
547    #[cfg(target_os = "macos")]
548    {
549        let output = env
550            .command("security")
551            .args([
552                "find-generic-password",
553                "-a",
554                alias,
555                "-s",
556                "purple-ssh",
557                "-w",
558            ])
559            .output()
560            .context("Failed to run security command")?;
561        if !output.status.success() {
562            let stderr = String::from_utf8_lossy(&output.stderr);
563            log::warn!(
564                "[external] askpass keychain lookup failed: alias={} exit={} stderr={}",
565                alias,
566                output.status.code().unwrap_or(-1),
567                stderr.trim().lines().next().unwrap_or("<empty>"),
568            );
569            anyhow::bail!("Keychain lookup failed");
570        }
571        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
572    }
573    #[cfg(not(target_os = "macos"))]
574    {
575        let output = env
576            .command("secret-tool")
577            .args(["lookup", "application", "purple-ssh", "host", alias])
578            .output()
579            .context("Failed to run secret-tool")?;
580        if !output.status.success() {
581            let stderr = String::from_utf8_lossy(&output.stderr);
582            log::warn!(
583                "[external] askpass secret-tool lookup failed: alias={} exit={} stderr={}",
584                alias,
585                output.status.code().unwrap_or(-1),
586                stderr.trim().lines().next().unwrap_or("<empty>"),
587            );
588            anyhow::bail!("Secret-tool lookup failed");
589        }
590        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
591    }
592}
593
594/// Check if a password exists in the OS keychain for this alias.
595pub fn keychain_has_password(env: &crate::runtime::env::Env, alias: &str) -> bool {
596    retrieve_from_keychain(env, alias).is_ok()
597}
598
599/// Retrieve a password from the OS keychain. Public for keychain migration on alias rename.
600pub fn retrieve_keychain_password(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
601    retrieve_from_keychain(env, alias)
602}
603
604/// Store a password in the OS keychain.
605pub fn store_in_keychain(
606    env: &crate::runtime::env::Env,
607    alias: &str,
608    password: &str,
609) -> Result<()> {
610    #[cfg(target_os = "macos")]
611    {
612        let status = env
613            .command("security")
614            .args([
615                "add-generic-password",
616                "-U",
617                "-a",
618                alias,
619                "-s",
620                "purple-ssh",
621                "-w",
622                password,
623            ])
624            .status()
625            .context("Failed to run security command")?;
626        if !status.success() {
627            anyhow::bail!("Failed to store password in Keychain");
628        }
629        Ok(())
630    }
631    #[cfg(not(target_os = "macos"))]
632    {
633        let mut child = env
634            .command("secret-tool")
635            .args([
636                "store",
637                "--label",
638                &format!("purple-ssh: {}", alias),
639                "application",
640                "purple-ssh",
641                "host",
642                alias,
643            ])
644            .stdin(std::process::Stdio::piped())
645            .spawn()
646            .context("Failed to run secret-tool")?;
647        if let Some(ref mut stdin) = child.stdin {
648            use std::io::Write;
649            stdin.write_all(password.as_bytes())?;
650        }
651        let status = child.wait()?;
652        if !status.success() {
653            anyhow::bail!("Failed to store password with secret-tool");
654        }
655        Ok(())
656    }
657}
658
659/// Remove a password from the OS keychain.
660pub fn remove_from_keychain(env: &crate::runtime::env::Env, alias: &str) -> Result<()> {
661    #[cfg(target_os = "macos")]
662    {
663        let status = env
664            .command("security")
665            .args(["delete-generic-password", "-a", alias, "-s", "purple-ssh"])
666            .status()
667            .context("Failed to run security command")?;
668        if !status.success() {
669            anyhow::bail!("No password found for '{}' in Keychain", alias);
670        }
671        Ok(())
672    }
673    #[cfg(not(target_os = "macos"))]
674    {
675        let status = env
676            .command("secret-tool")
677            .args(["clear", "application", "purple-ssh", "host", alias])
678            .status()
679            .context("Failed to run secret-tool")?;
680        if !status.success() {
681            anyhow::bail!("Failed to remove password with secret-tool");
682        }
683        Ok(())
684    }
685}
686
687/// Retrieve from 1Password CLI.
688fn retrieve_from_1password(env: &crate::runtime::env::Env, uri: &str) -> Result<String> {
689    let result = env
690        .command("op")
691        .args(["read", uri, "--no-newline"])
692        .output();
693    let output = match result {
694        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
695            error!("[config] Password manager binary not found: op");
696            return Err(e).context("Failed to run 1Password CLI (op)");
697        }
698        other => other.context("Failed to run 1Password CLI (op)")?,
699    };
700    if !output.status.success() {
701        let stderr = String::from_utf8_lossy(&output.stderr);
702        log::warn!(
703            "[external] askpass 1Password lookup failed: uri={} exit={} stderr={}",
704            uri,
705            output.status.code().unwrap_or(-1),
706            stderr.trim().lines().next().unwrap_or("<empty>"),
707        );
708        anyhow::bail!("1Password lookup failed");
709    }
710    Ok(String::from_utf8_lossy(&output.stdout).to_string())
711}
712
713/// Retrieve from pass (password-store). Returns the first line.
714fn retrieve_from_pass(env: &crate::runtime::env::Env, entry: &str) -> Result<String> {
715    let result = env.command("pass").args(["show", entry]).output();
716    let output = match result {
717        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
718            error!("[config] Password manager binary not found: pass");
719            return Err(e).context("Failed to run pass");
720        }
721        other => other.context("Failed to run pass")?,
722    };
723    if !output.status.success() {
724        let stderr = String::from_utf8_lossy(&output.stderr);
725        log::warn!(
726            "[external] askpass pass lookup failed: entry={} exit={} stderr={}",
727            entry,
728            output.status.code().unwrap_or(-1),
729            stderr.trim().lines().next().unwrap_or("<empty>"),
730        );
731        anyhow::bail!("pass lookup failed");
732    }
733    let full = String::from_utf8_lossy(&output.stdout);
734    Ok(full.lines().next().unwrap_or("").to_string())
735}
736
737/// Retrieve from Bitwarden CLI. The item_id can be an item ID or search term.
738/// Uses `bw get password <item_id>` which requires an unlocked vault (BW_SESSION).
739fn retrieve_from_bitwarden(env: &crate::runtime::env::Env, item_id: &str) -> Result<String> {
740    let result = env
741        .command("bw")
742        .args(["get", "password", item_id])
743        .output();
744    let output = match result {
745        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
746            error!("[config] Password manager binary not found: bw");
747            return Err(e).context("Failed to run Bitwarden CLI (bw)");
748        }
749        other => other.context("Failed to run Bitwarden CLI (bw)")?,
750    };
751    if !output.status.success() {
752        let stderr = String::from_utf8_lossy(&output.stderr);
753        log::warn!(
754            "[external] askpass Bitwarden lookup failed: item={} exit={} stderr={}",
755            item_id,
756            output.status.code().unwrap_or(-1),
757            stderr.trim().lines().next().unwrap_or("<empty>"),
758        );
759        anyhow::bail!("Bitwarden lookup failed");
760    }
761    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
762}
763
764/// Retrieve from the HashiCorp Vault KV secrets engine via the `vault` CLI.
765/// Spec format: `path#field` or just `path` (defaults to `password`).
766/// Distinct from the Vault SSH secrets engine (see src/vault_ssh.rs), which
767/// signs SSH certificates rather than storing passwords.
768fn retrieve_from_vault(env: &crate::runtime::env::Env, spec: &str) -> Result<String> {
769    let (path, field) = match spec.rsplit_once('#') {
770        Some((p, f)) => (p, f),
771        None => (spec, "password"),
772    };
773    let result = env
774        .command("vault")
775        .args(["kv", "get", &format!("-field={}", field), path])
776        .output();
777    let output = match result {
778        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
779            error!("[config] Password manager binary not found: vault");
780            return Err(e).context("Failed to run vault CLI");
781        }
782        other => other.context("Failed to run vault CLI")?,
783    };
784    if !output.status.success() {
785        let stderr = String::from_utf8_lossy(&output.stderr);
786        log::warn!(
787            "[external] askpass Vault KV lookup failed: path={} field={} exit={} stderr={}",
788            path,
789            field,
790            output.status.code().unwrap_or(-1),
791            stderr.trim().lines().next().unwrap_or("<empty>"),
792        );
793        anyhow::bail!("Vault lookup failed");
794    }
795    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
796}
797
798/// Retrieve via custom command. Supports %h (hostname) and %a (alias) substitution.
799/// Values are shell-escaped to prevent metacharacter injection.
800fn retrieve_from_command(
801    env: &crate::runtime::env::Env,
802    cmd: &str,
803    alias: &str,
804    hostname: &str,
805) -> Result<String> {
806    let safe_alias = crate::snippet::shell_escape(alias);
807    let safe_hostname = crate::snippet::shell_escape(hostname);
808    let expanded = cmd.replace("%a", &safe_alias).replace("%h", &safe_hostname);
809    let output = env
810        .command("sh")
811        .args(["-c", &expanded])
812        .output()
813        .context("Failed to run custom askpass command")?;
814    if !output.status.success() {
815        let stderr = String::from_utf8_lossy(&output.stderr);
816        log::warn!(
817            "[external] askpass custom command failed: alias={} exit={} stderr={}",
818            alias,
819            output.status.code().unwrap_or(-1),
820            stderr.trim().lines().next().unwrap_or("<empty>"),
821        );
822        anyhow::bail!("Custom askpass command failed");
823    }
824    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
825}
826
827/// Get the path for the retry marker file.
828/// Sanitizes the alias to prevent path traversal (replaces `/` and `\` with `_`).
829fn marker_path(paths: Option<&crate::runtime::env::Paths>, alias: &str) -> Option<PathBuf> {
830    paths.map(|p| p.askpass_marker(alias))
831}
832
833/// Filename prefix of the marker that records a prompt a background run
834/// could not place. The dot after `withheld` keeps it clear of a retry
835/// marker, whose own sanitizer turns every dot into an underscore.
836pub(crate) const WITHHELD_MARKER_PREFIX: &str = ".askpass_withheld.";
837
838/// Path of the marker that records a prompt this connection could not place.
839/// Keyed on the alias the TUI acted on, because that is the name it has to
840/// look up afterwards. Cleared by `cleanup_marker` along with the rest.
841fn withheld_marker_path(
842    paths: Option<&crate::runtime::env::Paths>,
843    alias: &str,
844) -> Option<PathBuf> {
845    paths.map(|p| p.askpass_withheld_marker(alias))
846}
847
848/// True when the last background run for `alias` met a prompt it could not
849/// place inside the connection's own chain. A password typed in the TUI
850/// would meet the same wall, so the caller asks for none.
851///
852/// Reading takes the marker with it. One withheld run then accounts for one
853/// dialog and never for a later, unrelated one, which matters because the
854/// runs that arm a marker do not all have a place to clear it.
855pub fn take_withheld(paths: Option<&crate::runtime::env::Paths>, alias: &str) -> bool {
856    let Some(path) = withheld_marker_path(paths, alias) else {
857        return false;
858    };
859    let recent = is_recent_marker(&path);
860    let _ = std::fs::remove_file(&path);
861    recent
862}
863
864/// Write the retry marker. Its presence within the next minute means this
865/// alias was already answered once, so the caller refuses a second answer.
866fn arm_marker(path: &Path) {
867    if let Err(e) = std::fs::create_dir_all(path.parent().unwrap()) {
868        debug!("[purple] Failed to create askpass marker directory: {e}");
869    }
870    if let Err(e) = crate::fs_util::atomic_write(path, b"") {
871        debug!("[purple] Failed to write askpass marker: {e}");
872    }
873}
874
875/// Check if a marker file exists and is recent (< 60 seconds old).
876fn is_recent_marker(path: &PathBuf) -> bool {
877    if let Ok(meta) = std::fs::metadata(path)
878        && let Ok(modified) = meta.modified()
879        && let Ok(elapsed) = SystemTime::now().duration_since(modified)
880    {
881        return elapsed.as_secs() < 60;
882    }
883    false
884}
885
886/// Clean up retry markers after a successful connection. ProxyJump connections
887/// create one marker per hop and the parent process only knows the final
888/// target alias, so we clear every `.askpass_*` marker in the state directory
889/// on success.
890/// Each marker has a 60s expiry; this just keeps rapid reconnects snappy and
891/// prevents a stranded bastion marker from blocking the next attempt.
892pub fn cleanup_marker(paths: Option<&crate::runtime::env::Paths>, _alias: &str) {
893    let Some(paths) = paths else {
894        return;
895    };
896    let Ok(read) = std::fs::read_dir(paths.state_dir()) else {
897        return;
898    };
899    for entry in read.flatten() {
900        // Retry markers only. A withheld or PIN marker belongs to the run it
901        // was armed for and is taken by whoever reads it, so a sweep running
902        // for another host has no business removing it.
903        if entry.file_name().to_str().is_some_and(|s| {
904            s.starts_with(".askpass_")
905                && !s.starts_with(WITHHELD_MARKER_PREFIX)
906                && !s.starts_with(crate::security_key::PIN_MARKER_PREFIX)
907        }) {
908            let _ = std::fs::remove_file(entry.path());
909        }
910    }
911}
912
913/// Parse an askpass source string and return a description for display.
914#[allow(dead_code)]
915pub fn describe_source(source: &str) -> &str {
916    if source == "keychain" {
917        "OS Keychain"
918    } else if source.starts_with("op://") {
919        "1Password"
920    } else if source.starts_with("proton:") {
921        "Proton Pass"
922    } else if source.starts_with("pass:") {
923        "pass"
924    } else if source.starts_with("bw:") {
925        "Bitwarden"
926    } else if source.starts_with("vault:") {
927        "HashiCorp Vault KV"
928    } else {
929        "Custom command"
930    }
931}
932
933/// Bitwarden vault status.
934#[derive(Debug, Clone, Copy, PartialEq)]
935pub enum BwStatus {
936    Unlocked,
937    Locked,
938    NotAuthenticated,
939    NotInstalled,
940}
941
942/// Parse the Bitwarden vault status from `bw status` JSON output.
943fn parse_bw_status(stdout: &str) -> BwStatus {
944    if let Some(status) = stdout
945        .split("\"status\":")
946        .nth(1)
947        .and_then(|s| s.split('"').nth(1))
948    {
949        match status {
950            "unlocked" => BwStatus::Unlocked,
951            "locked" => BwStatus::Locked,
952            "unauthenticated" => BwStatus::NotAuthenticated,
953            _ => BwStatus::Locked,
954        }
955    } else {
956        BwStatus::NotInstalled
957    }
958}
959
960/// Check the Bitwarden vault status by running `bw status`.
961pub fn bw_vault_status(env: &crate::runtime::env::Env) -> BwStatus {
962    let output = match env.command("bw").arg("status").output() {
963        Ok(o) => o,
964        Err(_) => return BwStatus::NotInstalled,
965    };
966    let stdout = String::from_utf8_lossy(&output.stdout);
967    parse_bw_status(&stdout)
968}
969
970/// Unlock the Bitwarden vault with the given master password.
971/// Passes the password via env var to avoid exposure in `ps` output.
972/// Returns the session token on success.
973pub fn bw_unlock(env: &crate::runtime::env::Env, password: &str) -> Result<String> {
974    let output = env
975        .command("bw")
976        .args(["unlock", "--passwordenv", "PURPLE_BW_MASTER", "--raw"])
977        .env("PURPLE_BW_MASTER", password)
978        .output()
979        .context("Failed to run Bitwarden CLI (bw)")?;
980    if !output.status.success() {
981        let stderr = String::from_utf8_lossy(&output.stderr);
982        anyhow::bail!("Bitwarden unlock failed: {}", stderr.trim());
983    }
984    let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
985    if token.is_empty() {
986        anyhow::bail!("Bitwarden unlock returned empty session token");
987    }
988    Ok(token)
989}
990
991/// Proton Pass CLI authentication status.
992#[derive(Debug, Clone, Copy, PartialEq)]
993pub enum ProtonStatus {
994    Authenticated,
995    NotAuthenticated,
996    NotInstalled,
997}
998
999/// Check whether `pass-cli` is installed and the user is logged in. Uses
1000/// `pass-cli test` (not `info`) because in pass-cli 2.x `info` exits 0 even
1001/// without a session and only reports the error on stderr. `test` is the
1002/// command that actually exits non-zero when authentication is missing.
1003pub fn proton_status(env: &crate::runtime::env::Env) -> ProtonStatus {
1004    let result = env.command("pass-cli").arg("test").output();
1005    let status = match result {
1006        Err(e) if e.kind() == std::io::ErrorKind::NotFound => ProtonStatus::NotInstalled,
1007        Err(_) => ProtonStatus::NotAuthenticated,
1008        Ok(out) if out.status.success() => ProtonStatus::Authenticated,
1009        Ok(_) => ProtonStatus::NotAuthenticated,
1010    };
1011    debug!("[external] Proton Pass status: {status:?}");
1012    status
1013}
1014
1015/// Log in to Proton Pass with a Personal Access Token.
1016/// PAT is supplied via the `PROTON_PASS_PERSONAL_ACCESS_TOKEN` env var so it
1017/// never appears in argv. Returns an error wrapping pass-cli's stderr on
1018/// non-zero exit so the prompt loop can surface it.
1019pub fn proton_login(env: &crate::runtime::env::Env, pat: &str) -> Result<()> {
1020    if pat.is_empty() {
1021        anyhow::bail!("empty PAT");
1022    }
1023    let output = env
1024        .command("pass-cli")
1025        .arg("login")
1026        .env("PROTON_PASS_PERSONAL_ACCESS_TOKEN", pat)
1027        .output()
1028        .context("Failed to run Proton Pass CLI (pass-cli)")?;
1029    if !output.status.success() {
1030        let stderr = String::from_utf8_lossy(&output.stderr);
1031        debug!("[external] Proton Pass login failed: {}", stderr.trim());
1032        anyhow::bail!("{}", stderr.trim());
1033    }
1034    debug!("[external] Proton Pass login succeeded");
1035    Ok(())
1036}
1037
1038/// Parse a `proton:Vault/Item/field` askpass spec into its three components.
1039/// Vault and item segments cannot contain `/`; the field segment is everything
1040/// after the second `/`. All three segments must be non-empty.
1041fn parse_proton_spec(spec: &str) -> Result<(&str, &str, &str)> {
1042    let (vault, rest) = spec
1043        .split_once('/')
1044        .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
1045    let (item, field) = rest
1046        .split_once('/')
1047        .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
1048    if vault.is_empty() || item.is_empty() || field.is_empty() {
1049        anyhow::bail!("Proton Pass spec segments must be non-empty");
1050    }
1051    Ok((vault, item, field))
1052}
1053
1054/// Retrieve a secret from Proton Pass via `pass-cli item view`. The askpass
1055/// spec `proton:Vault/Item/field` is mapped to name-based lookup flags
1056/// (`--vault-name`, `--item-title`, `--field`) rather than the URI form, so
1057/// purple users can refer to their vaults and items by human-readable names
1058/// instead of opaque share/item IDs.
1059fn retrieve_from_proton_pass(env: &crate::runtime::env::Env, spec: &str) -> Result<String> {
1060    let (vault, item, field) = parse_proton_spec(spec)?;
1061    let result = env
1062        .command("pass-cli")
1063        .args([
1064            "item",
1065            "view",
1066            "--vault-name",
1067            vault,
1068            "--item-title",
1069            item,
1070            "--field",
1071            field,
1072        ])
1073        .output();
1074    let output = match result {
1075        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1076            error!("[config] Password manager binary not found: pass-cli");
1077            return Err(e).context("Failed to run Proton Pass CLI (pass-cli)");
1078        }
1079        other => other.context("Failed to run Proton Pass CLI (pass-cli)")?,
1080    };
1081    if !output.status.success() {
1082        let stderr = String::from_utf8_lossy(&output.stderr);
1083        warn!("[external] Proton Pass lookup failed: {}", stderr.trim());
1084        anyhow::bail!("Proton Pass lookup failed");
1085    }
1086    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
1087    if value.is_empty() {
1088        warn!("[external] Proton Pass returned empty secret");
1089        anyhow::bail!("Proton Pass returned empty secret");
1090    }
1091    debug!("[external] Proton Pass lookup succeeded");
1092    Ok(value)
1093}
1094
1095#[cfg(test)]
1096#[path = "askpass_tests.rs"]
1097mod tests;