use base64::Engine;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
const ALGO_PREFERENCE: &[&str] = &[
"ssh-ed25519",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"ssh-rsa",
"ssh-dss",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublicKey {
pub algo: String,
pub blob_b64: String,
pub comment: Option<String>,
}
impl PublicKey {
pub fn fingerprint(&self) -> Option<String> {
let raw = base64::engine::general_purpose::STANDARD
.decode(self.blob_b64.as_bytes())
.ok()?;
let digest = Sha256::digest(&raw);
let b64 = base64::engine::general_purpose::STANDARD_NO_PAD.encode(digest);
Some(format!("SHA256:{b64}"))
}
pub fn to_line(&self) -> String {
match self.comment {
Some(ref c) if !c.is_empty() => format!("{} {} {}", self.algo, self.blob_b64, c),
_ => format!("{} {}", self.algo, self.blob_b64),
}
}
}
pub fn parse_public_key(contents: &str) -> Option<PublicKey> {
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((algo, rest)) = line.split_once(char::is_whitespace) else {
continue;
};
if !ALGO_PREFERENCE.contains(&algo) {
continue;
}
let rest = rest.trim_start();
let (blob, comment) = match rest.split_once(char::is_whitespace) {
Some((b, c)) => (b, c.trim()),
None => (rest, ""),
};
if blob.is_empty() {
continue;
}
if base64::engine::general_purpose::STANDARD
.decode(blob.as_bytes())
.is_err()
{
continue;
}
return Some(PublicKey {
algo: algo.to_string(),
blob_b64: blob.to_string(),
comment: (!comment.is_empty()).then(|| comment.to_string()),
});
}
None
}
fn public_path(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".pub");
PathBuf::from(name)
}
fn rank(algo: &str) -> usize {
ALGO_PREFERENCE
.iter()
.position(|a| *a == algo)
.unwrap_or(ALGO_PREFERENCE.len())
}
#[derive(Debug, Clone)]
pub struct FoundKey {
pub path: PathBuf,
pub key: PublicKey,
}
pub fn discover(ssh_dir: &Path) -> Vec<FoundKey> {
let entries = match std::fs::read_dir(ssh_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut found: Vec<FoundKey> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| p.extension().is_some_and(|x| x == "pub"))
.filter(|p| {
!p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with("-cert.pub"))
})
.filter_map(|path| {
let contents = std::fs::read_to_string(&path).ok()?;
let key = parse_public_key(&contents)?;
Some(FoundKey { path, key })
})
.collect();
found.sort_by(|a, b| {
rank(&a.key.algo)
.cmp(&rank(&b.key.algo))
.then_with(|| a.path.cmp(&b.path))
});
found
}
pub fn private_keys_without_usable_public(ssh_dir: &Path) -> Vec<PathBuf> {
const CANDIDATES: &[&str] = &[
"id_ed25519",
"id_ed25519_sk",
"id_ecdsa",
"id_ecdsa_sk",
"id_rsa",
"id_dsa",
];
CANDIDATES
.iter()
.map(|n| ssh_dir.join(n))
.filter(|p| p.is_file())
.filter(|p| {
std::fs::read_to_string(public_path(p))
.ok()
.and_then(|c| parse_public_key(&c))
.is_none()
})
.collect()
}
pub fn derive_public_key(private_key: &Path) -> Result<PublicKey, String> {
let out = std::process::Command::new("ssh-keygen")
.arg("-y")
.arg("-f")
.arg(private_key)
.output()
.map_err(|e| format!("could not run ssh-keygen: {e}"))?;
if !out.status.success() {
return Err(format!(
"ssh-keygen could not read {}: {}",
private_key.display(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
let text = String::from_utf8_lossy(&out.stdout);
parse_public_key(&text).ok_or_else(|| {
format!(
"ssh-keygen produced no usable key for {}",
private_key.display()
)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyType {
Ed25519,
Rsa4096,
}
impl KeyType {
pub fn keygen_type(&self) -> &'static str {
match self {
KeyType::Ed25519 => "ed25519",
KeyType::Rsa4096 => "rsa",
}
}
pub fn default_filename(&self) -> &'static str {
match self {
KeyType::Ed25519 => "id_ed25519",
KeyType::Rsa4096 => "id_rsa",
}
}
}
pub fn generate(
path: &Path,
key_type: KeyType,
comment: &str,
passphrase: Option<&str>,
) -> Result<PublicKey, String> {
if path.exists() {
return Err(format!(
"{} already exists — refusing to overwrite it",
path.display()
));
}
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
let existed = parent.is_dir();
std::fs::create_dir_all(parent)
.map_err(|e| format!("could not create {}: {e}", parent.display()))?;
#[cfg(unix)]
if !existed {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
let _ = existed;
}
let mut cmd = std::process::Command::new("ssh-keygen");
cmd.arg("-t")
.arg(key_type.keygen_type())
.arg("-f")
.arg(path)
.arg("-C")
.arg(comment);
if key_type == KeyType::Rsa4096 {
cmd.arg("-b").arg("4096");
}
if let Some(p) = passphrase {
cmd.arg("-N").arg(p);
}
let status = cmd
.status()
.map_err(|e| format!("could not run ssh-keygen: {e} — is OpenSSH installed?"))?;
if !status.success() {
return Err(format!("ssh-keygen exited with status {status}"));
}
let pub_path = public_path(path);
let contents = std::fs::read_to_string(&pub_path)
.map_err(|e| format!("could not read {}: {e}", pub_path.display()))?;
parse_public_key(&contents)
.ok_or_else(|| format!("{} is not a public key we understand", pub_path.display()))
}
pub fn ssh_dir() -> PathBuf {
if let Some(explicit) = std::env::var_os("ANVIL_SSH_DIR") {
if !explicit.is_empty() {
return PathBuf::from(explicit);
}
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ssh")
}
pub fn default_label() -> String {
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok()
.filter(|u| !u.is_empty());
let host = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.filter(|h| !h.is_empty());
match (user, host) {
(Some(u), Some(h)) => format!("{u}@{h}"),
(None, Some(h)) => h,
(Some(u), None) => u,
(None, None) => "anvil-cli".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const ED25519: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx cole@laptop";
#[test]
fn parses_algo_blob_and_comment() {
let k = parse_public_key(ED25519).expect("parse");
assert_eq!(k.algo, "ssh-ed25519");
assert_eq!(k.comment.as_deref(), Some("cole@laptop"));
}
#[test]
fn a_comment_may_contain_spaces() {
let k = parse_public_key(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx my laptop key",
)
.unwrap();
assert_eq!(k.comment.as_deref(), Some("my laptop key"));
}
#[test]
fn a_key_without_a_comment_parses_and_round_trips() {
let line =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx";
let k = parse_public_key(line).unwrap();
assert!(k.comment.is_none());
assert_eq!(k.to_line(), line);
}
#[test]
fn crlf_and_surrounding_blank_lines_are_tolerated() {
let k = parse_public_key(&format!("\n\r\n{ED25519}\r\n\n")).expect("parse");
assert_eq!(k.comment.as_deref(), Some("cole@laptop"));
assert!(!k.to_line().contains('\r'));
}
#[test]
fn a_private_key_is_never_parsed_as_a_public_one() {
let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----\n";
assert!(parse_public_key(private).is_none());
}
#[test]
fn extra_whitespace_between_fields_does_not_lose_the_key() {
let k = parse_public_key(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx cole@laptop",
)
.expect("a double-spaced key is still a key");
assert_eq!(k.algo, "ssh-ed25519");
assert_eq!(k.comment.as_deref(), Some("cole@laptop"));
assert_eq!(
k.fingerprint(),
parse_public_key(ED25519).unwrap().fingerprint()
);
}
#[test]
fn a_tab_separated_key_parses() {
let k = parse_public_key(
"ssh-ed25519\tAAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx\tcole@laptop",
)
.expect("tabs are whitespace too");
assert_eq!(k.comment.as_deref(), Some("cole@laptop"));
}
#[test]
fn garbage_and_non_base64_blobs_are_rejected() {
assert!(parse_public_key("").is_none());
assert!(parse_public_key("hello world").is_none());
assert!(parse_public_key("ssh-ed25519").is_none());
assert!(parse_public_key("ssh-ed25519 not!valid!base64!").is_none());
}
#[test]
fn fingerprint_matches_openssh_format() {
let k = parse_public_key(ED25519).unwrap();
let fp = k.fingerprint().expect("fingerprint");
assert!(fp.starts_with("SHA256:"), "got {fp}");
assert_eq!(fp.len(), "SHA256:".len() + 43, "got {fp}");
assert!(!fp.ends_with('='), "must be unpadded, got {fp}");
}
#[test]
fn fingerprint_ignores_the_comment() {
let a = parse_public_key(ED25519).unwrap();
let b = parse_public_key(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx someone@else",
)
.unwrap();
assert_eq!(a.fingerprint(), b.fingerprint());
}
#[test]
fn ed25519_outranks_rsa_which_outranks_dsa() {
assert!(rank("ssh-ed25519") < rank("ssh-rsa"));
assert!(rank("ssh-rsa") < rank("ssh-dss"));
assert!(rank("ssh-dss") < rank("something-invented"));
}
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"anvil-sshkeys-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
const RSA: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA rsa@host";
#[test]
fn discover_prefers_ed25519_over_rsa() {
let dir = tmpdir("prefer");
std::fs::write(dir.join("id_rsa.pub"), RSA).unwrap();
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
let found = discover(&dir);
assert_eq!(found.len(), 2, "both keys should be found");
assert_eq!(found[0].key.algo, "ssh-ed25519", "Ed25519 must come first");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_skips_certificates_and_private_keys() {
let dir = tmpdir("skip");
std::fs::write(dir.join("id_ed25519-cert.pub"), ED25519).unwrap();
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----",
)
.unwrap();
std::fs::write(dir.join("config"), "Host *\n").unwrap();
std::fs::write(dir.join("known_hosts"), "github.com ssh-rsa AAAA").unwrap();
let found = discover(&dir);
assert!(found.is_empty(), "found: {found:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_on_a_missing_directory_is_empty_not_an_error() {
let found = discover(Path::new("/definitely/not/a/real/ssh/dir"));
assert!(found.is_empty());
}
#[test]
fn discover_is_stable_when_two_keys_share_an_algorithm() {
let dir = tmpdir("stable");
std::fs::write(dir.join("b_key.pub"), ED25519).unwrap();
std::fs::write(dir.join("a_key.pub"), ED25519).unwrap();
let first = discover(&dir);
let second = discover(&dir);
assert_eq!(first.len(), 2);
assert_eq!(
first[0].path, second[0].path,
"the same key must win on every run"
);
assert!(first[0].path.ends_with("a_key.pub"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_private_key_with_no_public_half_is_reported() {
let dir = tmpdir("orphan");
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----",
)
.unwrap();
let orphans = private_keys_without_usable_public(&dir);
assert_eq!(orphans.len(), 1);
assert!(orphans[0].ends_with("id_ed25519"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_private_key_beside_an_unparseable_public_half_is_reported() {
let dir = tmpdir("corrupt");
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----",
)
.unwrap();
std::fs::write(dir.join("id_ed25519.pub"), "ssh-ed25519 \n").unwrap();
let orphans = private_keys_without_usable_public(&dir);
assert_eq!(orphans.len(), 1, "got: {orphans:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_private_key_that_still_has_its_public_half_is_not_reported() {
let dir = tmpdir("paired");
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----",
)
.unwrap();
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
assert!(private_keys_without_usable_public(&dir).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn generate_refuses_to_clobber_an_existing_key() {
let dir = tmpdir("clobber");
let path = dir.join("id_ed25519");
std::fs::write(&path, "existing").unwrap();
let err = generate(&path, KeyType::Ed25519, "c", Some("")).unwrap_err();
assert!(err.contains("already exists"), "got: {err}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "existing");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_public_path_is_appended_never_substituted() {
assert_eq!(
public_path(Path::new("/home/u/.ssh/anvil.key")),
PathBuf::from("/home/u/.ssh/anvil.key.pub")
);
assert_eq!(
public_path(Path::new("/home/u/.ssh/id_ed25519")),
PathBuf::from("/home/u/.ssh/id_ed25519.pub")
);
assert_eq!(
public_path(Path::new("/home/u/.ssh/my.work.key")),
PathBuf::from("/home/u/.ssh/my.work.key.pub")
);
}
#[test]
fn a_dotted_private_key_name_is_still_seen_as_paired() {
let dir = tmpdir("dotted");
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----",
)
.unwrap();
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
assert!(private_keys_without_usable_public(&dir).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn default_label_is_never_empty() {
assert!(!default_label().is_empty());
}
}