Skip to main content

purple_ssh/
askpass.rs

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