openpencil/crates/op-git/src/ssh.rs

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)
}