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