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
10pub 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 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
63pub fn handle() -> Result<()> {
66 crate::logging::init(false, false);
69
70 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 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 std::process::exit(1);
86 }
87
88 if alias.is_empty() || config_path.is_empty() {
89 std::process::exit(1);
90 }
91
92 let config =
98 SshConfigFile::parse(&PathBuf::from(&config_path)).context("Failed to parse SSH config")?;
99
100 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 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
158fn 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
176fn 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
202fn 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
236fn 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
249fn find_askpass_source(config: &SshConfigFile, alias: &str) -> Option<String> {
251 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 load_askpass_default_direct()
261}
262
263fn 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
285fn 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
295fn 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 let cmd = source.strip_prefix("cmd:").unwrap_or(source);
322 retrieve_from_command(env, cmd, alias, hostname)
323}
324
325fn 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
374pub fn keychain_has_password(env: &crate::runtime::env::Env, alias: &str) -> bool {
376 retrieve_from_keychain(env, alias).is_ok()
377}
378
379pub fn retrieve_keychain_password(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
381 retrieve_from_keychain(env, alias)
382}
383
384pub 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
439pub 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
467fn 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
493fn 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
517fn 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
544fn 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
578fn 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
607fn 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
614fn 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
626pub 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#[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#[derive(Debug, Clone, Copy, PartialEq)]
671pub enum BwStatus {
672 Unlocked,
673 Locked,
674 NotAuthenticated,
675 NotInstalled,
676}
677
678fn 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
696pub 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
706pub 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#[derive(Debug, Clone, Copy, PartialEq)]
729pub enum ProtonStatus {
730 Authenticated,
731 NotAuthenticated,
732 NotInstalled,
733}
734
735pub 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
751pub 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
774fn 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
790fn 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;