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