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
11pub 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 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
64pub fn handle() -> Result<()> {
67 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 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 std::process::exit(1);
83 }
84
85 if alias.is_empty() || config_path.is_empty() {
86 std::process::exit(1);
87 }
88
89 let config =
95 SshConfigFile::parse(&PathBuf::from(&config_path)).context("Failed to parse SSH config")?;
96
97 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 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
155fn 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
173fn 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
199fn 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
233fn 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
246fn find_askpass_source(config: &SshConfigFile, alias: &str) -> Option<String> {
248 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 load_askpass_default_direct()
258}
259
260fn 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
282fn 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
292fn 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 let cmd = source.strip_prefix("cmd:").unwrap_or(source);
314 retrieve_from_command(cmd, alias, hostname)
315}
316
317fn 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
364pub fn keychain_has_password(alias: &str) -> bool {
366 retrieve_from_keychain(alias).is_ok()
367}
368
369pub fn retrieve_keychain_password(alias: &str) -> Result<String> {
371 retrieve_from_keychain(alias)
372}
373
374pub 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
423pub 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
449fn 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
474fn 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
498fn 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
524fn 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
557fn 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
580fn 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
587fn 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
599pub 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#[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#[derive(Debug, Clone, Copy, PartialEq)]
644pub enum BwStatus {
645 Unlocked,
646 Locked,
647 NotAuthenticated,
648 NotInstalled,
649}
650
651fn 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
669pub 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
679pub 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#[derive(Debug, Clone, Copy, PartialEq)]
701pub enum ProtonStatus {
702 Authenticated,
703 NotAuthenticated,
704 NotInstalled,
705}
706
707pub 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
723pub 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
745fn 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
761fn 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;