210 lines
7.8 KiB
Rust
210 lines
7.8 KiB
Rust
//! 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<SshKeyStore, GitError> {
|
|
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<PathBuf>) -> 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<SshKey, GitError> {
|
|
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<Vec<SshKey>, 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<SshKey, GitError> {
|
|
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<SshKey, GitError> {
|
|
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 `<src>.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(())
|
|
}
|
|
|
|
/// `<path>` → `<path>.pub`.
|
|
fn append_pub(path: &Path) -> PathBuf {
|
|
let mut s = path.as_os_str().to_owned();
|
|
s.push(".pub");
|
|
PathBuf::from(s)
|
|
}
|