Skip to main content

purple_ssh/
askpass.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::process::Command;
4use std::time::SystemTime;
5
6use anyhow::{Context, Result};
7use log::{debug, error, warn};
8
9use crate::ssh_config::model::SshConfigFile;
10
11/// A password source option for the picker overlay.
12pub struct PasswordSourceOption {
13    pub label: &'static str,
14    pub value: &'static str,
15    pub hint: &'static str,
16}
17
18pub const PASSWORD_SOURCES: &[PasswordSourceOption] = &[
19    PasswordSourceOption {
20        label: "OS Keychain",
21        value: "keychain",
22        hint: "keychain",
23    },
24    PasswordSourceOption {
25        label: "1Password",
26        value: "op://",
27        hint: "op://Vault/Item/field",
28    },
29    PasswordSourceOption {
30        label: "Bitwarden",
31        value: "bw:",
32        hint: "bw:item-name",
33    },
34    PasswordSourceOption {
35        label: "pass",
36        value: "pass:",
37        hint: "pass:path/to/entry",
38    },
39    // Vault KV secrets engine (key/value store). Distinct from the Vault SSH
40    // secrets engine used for signed SSH certificates, which has its own
41    // "Vault SSH role" field on the host form.
42    PasswordSourceOption {
43        label: "HashiCorp Vault KV",
44        value: "vault:",
45        hint: "vault:secret/path#field",
46    },
47    PasswordSourceOption {
48        label: "Proton Pass",
49        value: "proton:",
50        hint: "proton:Vault/Item/field",
51    },
52    PasswordSourceOption {
53        label: "Custom command",
54        value: "cmd:",
55        hint: "cmd %a %h",
56    },
57    PasswordSourceOption {
58        label: "None",
59        value: "",
60        hint: "(remove)",
61    },
62];
63
64/// Handle an SSH_ASKPASS invocation. Called when purple is invoked as an askpass program.
65/// Reads the password source from the host's `# purple:askpass` comment and retrieves it.
66pub fn handle() -> Result<()> {
67    // Initialize file-only logging for askpass subprocess
68    // verbose is determined by PURPLE_LOG env var only (no CLI flag in subprocess)
69    crate::logging::init(false, false);
70
71    let alias = std::env::var("PURPLE_HOST_ALIAS").unwrap_or_default();
72    let config_path = std::env::var("PURPLE_CONFIG_PATH").unwrap_or_default();
73
74    // Check the prompt (argv[1]) to skip passphrase and host key verification prompts
75    let prompt = std::env::args().nth(1).unwrap_or_default();
76    let prompt_lower = prompt.to_ascii_lowercase();
77    if prompt_lower.contains("passphrase")
78        || prompt_lower.contains("yes/no")
79        || prompt_lower.contains("(yes/no/")
80    {
81        // Not a password prompt. Exit with error so SSH falls back to interactive.
82        std::process::exit(1);
83    }
84
85    if alias.is_empty() || config_path.is_empty() {
86        std::process::exit(1);
87    }
88
89    // Parse config first so we can resolve the prompt's host to the right entry.
90    // With ProxyJump, ssh fires askpass for each hop. The prompt argv[1] tells us
91    // which hop is being authenticated; PURPLE_HOST_ALIAS only knows the final
92    // target. Resolving the prompt host to its own alias scopes the keychain
93    // lookup to the correct entry per hop.
94    let config =
95        SshConfigFile::parse(&PathBuf::from(&config_path)).context("Failed to parse SSH config")?;
96
97    // Restrict prompt-based resolution to PURPLE_HOST_ALIAS and the hosts
98    // reachable via its ProxyJump chain. Without this scope, a malicious
99    // server could send a keyboard-interactive prompt formatted like a
100    // password prompt for an unrelated host (`attacker@victim's password:`)
101    // and exfiltrate that host's credential. Chain membership ensures we
102    // only ever supply credentials for hosts the user has wired into this
103    // connection.
104    let chain = build_proxy_chain(&config, &alias);
105    let resolved_alias = parse_password_prompt_host(&prompt)
106        .and_then(|h| find_alias_for_host(&config, h, &chain))
107        .unwrap_or_else(|| alias.clone());
108
109    // Retry detection: if we've been called recently for this resolved alias,
110    // the password was wrong. Exit with error so SSH falls back to interactive.
111    // The marker is keyed on the resolved alias so retries on one ProxyJump hop
112    // do not block askpass on the next hop.
113    let marker = marker_path(&resolved_alias);
114    if let Some(marker_path) = &marker {
115        if is_recent_marker(marker_path) {
116            debug!("Askpass retry detected for {resolved_alias}");
117            let _ = std::fs::remove_file(marker_path);
118            std::process::exit(1);
119        }
120        if let Err(e) = std::fs::create_dir_all(marker_path.parent().unwrap()) {
121            debug!("[config] Failed to create askpass marker directory: {e}");
122        }
123        if let Err(e) = crate::fs_util::atomic_write(marker_path, b"") {
124            debug!("[config] Failed to write askpass marker: {e}");
125        }
126    }
127
128    let source = find_askpass_source(&config, &resolved_alias);
129
130    let source = match source {
131        Some(s) => s,
132        None => std::process::exit(1),
133    };
134
135    debug!("Askpass invoked for alias={resolved_alias} source={source}");
136
137    let hostname = find_hostname(&config, &resolved_alias);
138    match retrieve_password(&source, &resolved_alias, &hostname) {
139        Ok(password) => {
140            debug!("Askpass retrieved password for {resolved_alias} via {source}");
141            print!("{}", password);
142            Ok(())
143        }
144        Err(err) => {
145            warn!("[external] Password retrieval failed via {source}");
146            debug!("[external] Password retrieval detail: {err}");
147            if let Some(m) = &marker {
148                let _ = std::fs::remove_file(m);
149            }
150            std::process::exit(1);
151        }
152    }
153}
154
155/// Extract the host being authenticated from an OpenSSH password prompt.
156/// OpenSSH builds prompts as `<user>@<host>'s password:` (see `userauth_passwd`
157/// in openssh-portable). IPv6 hosts are rendered with brackets (`user@[::1]`),
158/// which we strip so the result matches a plain `HostName` entry. Returns
159/// `None` for keyboard-interactive prompts or any other format we cannot parse,
160/// so the caller falls back to PURPLE_HOST_ALIAS.
161fn parse_password_prompt_host(prompt: &str) -> Option<&str> {
162    let idx = prompt.find("'s password")?;
163    let head = &prompt[..idx];
164    let (_, host) = head.rsplit_once('@')?;
165    let host = host.trim();
166    let host = host
167        .strip_prefix('[')
168        .and_then(|s| s.strip_suffix(']'))
169        .unwrap_or(host);
170    if host.is_empty() { None } else { Some(host) }
171}
172
173/// Find the alias whose entry matches `host` by alias or hostname, restricted
174/// to entries in `permitted`. Alias match takes priority over hostname match
175/// in a single pass. Used to map the SSH prompt's host (which may be a bastion
176/// in a ProxyJump chain) back to the entry that owns its `# purple:askpass`
177/// config. The `permitted` scope blocks malicious-server attempts to coax a
178/// credential lookup for an unrelated host in `~/.ssh/config`.
179fn find_alias_for_host(
180    config: &SshConfigFile,
181    host: &str,
182    permitted: &HashSet<String>,
183) -> Option<String> {
184    let mut by_hostname: Option<String> = None;
185    for entry in config.host_entries() {
186        if !permitted.contains(&entry.alias) {
187            continue;
188        }
189        if entry.alias.eq_ignore_ascii_case(host) {
190            return Some(entry.alias.clone());
191        }
192        if by_hostname.is_none() && entry.hostname.eq_ignore_ascii_case(host) {
193            by_hostname = Some(entry.alias.clone());
194        }
195    }
196    by_hostname
197}
198
199/// Build the set of aliases reachable from `target` via its ProxyJump chain,
200/// including `target` itself. ProxyJump values can be comma-separated and
201/// formatted `[user@]host[:port]`, including bracketed IPv6 hosts. Cycles are
202/// broken by the visited-set; entries that reference unknown hosts contribute
203/// nothing to the chain.
204fn build_proxy_chain(config: &SshConfigFile, target: &str) -> HashSet<String> {
205    let entries = config.host_entries();
206    let mut chain: HashSet<String> = HashSet::new();
207    let mut queue: Vec<String> = vec![target.to_string()];
208    while let Some(current) = queue.pop() {
209        if !chain.insert(current.clone()) {
210            continue;
211        }
212        let Some(entry) = entries.iter().find(|e| e.alias == current) else {
213            continue;
214        };
215        if entry.proxy_jump.is_empty() {
216            continue;
217        }
218        for jump in entry.proxy_jump.split(',') {
219            let host = parse_proxy_jump_host(jump);
220            if host.is_empty() {
221                continue;
222            }
223            for e in &entries {
224                if e.alias.eq_ignore_ascii_case(host) || e.hostname.eq_ignore_ascii_case(host) {
225                    queue.push(e.alias.clone());
226                }
227            }
228        }
229    }
230    chain
231}
232
233/// Extract the host portion from a single ProxyJump entry of the form
234/// `[user@]host[:port]`. Handles bracketed IPv6 hosts (`[::1]:22`).
235fn parse_proxy_jump_host(jump: &str) -> &str {
236    let trimmed = jump.trim();
237    let after_user = trimmed.rsplit_once('@').map(|(_, h)| h).unwrap_or(trimmed);
238    if let Some(rest) = after_user.strip_prefix('[') {
239        if let Some(end) = rest.find(']') {
240            return &rest[..end];
241        }
242    }
243    after_user.split(':').next().unwrap_or(after_user)
244}
245
246/// Find the askpass source for a host. Checks per-host config, then global default.
247fn find_askpass_source(config: &SshConfigFile, alias: &str) -> Option<String> {
248    // Per-host source
249    for entry in config.host_entries() {
250        if entry.alias == alias {
251            if let Some(ref source) = entry.askpass {
252                return Some(source.clone());
253            }
254        }
255    }
256    // Global default from preferences file
257    load_askpass_default_direct()
258}
259
260/// Read askpass default directly from ~/.purple/preferences without depending on the
261/// preferences module (which requires crate::app and isn't available in askpass subprocess).
262fn load_askpass_default_direct() -> Option<String> {
263    let path = dirs::home_dir()?.join(".purple/preferences");
264    let content = std::fs::read_to_string(path).ok()?;
265    for line in content.lines() {
266        let line = line.trim();
267        if line.starts_with('#') || line.is_empty() {
268            continue;
269        }
270        if let Some((k, v)) = line.split_once('=') {
271            if k.trim() == "askpass" {
272                let val = v.trim();
273                if !val.is_empty() {
274                    return Some(val.to_string());
275                }
276            }
277        }
278    }
279    None
280}
281
282/// Find the hostname for an alias (for %h substitution).
283fn find_hostname(config: &SshConfigFile, alias: &str) -> String {
284    for entry in config.host_entries() {
285        if entry.alias == alias {
286            return entry.hostname.clone();
287        }
288    }
289    alias.to_string()
290}
291
292/// Retrieve a password from the given source.
293fn retrieve_password(source: &str, alias: &str, hostname: &str) -> Result<String> {
294    if source == "keychain" {
295        return retrieve_from_keychain(alias);
296    }
297    if let Some(uri) = source.strip_prefix("op://") {
298        return retrieve_from_1password(&format!("op://{}", uri));
299    }
300    if let Some(entry) = source.strip_prefix("pass:") {
301        return retrieve_from_pass(entry);
302    }
303    if let Some(item_id) = source.strip_prefix("bw:") {
304        return retrieve_from_bitwarden(item_id);
305    }
306    if let Some(rest) = source.strip_prefix("vault:") {
307        return retrieve_from_vault(rest);
308    }
309    if let Some(spec) = source.strip_prefix("proton:") {
310        return retrieve_from_proton_pass(spec);
311    }
312    // Custom command (with or without cmd: prefix)
313    let cmd = source.strip_prefix("cmd:").unwrap_or(source);
314    retrieve_from_command(cmd, alias, hostname)
315}
316
317/// Retrieve from OS keychain (macOS: Keychain, Linux: secret-tool).
318fn retrieve_from_keychain(alias: &str) -> Result<String> {
319    #[cfg(target_os = "macos")]
320    {
321        let output = Command::new("security")
322            .args([
323                "find-generic-password",
324                "-a",
325                alias,
326                "-s",
327                "purple-ssh",
328                "-w",
329            ])
330            .output()
331            .context("Failed to run security command")?;
332        if !output.status.success() {
333            let stderr = String::from_utf8_lossy(&output.stderr);
334            log::warn!(
335                "[external] askpass keychain lookup failed: alias={} exit={} stderr={}",
336                alias,
337                output.status.code().unwrap_or(-1),
338                stderr.trim().lines().next().unwrap_or("<empty>"),
339            );
340            anyhow::bail!("Keychain lookup failed");
341        }
342        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
343    }
344    #[cfg(not(target_os = "macos"))]
345    {
346        let output = Command::new("secret-tool")
347            .args(["lookup", "application", "purple-ssh", "host", alias])
348            .output()
349            .context("Failed to run secret-tool")?;
350        if !output.status.success() {
351            let stderr = String::from_utf8_lossy(&output.stderr);
352            log::warn!(
353                "[external] askpass secret-tool lookup failed: alias={} exit={} stderr={}",
354                alias,
355                output.status.code().unwrap_or(-1),
356                stderr.trim().lines().next().unwrap_or("<empty>"),
357            );
358            anyhow::bail!("Secret-tool lookup failed");
359        }
360        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
361    }
362}
363
364/// Check if a password exists in the OS keychain for this alias.
365pub fn keychain_has_password(alias: &str) -> bool {
366    retrieve_from_keychain(alias).is_ok()
367}
368
369/// Retrieve a password from the OS keychain. Public for keychain migration on alias rename.
370pub fn retrieve_keychain_password(alias: &str) -> Result<String> {
371    retrieve_from_keychain(alias)
372}
373
374/// Store a password in the OS keychain.
375pub fn store_in_keychain(alias: &str, password: &str) -> Result<()> {
376    #[cfg(target_os = "macos")]
377    {
378        let status = Command::new("security")
379            .args([
380                "add-generic-password",
381                "-U",
382                "-a",
383                alias,
384                "-s",
385                "purple-ssh",
386                "-w",
387                password,
388            ])
389            .status()
390            .context("Failed to run security command")?;
391        if !status.success() {
392            anyhow::bail!("Failed to store password in Keychain");
393        }
394        Ok(())
395    }
396    #[cfg(not(target_os = "macos"))]
397    {
398        let mut child = Command::new("secret-tool")
399            .args([
400                "store",
401                "--label",
402                &format!("purple-ssh: {}", alias),
403                "application",
404                "purple-ssh",
405                "host",
406                alias,
407            ])
408            .stdin(std::process::Stdio::piped())
409            .spawn()
410            .context("Failed to run secret-tool")?;
411        if let Some(ref mut stdin) = child.stdin {
412            use std::io::Write;
413            stdin.write_all(password.as_bytes())?;
414        }
415        let status = child.wait()?;
416        if !status.success() {
417            anyhow::bail!("Failed to store password with secret-tool");
418        }
419        Ok(())
420    }
421}
422
423/// Remove a password from the OS keychain.
424pub fn remove_from_keychain(alias: &str) -> Result<()> {
425    #[cfg(target_os = "macos")]
426    {
427        let status = Command::new("security")
428            .args(["delete-generic-password", "-a", alias, "-s", "purple-ssh"])
429            .status()
430            .context("Failed to run security command")?;
431        if !status.success() {
432            anyhow::bail!("No password found for '{}' in Keychain", alias);
433        }
434        Ok(())
435    }
436    #[cfg(not(target_os = "macos"))]
437    {
438        let status = Command::new("secret-tool")
439            .args(["clear", "application", "purple-ssh", "host", alias])
440            .status()
441            .context("Failed to run secret-tool")?;
442        if !status.success() {
443            anyhow::bail!("Failed to remove password with secret-tool");
444        }
445        Ok(())
446    }
447}
448
449/// Retrieve from 1Password CLI.
450fn retrieve_from_1password(uri: &str) -> Result<String> {
451    let result = Command::new("op")
452        .args(["read", uri, "--no-newline"])
453        .output();
454    let output = match result {
455        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
456            error!("[config] Password manager binary not found: op");
457            return Err(e).context("Failed to run 1Password CLI (op)");
458        }
459        other => other.context("Failed to run 1Password CLI (op)")?,
460    };
461    if !output.status.success() {
462        let stderr = String::from_utf8_lossy(&output.stderr);
463        log::warn!(
464            "[external] askpass 1Password lookup failed: uri={} exit={} stderr={}",
465            uri,
466            output.status.code().unwrap_or(-1),
467            stderr.trim().lines().next().unwrap_or("<empty>"),
468        );
469        anyhow::bail!("1Password lookup failed");
470    }
471    Ok(String::from_utf8_lossy(&output.stdout).to_string())
472}
473
474/// Retrieve from pass (password-store). Returns the first line.
475fn retrieve_from_pass(entry: &str) -> Result<String> {
476    let result = Command::new("pass").args(["show", entry]).output();
477    let output = match result {
478        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
479            error!("[config] Password manager binary not found: pass");
480            return Err(e).context("Failed to run pass");
481        }
482        other => other.context("Failed to run pass")?,
483    };
484    if !output.status.success() {
485        let stderr = String::from_utf8_lossy(&output.stderr);
486        log::warn!(
487            "[external] askpass pass lookup failed: entry={} exit={} stderr={}",
488            entry,
489            output.status.code().unwrap_or(-1),
490            stderr.trim().lines().next().unwrap_or("<empty>"),
491        );
492        anyhow::bail!("pass lookup failed");
493    }
494    let full = String::from_utf8_lossy(&output.stdout);
495    Ok(full.lines().next().unwrap_or("").to_string())
496}
497
498/// Retrieve from Bitwarden CLI. The item_id can be an item ID or search term.
499/// Uses `bw get password <item_id>` which requires an unlocked vault (BW_SESSION).
500fn retrieve_from_bitwarden(item_id: &str) -> Result<String> {
501    let result = Command::new("bw")
502        .args(["get", "password", item_id])
503        .output();
504    let output = match result {
505        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
506            error!("[config] Password manager binary not found: bw");
507            return Err(e).context("Failed to run Bitwarden CLI (bw)");
508        }
509        other => other.context("Failed to run Bitwarden CLI (bw)")?,
510    };
511    if !output.status.success() {
512        let stderr = String::from_utf8_lossy(&output.stderr);
513        log::warn!(
514            "[external] askpass Bitwarden lookup failed: item={} exit={} stderr={}",
515            item_id,
516            output.status.code().unwrap_or(-1),
517            stderr.trim().lines().next().unwrap_or("<empty>"),
518        );
519        anyhow::bail!("Bitwarden lookup failed");
520    }
521    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
522}
523
524/// Retrieve from the HashiCorp Vault KV secrets engine via the `vault` CLI.
525/// Spec format: `path#field` or just `path` (defaults to `password`).
526/// Distinct from the Vault SSH secrets engine (see src/vault_ssh.rs), which
527/// signs SSH certificates rather than storing passwords.
528fn retrieve_from_vault(spec: &str) -> Result<String> {
529    let (path, field) = match spec.rsplit_once('#') {
530        Some((p, f)) => (p, f),
531        None => (spec, "password"),
532    };
533    let result = Command::new("vault")
534        .args(["kv", "get", &format!("-field={}", field), path])
535        .output();
536    let output = match result {
537        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
538            error!("[config] Password manager binary not found: vault");
539            return Err(e).context("Failed to run vault CLI");
540        }
541        other => other.context("Failed to run vault CLI")?,
542    };
543    if !output.status.success() {
544        let stderr = String::from_utf8_lossy(&output.stderr);
545        log::warn!(
546            "[external] askpass Vault KV lookup failed: path={} field={} exit={} stderr={}",
547            path,
548            field,
549            output.status.code().unwrap_or(-1),
550            stderr.trim().lines().next().unwrap_or("<empty>"),
551        );
552        anyhow::bail!("Vault lookup failed");
553    }
554    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
555}
556
557/// Retrieve via custom command. Supports %h (hostname) and %a (alias) substitution.
558/// Values are shell-escaped to prevent metacharacter injection.
559fn retrieve_from_command(cmd: &str, alias: &str, hostname: &str) -> Result<String> {
560    let safe_alias = crate::snippet::shell_escape(alias);
561    let safe_hostname = crate::snippet::shell_escape(hostname);
562    let expanded = cmd.replace("%a", &safe_alias).replace("%h", &safe_hostname);
563    let output = Command::new("sh")
564        .args(["-c", &expanded])
565        .output()
566        .context("Failed to run custom askpass command")?;
567    if !output.status.success() {
568        let stderr = String::from_utf8_lossy(&output.stderr);
569        log::warn!(
570            "[external] askpass custom command failed: alias={} exit={} stderr={}",
571            alias,
572            output.status.code().unwrap_or(-1),
573            stderr.trim().lines().next().unwrap_or("<empty>"),
574        );
575        anyhow::bail!("Custom askpass command failed");
576    }
577    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
578}
579
580/// Get the path for the retry marker file.
581/// Sanitizes the alias to prevent path traversal (replaces `/` and `\` with `_`).
582fn marker_path(alias: &str) -> Option<PathBuf> {
583    let safe = alias.replace(['/', '\\', '.'], "_");
584    dirs::home_dir().map(|h| h.join(format!(".purple/.askpass_{}", safe)))
585}
586
587/// Check if a marker file exists and is recent (< 60 seconds old).
588fn is_recent_marker(path: &PathBuf) -> bool {
589    if let Ok(meta) = std::fs::metadata(path) {
590        if let Ok(modified) = meta.modified() {
591            if let Ok(elapsed) = SystemTime::now().duration_since(modified) {
592                return elapsed.as_secs() < 60;
593            }
594        }
595    }
596    false
597}
598
599/// Clean up retry markers after a successful connection. ProxyJump connections
600/// create one marker per hop and the parent process only knows the final
601/// target alias, so we clear every `~/.purple/.askpass_*` file on success.
602/// Each marker has a 60s expiry; this just keeps rapid reconnects snappy and
603/// prevents a stranded bastion marker from blocking the next attempt.
604pub fn cleanup_marker(_alias: &str) {
605    let Some(home) = dirs::home_dir() else {
606        return;
607    };
608    let Ok(read) = std::fs::read_dir(home.join(".purple")) else {
609        return;
610    };
611    for entry in read.flatten() {
612        if entry
613            .file_name()
614            .to_str()
615            .is_some_and(|s| s.starts_with(".askpass_"))
616        {
617            let _ = std::fs::remove_file(entry.path());
618        }
619    }
620}
621
622/// Parse an askpass source string and return a description for display.
623#[allow(dead_code)]
624pub fn describe_source(source: &str) -> &str {
625    if source == "keychain" {
626        "OS Keychain"
627    } else if source.starts_with("op://") {
628        "1Password"
629    } else if source.starts_with("proton:") {
630        "Proton Pass"
631    } else if source.starts_with("pass:") {
632        "pass"
633    } else if source.starts_with("bw:") {
634        "Bitwarden"
635    } else if source.starts_with("vault:") {
636        "HashiCorp Vault KV"
637    } else {
638        "Custom command"
639    }
640}
641
642/// Bitwarden vault status.
643#[derive(Debug, Clone, Copy, PartialEq)]
644pub enum BwStatus {
645    Unlocked,
646    Locked,
647    NotAuthenticated,
648    NotInstalled,
649}
650
651/// Parse the Bitwarden vault status from `bw status` JSON output.
652fn parse_bw_status(stdout: &str) -> BwStatus {
653    if let Some(status) = stdout
654        .split("\"status\":")
655        .nth(1)
656        .and_then(|s| s.split('"').nth(1))
657    {
658        match status {
659            "unlocked" => BwStatus::Unlocked,
660            "locked" => BwStatus::Locked,
661            "unauthenticated" => BwStatus::NotAuthenticated,
662            _ => BwStatus::Locked,
663        }
664    } else {
665        BwStatus::NotInstalled
666    }
667}
668
669/// Check the Bitwarden vault status by running `bw status`.
670pub fn bw_vault_status() -> BwStatus {
671    let output = match Command::new("bw").arg("status").output() {
672        Ok(o) => o,
673        Err(_) => return BwStatus::NotInstalled,
674    };
675    let stdout = String::from_utf8_lossy(&output.stdout);
676    parse_bw_status(&stdout)
677}
678
679/// Unlock the Bitwarden vault with the given master password.
680/// Passes the password via env var to avoid exposure in `ps` output.
681/// Returns the session token on success.
682pub fn bw_unlock(password: &str) -> Result<String> {
683    let output = Command::new("bw")
684        .args(["unlock", "--passwordenv", "PURPLE_BW_MASTER", "--raw"])
685        .env("PURPLE_BW_MASTER", password)
686        .output()
687        .context("Failed to run Bitwarden CLI (bw)")?;
688    if !output.status.success() {
689        let stderr = String::from_utf8_lossy(&output.stderr);
690        anyhow::bail!("Bitwarden unlock failed: {}", stderr.trim());
691    }
692    let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
693    if token.is_empty() {
694        anyhow::bail!("Bitwarden unlock returned empty session token");
695    }
696    Ok(token)
697}
698
699/// Proton Pass CLI authentication status.
700#[derive(Debug, Clone, Copy, PartialEq)]
701pub enum ProtonStatus {
702    Authenticated,
703    NotAuthenticated,
704    NotInstalled,
705}
706
707/// Check whether `pass-cli` is installed and the user is logged in. Uses
708/// `pass-cli test` (not `info`) because in pass-cli 2.x `info` exits 0 even
709/// without a session and only reports the error on stderr. `test` is the
710/// command that actually exits non-zero when authentication is missing.
711pub fn proton_status() -> ProtonStatus {
712    let result = Command::new("pass-cli").arg("test").output();
713    let status = match result {
714        Err(e) if e.kind() == std::io::ErrorKind::NotFound => ProtonStatus::NotInstalled,
715        Err(_) => ProtonStatus::NotAuthenticated,
716        Ok(out) if out.status.success() => ProtonStatus::Authenticated,
717        Ok(_) => ProtonStatus::NotAuthenticated,
718    };
719    debug!("Proton Pass status: {status:?}");
720    status
721}
722
723/// Log in to Proton Pass with a Personal Access Token.
724/// PAT is supplied via the `PROTON_PASS_PERSONAL_ACCESS_TOKEN` env var so it
725/// never appears in argv. Returns an error wrapping pass-cli's stderr on
726/// non-zero exit so the prompt loop can surface it.
727pub fn proton_login(pat: &str) -> Result<()> {
728    if pat.is_empty() {
729        anyhow::bail!("empty PAT");
730    }
731    let output = Command::new("pass-cli")
732        .arg("login")
733        .env("PROTON_PASS_PERSONAL_ACCESS_TOKEN", pat)
734        .output()
735        .context("Failed to run Proton Pass CLI (pass-cli)")?;
736    if !output.status.success() {
737        let stderr = String::from_utf8_lossy(&output.stderr);
738        debug!("Proton Pass login failed: {}", stderr.trim());
739        anyhow::bail!("{}", stderr.trim());
740    }
741    debug!("Proton Pass login succeeded");
742    Ok(())
743}
744
745/// Parse a `proton:Vault/Item/field` askpass spec into its three components.
746/// Vault and item segments cannot contain `/`; the field segment is everything
747/// after the second `/`. All three segments must be non-empty.
748fn parse_proton_spec(spec: &str) -> Result<(&str, &str, &str)> {
749    let (vault, rest) = spec
750        .split_once('/')
751        .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
752    let (item, field) = rest
753        .split_once('/')
754        .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
755    if vault.is_empty() || item.is_empty() || field.is_empty() {
756        anyhow::bail!("Proton Pass spec segments must be non-empty");
757    }
758    Ok((vault, item, field))
759}
760
761/// Retrieve a secret from Proton Pass via `pass-cli item view`. The askpass
762/// spec `proton:Vault/Item/field` is mapped to name-based lookup flags
763/// (`--vault-name`, `--item-title`, `--field`) rather than the URI form, so
764/// purple users can refer to their vaults and items by human-readable names
765/// instead of opaque share/item IDs.
766fn retrieve_from_proton_pass(spec: &str) -> Result<String> {
767    let (vault, item, field) = parse_proton_spec(spec)?;
768    let result = Command::new("pass-cli")
769        .args([
770            "item",
771            "view",
772            "--vault-name",
773            vault,
774            "--item-title",
775            item,
776            "--field",
777            field,
778        ])
779        .output();
780    let output = match result {
781        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
782            error!("[config] Password manager binary not found: pass-cli");
783            return Err(e).context("Failed to run Proton Pass CLI (pass-cli)");
784        }
785        other => other.context("Failed to run Proton Pass CLI (pass-cli)")?,
786    };
787    if !output.status.success() {
788        let stderr = String::from_utf8_lossy(&output.stderr);
789        warn!("[external] Proton Pass lookup failed: {}", stderr.trim());
790        anyhow::bail!("Proton Pass lookup failed");
791    }
792    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
793    if value.is_empty() {
794        warn!("[external] Proton Pass returned empty secret");
795        anyhow::bail!("Proton Pass returned empty secret");
796    }
797    debug!("Proton Pass lookup succeeded");
798    Ok(value)
799}
800
801#[cfg(test)]
802#[path = "askpass_tests.rs"]
803mod tests;