use std::path::Path;
use toride_ssh_core::{CliRunner, Error, Fingerprint, KeySource, KeyType, Result, SshKey};
#[cfg(feature = "native")]
pub async fn connect() -> Result<Box<dyn ssh_agent_lib::agent::Session>> {
let socket_path = std::env::var("SSH_AUTH_SOCK").map_err(|_| Error::AgentNotAvailable)?;
let path = std::path::PathBuf::from(&socket_path);
if !path.exists() {
return Err(Error::AgentNotAvailable);
}
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
if let Ok(meta) = std::fs::metadata(&path)
&& !meta.file_type().is_socket()
{
return Err(Error::AgentOperationFailed(format!(
"SSH_AUTH_SOCK ({socket_path}) is not a Unix socket"
)));
}
}
let stream = tokio::task::spawn_blocking(move || {
std::os::unix::net::UnixStream::connect(&path)
.map_err(|e| Error::AgentOperationFailed(e.to_string()))
})
.await
.map_err(|e| Error::AgentOperationFailed(e.to_string()))??;
let tokio_stream = tokio::net::UnixStream::from_std(stream)
.map_err(|e| Error::AgentOperationFailed(e.to_string()))?;
Ok(Box::new(ssh_agent_lib::client::Client::new(tokio_stream)))
}
pub async fn list_identities(runner: &dyn CliRunner) -> Result<Vec<SshKey>> {
#[cfg(feature = "native")]
{
match list_identities_native().await {
Ok(keys) => return Ok(keys),
Err(Error::AgentNotAvailable) => return Err(Error::AgentNotAvailable),
Err(_) => {}
}
}
list_identities_via_cli(runner).await
}
pub async fn add_key(key_path: &Path, runner: &dyn CliRunner) -> Result<()> {
let path_str = key_path
.to_str()
.ok_or_else(|| Error::AgentOperationFailed("key path is not valid UTF-8".into()))?
.to_owned();
runner
.run("ssh-add", vec![path_str])
.await
.map_err(|e| Error::AgentOperationFailed(e.to_string()))?;
Ok(())
}
pub async fn test_key_usability(key_path: &Path, runner: &dyn CliRunner) -> Result<bool> {
let path_str = key_path
.to_str()
.ok_or_else(|| Error::AgentOperationFailed("key path is not valid UTF-8".into()))?
.to_owned();
match runner.run("ssh-add", vec!["-T".to_owned(), path_str]).await {
Ok(_) => Ok(true),
Err(Error::CommandFailed(_)) => Ok(false),
Err(e) => Err(Error::AgentOperationFailed(e.to_string())),
}
}
pub async fn destination_constrained_add(
key_path: &Path,
hosts: &[&str],
runner: &dyn CliRunner,
) -> Result<()> {
if hosts.is_empty() {
return Err(Error::AgentOperationFailed(
"destination-constrained add requires at least one host".into(),
));
}
let path_str = key_path
.to_str()
.ok_or_else(|| Error::AgentOperationFailed("key path is not valid UTF-8".into()))?
.to_owned();
let constraint = hosts.join(">");
runner
.run("ssh-add", vec!["-h".to_owned(), constraint, path_str])
.await
.map_err(|e| Error::AgentOperationFailed(e.to_string()))?;
Ok(())
}
pub async fn remove_key(key_path: &Path, runner: &dyn CliRunner) -> Result<()> {
let pub_path = key_path.with_extension("pub");
let path = if pub_path.exists() {
pub_path
} else {
key_path.to_path_buf()
};
let path_str = path
.to_str()
.ok_or_else(|| Error::AgentOperationFailed("key path is not valid UTF-8".into()))?
.to_owned();
runner
.run("ssh-add", vec!["-d".to_owned(), path_str])
.await
.map_err(|e| Error::AgentOperationFailed(e.to_string()))?;
Ok(())
}
#[cfg(feature = "native")]
async fn list_identities_native() -> Result<Vec<SshKey>> {
let mut client = connect().await?;
let identities = client
.request_identities()
.await
.map_err(|e| Error::AgentOperationFailed(e.to_string()))?;
let mut keys = Vec::with_capacity(identities.len());
for identity in identities {
let key_data = identity.credential.key_data();
let alg_str = key_data.algorithm().to_string();
let Some(key_type) = parse_key_type_from_algorithm(&alg_str) else {
tracing::warn!("skipping agent key with unknown algorithm: {alg_str}");
continue;
};
let fingerprint = match encode_key_data(key_data) {
Ok(bytes) => Some(compute_sha256_fingerprint(&bytes, key_type)),
Err(e) => {
tracing::warn!("failed to encode agent key data: {e}");
None
}
};
keys.push(SshKey {
path: std::path::PathBuf::from(if identity.comment.is_empty() {
format!("agent:{key_type:?}")
} else {
format!("agent:{}", identity.comment)
}),
key_type,
fingerprint,
comment: if identity.comment.is_empty() {
None
} else {
Some(identity.comment)
},
encrypted: false,
source: KeySource::Agent,
permissions: None,
has_public_pair: false,
has_certificate: false,
last_modified: None,
used_by_hosts: Vec::new(),
key_format: None,
});
}
Ok(keys)
}
#[cfg(feature = "native")]
fn encode_key_data(key_data: &ssh_agent_lib::ssh_key::public::KeyData) -> Result<Vec<u8>> {
use ssh_agent_lib::ssh_encoding::Encode;
let len = key_data
.encoded_len()
.map_err(|e| Error::AgentOperationFailed(format!("encoded_len failed: {e}")))?;
let mut buf = Vec::with_capacity(len);
key_data
.encode(&mut buf)
.map_err(|e| Error::AgentOperationFailed(format!("encode failed: {e}")))?;
Ok(buf)
}
#[allow(dead_code, reason = "public API helper, not exercised in-workspace")]
fn compute_sha256_fingerprint(bytes: &[u8], key_type: KeyType) -> Fingerprint {
use base64::Engine;
use base64::engine::general_purpose::STANDARD_NO_PAD;
use ssh_key::sha2::{Digest, Sha256};
let hash = Sha256::digest(bytes);
Fingerprint {
hash: STANDARD_NO_PAD.encode(hash),
key_type,
}
}
#[allow(dead_code, reason = "public API helper, not exercised in-workspace")]
pub(crate) fn parse_key_type_from_algorithm(alg: &str) -> Option<KeyType> {
match alg {
"ssh-ed25519" => Some(KeyType::Ed25519),
"ssh-rsa" => Some(KeyType::Rsa { bits: 0 }),
"ecdsa-sha2-nistp256" => Some(KeyType::EcdsaP256),
"ecdsa-sha2-nistp384" => Some(KeyType::EcdsaP384),
"ecdsa-sha2-nistp521" => Some(KeyType::EcdsaP521),
"ssh-dss" => Some(KeyType::Dsa),
"sk-ssh-ed25519@openssh.com" => Some(KeyType::SkEd25519),
"sk-ecdsa-sha2-nistp256@openssh.com" => Some(KeyType::SkEcdsaP256),
_ => {
tracing::warn!("unknown SSH key algorithm \"{alg}\"");
None
}
}
}
async fn list_identities_via_cli(runner: &dyn CliRunner) -> Result<Vec<SshKey>> {
let output = runner.run("ssh-add", vec!["-l".to_owned()]).await?;
Ok(output.lines().filter_map(parse_ssh_add_line).collect())
}
pub(crate) fn parse_ssh_add_line(line: &str) -> Option<SshKey> {
let line = line.trim();
if line.is_empty() || line.contains("The agent has no identities") {
return None;
}
let (_bits, rest) = line.split_once(' ')?;
let rest = rest.trim();
let (rest, key_type_opt) = if let Some(start) = rest.rfind('(') {
if let Some(end) = rest[start..].find(')') {
let kt = &rest[start + 1..start + end];
(rest[..start].trim_end(), Some(kt))
} else {
(rest, None)
}
} else {
(rest, None)
};
let key_type = key_type_opt.and_then(parse_key_type_from_display)?;
let (fingerprint_part, comment_part) = if let Some(space) = rest.find(' ') {
let (fp, c) = rest.split_at(space);
let c = c.trim();
(fp, if c.is_empty() { None } else { Some(c) })
} else {
(rest, None)
};
let hash = fingerprint_part
.strip_prefix("SHA256:")
.unwrap_or(fingerprint_part)
.to_string();
Some(SshKey {
path: std::path::PathBuf::from(format!("agent:{}", comment_part.unwrap_or("unknown"))),
key_type,
fingerprint: Some(Fingerprint { hash, key_type }),
comment: comment_part.map(str::to_owned),
encrypted: false,
source: KeySource::Agent,
permissions: None,
has_public_pair: false,
has_certificate: false,
last_modified: None,
used_by_hosts: Vec::new(),
key_format: None,
})
}
fn parse_key_type_from_display(s: &str) -> Option<KeyType> {
if s.eq_ignore_ascii_case("ED25519") {
Some(KeyType::Ed25519)
} else if s.eq_ignore_ascii_case("ED25519-SK") {
Some(KeyType::SkEd25519)
} else if s.eq_ignore_ascii_case("RSA") {
Some(KeyType::Rsa { bits: 0 })
} else if s.eq_ignore_ascii_case("ECDSA") {
Some(KeyType::EcdsaP256)
} else if s.eq_ignore_ascii_case("ECDSA-SK") {
Some(KeyType::SkEcdsaP256)
} else if s.eq_ignore_ascii_case("DSA") {
Some(KeyType::Dsa)
} else {
None
}
}
#[cfg(test)]
#[path = "client.test.rs"]
mod tests;