use std::collections::HashSet;
use std::path::PathBuf;
use std::process::Command;
use std::time::SystemTime;
use anyhow::{Context, Result};
use log::{debug, error, warn};
use crate::ssh_config::model::SshConfigFile;
pub struct PasswordSourceOption {
pub label: &'static str,
pub value: &'static str,
pub hint: &'static str,
}
pub const PASSWORD_SOURCES: &[PasswordSourceOption] = &[
PasswordSourceOption {
label: "OS Keychain",
value: "keychain",
hint: "keychain",
},
PasswordSourceOption {
label: "1Password",
value: "op://",
hint: "op://Vault/Item/field",
},
PasswordSourceOption {
label: "Bitwarden",
value: "bw:",
hint: "bw:item-name",
},
PasswordSourceOption {
label: "pass",
value: "pass:",
hint: "pass:path/to/entry",
},
PasswordSourceOption {
label: "HashiCorp Vault KV",
value: "vault:",
hint: "vault:secret/path#field",
},
PasswordSourceOption {
label: "Custom command",
value: "cmd:",
hint: "cmd %a %h",
},
PasswordSourceOption {
label: "None",
value: "",
hint: "(remove)",
},
];
pub fn handle() -> Result<()> {
crate::logging::init(false, false);
let alias = std::env::var("PURPLE_HOST_ALIAS").unwrap_or_default();
let config_path = std::env::var("PURPLE_CONFIG_PATH").unwrap_or_default();
let prompt = std::env::args().nth(1).unwrap_or_default();
let prompt_lower = prompt.to_ascii_lowercase();
if prompt_lower.contains("passphrase")
|| prompt_lower.contains("yes/no")
|| prompt_lower.contains("(yes/no/")
{
std::process::exit(1);
}
if alias.is_empty() || config_path.is_empty() {
std::process::exit(1);
}
let config =
SshConfigFile::parse(&PathBuf::from(&config_path)).context("Failed to parse SSH config")?;
let chain = build_proxy_chain(&config, &alias);
let resolved_alias = parse_password_prompt_host(&prompt)
.and_then(|h| find_alias_for_host(&config, h, &chain))
.unwrap_or_else(|| alias.clone());
let marker = marker_path(&resolved_alias);
if let Some(marker_path) = &marker {
if is_recent_marker(marker_path) {
debug!("Askpass retry detected for {resolved_alias}");
let _ = std::fs::remove_file(marker_path);
std::process::exit(1);
}
if let Err(e) = std::fs::create_dir_all(marker_path.parent().unwrap()) {
debug!("[config] Failed to create askpass marker directory: {e}");
}
if let Err(e) = crate::fs_util::atomic_write(marker_path, b"") {
debug!("[config] Failed to write askpass marker: {e}");
}
}
let source = find_askpass_source(&config, &resolved_alias);
let source = match source {
Some(s) => s,
None => std::process::exit(1),
};
debug!("Askpass invoked for alias={resolved_alias} source={source}");
let hostname = find_hostname(&config, &resolved_alias);
match retrieve_password(&source, &resolved_alias, &hostname) {
Ok(password) => {
debug!("Askpass retrieved password for {resolved_alias} via {source}");
print!("{}", password);
Ok(())
}
Err(err) => {
warn!("[external] Password retrieval failed via {source}");
debug!("[external] Password retrieval detail: {err}");
if let Some(m) = &marker {
let _ = std::fs::remove_file(m);
}
std::process::exit(1);
}
}
}
fn parse_password_prompt_host(prompt: &str) -> Option<&str> {
let idx = prompt.find("'s password")?;
let head = &prompt[..idx];
let (_, host) = head.rsplit_once('@')?;
let host = host.trim();
let host = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host);
if host.is_empty() { None } else { Some(host) }
}
fn find_alias_for_host(
config: &SshConfigFile,
host: &str,
permitted: &HashSet<String>,
) -> Option<String> {
let mut by_hostname: Option<String> = None;
for entry in config.host_entries() {
if !permitted.contains(&entry.alias) {
continue;
}
if entry.alias.eq_ignore_ascii_case(host) {
return Some(entry.alias.clone());
}
if by_hostname.is_none() && entry.hostname.eq_ignore_ascii_case(host) {
by_hostname = Some(entry.alias.clone());
}
}
by_hostname
}
fn build_proxy_chain(config: &SshConfigFile, target: &str) -> HashSet<String> {
let entries = config.host_entries();
let mut chain: HashSet<String> = HashSet::new();
let mut queue: Vec<String> = vec![target.to_string()];
while let Some(current) = queue.pop() {
if !chain.insert(current.clone()) {
continue;
}
let Some(entry) = entries.iter().find(|e| e.alias == current) else {
continue;
};
if entry.proxy_jump.is_empty() {
continue;
}
for jump in entry.proxy_jump.split(',') {
let host = parse_proxy_jump_host(jump);
if host.is_empty() {
continue;
}
for e in &entries {
if e.alias.eq_ignore_ascii_case(host) || e.hostname.eq_ignore_ascii_case(host) {
queue.push(e.alias.clone());
}
}
}
}
chain
}
fn parse_proxy_jump_host(jump: &str) -> &str {
let trimmed = jump.trim();
let after_user = trimmed.rsplit_once('@').map(|(_, h)| h).unwrap_or(trimmed);
if let Some(rest) = after_user.strip_prefix('[') {
if let Some(end) = rest.find(']') {
return &rest[..end];
}
}
after_user.split(':').next().unwrap_or(after_user)
}
fn find_askpass_source(config: &SshConfigFile, alias: &str) -> Option<String> {
for entry in config.host_entries() {
if entry.alias == alias {
if let Some(ref source) = entry.askpass {
return Some(source.clone());
}
}
}
load_askpass_default_direct()
}
fn load_askpass_default_direct() -> Option<String> {
let path = dirs::home_dir()?.join(".purple/preferences");
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
continue;
}
if let Some((k, v)) = line.split_once('=') {
if k.trim() == "askpass" {
let val = v.trim();
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
None
}
fn find_hostname(config: &SshConfigFile, alias: &str) -> String {
for entry in config.host_entries() {
if entry.alias == alias {
return entry.hostname.clone();
}
}
alias.to_string()
}
fn retrieve_password(source: &str, alias: &str, hostname: &str) -> Result<String> {
if source == "keychain" {
return retrieve_from_keychain(alias);
}
if let Some(uri) = source.strip_prefix("op://") {
return retrieve_from_1password(&format!("op://{}", uri));
}
if let Some(entry) = source.strip_prefix("pass:") {
return retrieve_from_pass(entry);
}
if let Some(item_id) = source.strip_prefix("bw:") {
return retrieve_from_bitwarden(item_id);
}
if let Some(rest) = source.strip_prefix("vault:") {
return retrieve_from_vault(rest);
}
let cmd = source.strip_prefix("cmd:").unwrap_or(source);
retrieve_from_command(cmd, alias, hostname)
}
fn retrieve_from_keychain(alias: &str) -> Result<String> {
#[cfg(target_os = "macos")]
{
let output = Command::new("security")
.args([
"find-generic-password",
"-a",
alias,
"-s",
"purple-ssh",
"-w",
])
.output()
.context("Failed to run security command")?;
if !output.status.success() {
anyhow::bail!("Keychain lookup failed");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg(not(target_os = "macos"))]
{
let output = Command::new("secret-tool")
.args(["lookup", "application", "purple-ssh", "host", alias])
.output()
.context("Failed to run secret-tool")?;
if !output.status.success() {
anyhow::bail!("Secret-tool lookup failed");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
}
pub fn keychain_has_password(alias: &str) -> bool {
retrieve_from_keychain(alias).is_ok()
}
pub fn retrieve_keychain_password(alias: &str) -> Result<String> {
retrieve_from_keychain(alias)
}
pub fn store_in_keychain(alias: &str, password: &str) -> Result<()> {
#[cfg(target_os = "macos")]
{
let status = Command::new("security")
.args([
"add-generic-password",
"-U",
"-a",
alias,
"-s",
"purple-ssh",
"-w",
password,
])
.status()
.context("Failed to run security command")?;
if !status.success() {
anyhow::bail!("Failed to store password in Keychain");
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
let mut child = Command::new("secret-tool")
.args([
"store",
"--label",
&format!("purple-ssh: {}", alias),
"application",
"purple-ssh",
"host",
alias,
])
.stdin(std::process::Stdio::piped())
.spawn()
.context("Failed to run secret-tool")?;
if let Some(ref mut stdin) = child.stdin {
use std::io::Write;
stdin.write_all(password.as_bytes())?;
}
let status = child.wait()?;
if !status.success() {
anyhow::bail!("Failed to store password with secret-tool");
}
Ok(())
}
}
pub fn remove_from_keychain(alias: &str) -> Result<()> {
#[cfg(target_os = "macos")]
{
let status = Command::new("security")
.args(["delete-generic-password", "-a", alias, "-s", "purple-ssh"])
.status()
.context("Failed to run security command")?;
if !status.success() {
anyhow::bail!("No password found for '{}' in Keychain", alias);
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
let status = Command::new("secret-tool")
.args(["clear", "application", "purple-ssh", "host", alias])
.status()
.context("Failed to run secret-tool")?;
if !status.success() {
anyhow::bail!("Failed to remove password with secret-tool");
}
Ok(())
}
}
fn retrieve_from_1password(uri: &str) -> Result<String> {
let result = Command::new("op")
.args(["read", uri, "--no-newline"])
.output();
let output = match result {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
error!("[config] Password manager binary not found: op");
return Err(e).context("Failed to run 1Password CLI (op)");
}
other => other.context("Failed to run 1Password CLI (op)")?,
};
if !output.status.success() {
anyhow::bail!("1Password lookup failed");
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn retrieve_from_pass(entry: &str) -> Result<String> {
let result = Command::new("pass").args(["show", entry]).output();
let output = match result {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
error!("[config] Password manager binary not found: pass");
return Err(e).context("Failed to run pass");
}
other => other.context("Failed to run pass")?,
};
if !output.status.success() {
anyhow::bail!("pass lookup failed");
}
let full = String::from_utf8_lossy(&output.stdout);
Ok(full.lines().next().unwrap_or("").to_string())
}
fn retrieve_from_bitwarden(item_id: &str) -> Result<String> {
let result = Command::new("bw")
.args(["get", "password", item_id])
.output();
let output = match result {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
error!("[config] Password manager binary not found: bw");
return Err(e).context("Failed to run Bitwarden CLI (bw)");
}
other => other.context("Failed to run Bitwarden CLI (bw)")?,
};
if !output.status.success() {
anyhow::bail!("Bitwarden lookup failed");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn retrieve_from_vault(spec: &str) -> Result<String> {
let (path, field) = match spec.rsplit_once('#') {
Some((p, f)) => (p, f),
None => (spec, "password"),
};
let result = Command::new("vault")
.args(["kv", "get", &format!("-field={}", field), path])
.output();
let output = match result {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
error!("[config] Password manager binary not found: vault");
return Err(e).context("Failed to run vault CLI");
}
other => other.context("Failed to run vault CLI")?,
};
if !output.status.success() {
anyhow::bail!("Vault lookup failed");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn retrieve_from_command(cmd: &str, alias: &str, hostname: &str) -> Result<String> {
let safe_alias = crate::snippet::shell_escape(alias);
let safe_hostname = crate::snippet::shell_escape(hostname);
let expanded = cmd.replace("%a", &safe_alias).replace("%h", &safe_hostname);
let output = Command::new("sh")
.args(["-c", &expanded])
.output()
.context("Failed to run custom askpass command")?;
if !output.status.success() {
anyhow::bail!("Custom askpass command failed");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn marker_path(alias: &str) -> Option<PathBuf> {
let safe = alias.replace(['/', '\\', '.'], "_");
dirs::home_dir().map(|h| h.join(format!(".purple/.askpass_{}", safe)))
}
fn is_recent_marker(path: &PathBuf) -> bool {
if let Ok(meta) = std::fs::metadata(path) {
if let Ok(modified) = meta.modified() {
if let Ok(elapsed) = SystemTime::now().duration_since(modified) {
return elapsed.as_secs() < 60;
}
}
}
false
}
pub fn cleanup_marker(_alias: &str) {
let Some(home) = dirs::home_dir() else {
return;
};
let Ok(read) = std::fs::read_dir(home.join(".purple")) else {
return;
};
for entry in read.flatten() {
if entry
.file_name()
.to_str()
.is_some_and(|s| s.starts_with(".askpass_"))
{
let _ = std::fs::remove_file(entry.path());
}
}
}
#[allow(dead_code)]
pub fn describe_source(source: &str) -> &str {
if source == "keychain" {
"OS Keychain"
} else if source.starts_with("op://") {
"1Password"
} else if source.starts_with("pass:") {
"pass"
} else if source.starts_with("bw:") {
"Bitwarden"
} else if source.starts_with("vault:") {
"HashiCorp Vault KV"
} else {
"Custom command"
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BwStatus {
Unlocked,
Locked,
NotAuthenticated,
NotInstalled,
}
fn parse_bw_status(stdout: &str) -> BwStatus {
if let Some(status) = stdout
.split("\"status\":")
.nth(1)
.and_then(|s| s.split('"').nth(1))
{
match status {
"unlocked" => BwStatus::Unlocked,
"locked" => BwStatus::Locked,
"unauthenticated" => BwStatus::NotAuthenticated,
_ => BwStatus::Locked,
}
} else {
BwStatus::NotInstalled
}
}
pub fn bw_vault_status() -> BwStatus {
let output = match Command::new("bw").arg("status").output() {
Ok(o) => o,
Err(_) => return BwStatus::NotInstalled,
};
let stdout = String::from_utf8_lossy(&output.stdout);
parse_bw_status(&stdout)
}
pub fn bw_unlock(password: &str) -> Result<String> {
let output = Command::new("bw")
.args(["unlock", "--passwordenv", "PURPLE_BW_MASTER", "--raw"])
.env("PURPLE_BW_MASTER", password)
.output()
.context("Failed to run Bitwarden CLI (bw)")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Bitwarden unlock failed: {}", stderr.trim());
}
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
if token.is_empty() {
anyhow::bail!("Bitwarden unlock returned empty session token");
}
Ok(token)
}
#[cfg(test)]
#[path = "askpass_tests.rs"]
mod tests;