//! SSH key management for git remote authentication. //! //! The Rust counterpart of the TS Electron app's `ssh-keys.ts`: //! generate / list / import / delete the ed25519 key pairs used to //! authenticate `git fetch` / `pull` / `push` over SSH. //! //! Keys live in an SSH directory — the user's `~/.ssh` in //! production, or any directory ([`SshKeyStore::at`]) so the //! operations are hermetically testable. use std::path::{Path, PathBuf}; use std::process::Command; use crate::GitError; /// An SSH key pair in an [`SshKeyStore`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SshKey { /// The key's base name — the private-key file name. pub name: String, /// Absolute path to the private-key file. pub private_path: PathBuf, /// The public-key text (the `.pub` file contents, trimmed). pub public_key: String, } /// A directory of SSH key pairs — normally the user's `~/.ssh`. #[derive(Debug, Clone)] pub struct SshKeyStore { dir: PathBuf, } impl SshKeyStore { /// The current user's `~/.ssh` store. pub fn user() -> Result { let home = std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .ok_or_else(|| GitError::Io("no home directory in the environment".into()))?; Ok(SshKeyStore { dir: PathBuf::from(home).join(".ssh"), }) } /// A store rooted at an explicit directory. pub fn at(dir: impl Into) -> SshKeyStore { SshKeyStore { dir: dir.into() } } /// The store's directory. pub fn dir(&self) -> &Path { &self.dir } /// Generate a new ed25519 key pair named `name`, embedding /// `comment`. Fails if a key of that name already exists, so an /// existing key is never silently overwritten. pub fn generate(&self, name: &str, comment: &str) -> Result { validate_key_name(name)?; std::fs::create_dir_all(&self.dir).map_err(|e| GitError::Io(e.to_string()))?; let private = self.dir.join(name); if private.exists() { return Err(GitError::Io(format!( "an SSH key named `{name}` already exists" ))); } let private_str = private .to_str() .ok_or_else(|| GitError::Io("non-UTF-8 key path".into()))?; let output = Command::new("ssh-keygen") .args(["-t", "ed25519", "-f", private_str, "-N", "", "-C", comment]) .output() .map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { GitError::Io("`ssh-keygen` was not found — install OpenSSH".into()) } else { GitError::Io(e.to_string()) } })?; if !output.status.success() { return Err(GitError::Command { operation: "ssh-keygen".to_string(), stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), }); } self.load(name) } /// Every key pair in the store — one entry per `.pub` file that /// has a matching private-key file beside it. pub fn list(&self) -> Result, GitError> { let entries = match std::fs::read_dir(&self.dir) { Ok(entries) => entries, // A missing `~/.ssh` is simply "no keys", not an error. Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(e) => return Err(GitError::Io(e.to_string())), }; let mut keys = Vec::new(); for entry in entries.flatten() { let path = entry.path(); let Some(name) = path .file_name() .and_then(|n| n.to_str()) .and_then(|n| n.strip_suffix(".pub")) else { continue; }; // A `.pub` without its private key is an incomplete pair. if self.dir.join(name).is_file() { if let Ok(key) = self.load(name) { keys.push(key); } } } keys.sort_by(|a, b| a.name.cmp(&b.name)); Ok(keys) } /// Load a single key pair by name. pub fn load(&self, name: &str) -> Result { validate_key_name(name)?; let private_path = self.dir.join(name); if !private_path.is_file() { return Err(GitError::Io(format!("no SSH key named `{name}`"))); } let public_key = std::fs::read_to_string(self.dir.join(format!("{name}.pub"))) .map_err(|e| GitError::Io(format!("reading {name}.pub: {e}")))? .trim() .to_string(); Ok(SshKey { name: name.to_string(), private_path, public_key, }) } /// Import an existing private-key file into the store under /// `name`, copying the matching `.pub` alongside it. The private /// key is given owner-only (`0600`) permissions on Unix. pub fn import(&self, source_private_key: &Path, name: &str) -> Result { validate_key_name(name)?; std::fs::create_dir_all(&self.dir).map_err(|e| GitError::Io(e.to_string()))?; let dest_private = self.dir.join(name); if dest_private.exists() { return Err(GitError::Io(format!( "an SSH key named `{name}` already exists" ))); } // Read the source key and write it through `write_private_file` // so the destination is created `0600` *before* the private // key bytes land in it — `fs::copy` + `chmod` would leave a // window where the key is world-readable. let key_bytes = std::fs::read(source_private_key) .map_err(|e| GitError::Io(format!("reading source private key: {e}")))?; crate::write_private_file(&dest_private, &key_bytes)?; // The public key sits next to the private one as `.pub`. let source_pub = append_pub(source_private_key); if source_pub.is_file() { std::fs::copy(&source_pub, self.dir.join(format!("{name}.pub"))) .map_err(|e| GitError::Io(format!("copying public key: {e}")))?; } self.load(name) } /// Delete the key pair named `name` — both the private key and /// its `.pub`. Missing files are tolerated (idempotent). pub fn delete(&self, name: &str) -> Result<(), GitError> { validate_key_name(name)?; for path in [self.dir.join(name), self.dir.join(format!("{name}.pub"))] { match std::fs::remove_file(&path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(GitError::Io(e.to_string())), } } Ok(()) } } /// Reject a key `name` that is not a single, safe path component. /// /// Every store method joins `name` onto the store directory; an /// unchecked `name` such as `../../id_ed25519`, `sub/key`, or an /// absolute path would let `generate` / `import` / `delete` write or /// remove files *outside* the store — e.g. clobber the user's real /// `~/.ssh/id_ed25519`. A valid name has no path separators and is /// not a directory-traversal token. fn validate_key_name(name: &str) -> Result<(), GitError> { let unsafe_name = name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\') || name.contains('\0'); if unsafe_name { return Err(GitError::Io(format!( "invalid SSH key name `{name}` — must be a plain file name \ with no path separators" ))); } Ok(()) } /// `` → `.pub`. fn append_pub(path: &Path) -> PathBuf { let mut s = path.as_os_str().to_owned(); s.push(".pub"); PathBuf::from(s) }