ref:main
//! Local SSH key discovery, parsing, fingerprinting and generation.
//!
//! Split out from `commands::ssh_key` so the parts that touch neither the
//! network nor a terminal can be unit-tested directly: everything here is
//! either pure (`parse_public_key`, `fingerprint`, `rank`) or takes the
//! directory to look in as an argument, so tests never depend on the real
//! `~/.ssh`.
use base64::Engine;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
/// Public-key algorithms we recognise, best first.
///
/// Order is the preference used to pick a key when a user has several: Ed25519
/// is the modern default, the `sk-` variants are hardware-backed and equally
/// good, ECDSA and RSA are legacy-but-fine, and DSA is effectively dead (and
/// refused by current OpenSSH) so it sorts last.
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",
];
/// A parsed OpenSSH public key: `<algo> <base64 blob> [comment]`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublicKey {
pub algo: String,
pub blob_b64: String,
pub comment: Option<String>,
}
impl PublicKey {
/// The SHA256 fingerprint in OpenSSH's display form (`SHA256:…`, base64
/// without padding) — the same string `ssh-keygen -lf` prints and the
/// server stores, so the two can be compared to spot an already-uploaded
/// key.
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}"))
}
/// Re-render the key in the exact one-line form OpenSSH uses, which is what
/// the server wants in `public_key`.
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),
}
}
}
/// Parse the first usable public key out of a `.pub` file's contents.
///
/// Tolerant of the things that actually show up in these files: blank lines,
/// `#` comments, CRLF endings, and trailing whitespace. Returns `None` for
/// anything that isn't a recognised `<algo> <base64> [comment]` line — notably
/// a *private* key handed over by mistake, which must not be uploaded.
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;
}
// Split on whitespace *runs*, not single characters: a hand-edited or
// re-wrapped `.pub` file can have two spaces between the fields, and
// `splitn` would hand back an empty blob and make us discard a
// perfectly good key. The comment keeps its own internal spacing,
// which `split_whitespace` would destroy.
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;
}
// The blob must be real base64, or the server would reject it and the
// fingerprint we compare against would be meaningless.
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
}
/// The public-key path `ssh-keygen` writes next to the private key at `path`.
///
/// `ssh-keygen -f x.key` produces `x.key.pub` — it *appends*. `Path::with_
/// extension("pub")` *replaces*, so it would name `x.pub` for any filename
/// containing a dot, and we would go looking for a file that was never
/// written. That matters because the user is free to type a filename at the
/// generation prompt.
fn public_path(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".pub");
PathBuf::from(name)
}
/// Preference index for an algorithm; unknown algorithms sort last.
fn rank(algo: &str) -> usize {
ALGO_PREFERENCE
.iter()
.position(|a| *a == algo)
.unwrap_or(ALGO_PREFERENCE.len())
}
/// A public key found on disk, with the path it came from.
#[derive(Debug, Clone)]
pub struct FoundKey {
pub path: PathBuf,
pub key: PublicKey,
}
/// Every usable public key in `ssh_dir`, best first.
///
/// Sorted by algorithm preference and then by path, so the choice is stable
/// across runs rather than dependent on directory order — a user with both
/// `id_rsa.pub` and `id_ed25519.pub` gets the Ed25519 key every time.
///
/// Certificates (`*-cert.pub`) are skipped: they are not something the server
/// stores as an account key, and uploading one instead of the key it certifies
/// would silently not grant access.
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
}
/// Private keys with no *usable* public half beside them, best first.
///
/// A `.pub` file is a convenience, not a requirement — it is derivable from the
/// private key. When one is missing or unreadable, recovering it beats
/// declaring "no key found" and generating a second key next to the perfectly
/// good one the user already has (which `ssh-keygen` would then refuse to
/// overwrite, leaving them stuck on a message about the wrong problem).
///
/// "Usable" is the operative word, and it is deliberately not "exists": a
/// truncated, corrupt, or unrecognised-algorithm `.pub` file is exactly as
/// useless to us as a missing one, and testing only for existence would send
/// that case down the same dead end.
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()
}
/// Derive a public key from a private key via `ssh-keygen -y`.
///
/// Fails (rather than hangs) on a passphrase-protected key in a non-interactive
/// context: `ssh-keygen` reads the passphrase from the terminal, and with no
/// terminal it exits nonzero.
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()
)
})
}
/// What `generate` should create. Ed25519 with no size argument is the right
/// default; RSA is kept only for hosts that still need it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyType {
Ed25519,
Rsa4096,
}
impl KeyType {
/// The `ssh-keygen -t` name.
pub fn keygen_type(&self) -> &'static str {
match self {
KeyType::Ed25519 => "ed25519",
KeyType::Rsa4096 => "rsa",
}
}
/// Default filename OpenSSH itself would use, so the key lands where
/// `ssh` looks for it without any `IdentityFile` configuration.
pub fn default_filename(&self) -> &'static str {
match self {
KeyType::Ed25519 => "id_ed25519",
KeyType::Rsa4096 => "id_rsa",
}
}
}
/// Create a new key pair at `path` with `ssh-keygen`.
///
/// `passphrase` of `None` means "let `ssh-keygen` prompt on the terminal" — the
/// passphrase then never passes through our process, our argv, or our memory.
/// `Some("")` requests an unencrypted key, which is what a non-interactive run
/// has to do.
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()) {
// Tighten permissions ONLY on a directory we just created. The parent
// is whatever the user typed at the generation prompt: answer
// `~/anvil_key` and it is their home directory, and chmod-ing that to
// 0700 would break anything relying on it being traversable (other
// accounts, a web server serving ~/public_html). Creating it 0700 is
// right; re-permissioning someone's existing directory is not ours to
// do.
let existed = parent.is_dir();
std::fs::create_dir_all(parent)
.map_err(|e| format!("could not create {}: {e}", parent.display()))?;
// ssh(1) ignores a world-readable ~/.ssh for some operations and
// OpenSSH refuses group-writable ones outright, so a directory we
// create for keys starts at 0700.
#[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");
}
// `-N ""` is the documented way to ask for no passphrase, and is only
// reached when we could not prompt (the caller says so out loud). With no
// `-N` at all, ssh-keygen opens the terminal and asks for itself — so the
// passphrase never passes through our argv or memory.
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()))
}
/// `~/.ssh`, or `$ANVIL_SSH_DIR` when set (tests, and unusual homes).
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")
}
/// `user@host`, the label OpenSSH puts in a key comment. Used as the default
/// for both the key comment and the name shown in `anvil ssh-key list`, so a
/// key is identifiable later without the user having to invent a name.
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::*;
// A real Ed25519 public key (generated for tests; the private half was
// discarded). Using a genuine one matters: the fingerprint assertions below
// are checked against `ssh-keygen -lf`.
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() {
// OpenSSH treats everything after the blob as the comment, spaces and
// all. Splitting on every space would truncate it.
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"));
// A stray \r must not ride along into the comment we upload.
assert!(!k.to_line().contains('\r'));
}
#[test]
fn a_private_key_is_never_parsed_as_a_public_one() {
// The whole point of the guard: uploading a private key would hand the
// server the user's credential.
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() {
// Splitting on single whitespace *characters* handed back an empty
// blob here and discarded the key — after which `--auto` would decide
// the user had no key at all and try to generate one.
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}");
// Base64 of a 32-byte digest, unpadded: 43 chars after the prefix.
assert_eq!(fp.len(), "SHA256:".len() + 43, "got {fp}");
assert!(!fp.ends_with('='), "must be unpadded, got {fp}");
}
#[test]
fn fingerprint_ignores_the_comment() {
// The server fingerprints the blob. If ours folded the comment in, the
// "already uploaded" check would never match and we'd re-POST forever.
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"));
}
// ── discover ──────────────────────────────────────────────────────
//
// These write into a temp dir rather than the caller's ~/.ssh; `discover`
// takes the directory precisely so that's possible.
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() {
// A machine with no ~/.ssh at all is exactly the case `--auto` exists
// to handle; it must reach the "generate one" path, not blow up.
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() {
// A `.pub` that exists but is corrupt is exactly as useless as one
// that is missing. Testing only for existence hid this key from the
// recovery path and sent the caller off to generate a duplicate.
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() {
// Overwriting a private key destroys access to every other host that
// trusts it. This must fail before ssh-keygen is even invoked.
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() {
// ssh-keygen -f x.key writes x.key.pub. `with_extension("pub")` would
// say `x.pub`, so generation would create the pair and then fail
// reading a file that was never written — leaving the user wedged
// behind the "already exists" guard on the retry.
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() {
// The same substitution bug would make `private_keys_without_usable_public`
// think a paired key was orphaned.
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());
}
}