crates/nornir-git/src/sshkey.rs
//! # sshkey — pure-Rust ed25519 OpenSSH **deploy-key** minting
//!
//! The ONE canonical implementation of the git SSH identity that nornir and
//! dwarves used to twin (`nornir::generate_keypair` +
//! `dwarves::service::generate_deploy_keypair` — byte-for-byte the same
//! `ssh-key` + `rand_core` recipe). Lifted here (L5, DRY) next to the transport
//! that consumes it: [`crate::ssh`] authenticates `git-upload-pack` with exactly
//! this `id_ed25519`.
//!
//! Pure-Rust, **zero-shell** (L9): the private key is generated with
//! [`ssh_key::PrivateKey::random`] over [`rand_core::OsRng`] and serialised in
//! OpenSSH format in-process — never an `ssh-keygen` subprocess.
//!
//! ## What it writes (identical to both former twins)
//! - `<dir>/id_ed25519` — the OpenSSH private key, mode **0600** (owner-only).
//! - `<dir>/id_ed25519.pub` — `ssh-ed25519 AAAA… <user>@<host>`, mode **0644**.
//! - `<dir>` itself is created **0700**.
//!
//! Everything is **idempotent**: an existing private key is preserved (no
//! silent overwrite of a live deploy identity).
//!
//! ## Two entry points
//! - [`generate_ed25519_openssh`] — the primitive: mint into any `dir` with a
//! caller-chosen OpenSSH `comment`. Returns whether a new key was created.
//! - [`ensure_deploy_key`] — the service-install helper: mint
//! `<home>/.ssh/id_ed25519`, **chown** the `.ssh` dir + key files to the
//! service user (`user_uid:gid`), and hand back the public key. The comment is
//! derived from the home dir's basename, so `/home/nornir` → `nornir@<host>`
//! and `/home/dwarves` → `dwarves@<host>`, matching the old twins exactly.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// The private deploy-key filename (`id_ed25519`).
pub const PRIV_FILE: &str = "id_ed25519";
/// The public deploy-key filename (`id_ed25519.pub`).
pub const PUB_FILE: &str = "id_ed25519.pub";
/// A deploy key on disk — the result of [`ensure_deploy_key`].
#[derive(Debug, Clone)]
pub struct DeployKey {
/// The public key line, `ssh-ed25519 AAAA… <user>@<host>` (no trailing `\n`).
/// This is what you paste as a repo deploy key.
pub pubkey: String,
/// Path of the OpenSSH private key (`<home>/.ssh/id_ed25519`, mode 0600).
pub private_path: PathBuf,
/// Path of the public key (`<home>/.ssh/id_ed25519.pub`, mode 0644).
pub public_path: PathBuf,
/// `true` when this call minted a fresh key; `false` when one already existed.
pub created: bool,
}
/// The host label baked into the key comment. Reads `/etc/hostname` (trimmed),
/// falling back to `localhost` — exactly as the former twins did.
fn hostname() -> String {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "localhost".to_string())
}
/// Generate an ed25519 keypair (pure Rust) into `dir`, OpenSSH format, with the
/// given OpenSSH `comment` (e.g. `"nornir@loki"`).
///
/// Idempotent: if `<dir>/id_ed25519` already exists it is preserved and this
/// returns `Ok(false)`. On a fresh mint it writes the private key **0600**, the
/// public key **0644**, creates `dir` **0700**, and returns `Ok(true)`.
pub fn generate_ed25519_openssh(dir: &Path, comment: &str) -> Result<bool> {
let priv_path = dir.join(PRIV_FILE);
let pub_path = dir.join(PUB_FILE);
if priv_path.exists() {
return Ok(false);
}
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).ok();
}
let mut key = ssh_key::PrivateKey::random(&mut rand_core::UnwrapErr(getrandom::SysRng), ssh_key::Algorithm::Ed25519)
.context("generate ed25519 deploy key")?;
key.set_comment(comment);
let openssh = key
.to_openssh(ssh_key::LineEnding::LF)
.context("encode private key (OpenSSH)")?;
std::fs::write(&priv_path, openssh.as_bytes())
.with_context(|| format!("write {}", priv_path.display()))?;
let pubtxt = key
.public_key()
.to_openssh()
.context("encode public key (OpenSSH)")?;
std::fs::write(&pub_path, format!("{pubtxt}\n"))
.with_context(|| format!("write {}", pub_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600))?;
std::fs::set_permissions(&pub_path, std::fs::Permissions::from_mode(0o644))?;
}
Ok(true)
}
/// Ensure the deploy key for a service user exists at `<home>/.ssh/id_ed25519`
/// (idempotent), chown its `.ssh` dir + key files to `user_uid:gid`, and return
/// the [`DeployKey`] (including the public-key line to register as a repo deploy
/// key).
///
/// The OpenSSH comment is derived from the home dir's basename, so
/// `/home/nornir` → `nornir@<host>` and `/home/dwarves` → `dwarves@<host>` — the
/// same comments the former per-service twins wrote.
///
/// Root-only in practice (chowns into another user's home). On non-unix targets
/// the chown is skipped (there is nothing to chown), but the key is still minted.
///
/// INCIDENT 2026-07-22: `home` MUST be absolute. This runs as root during a service
/// install, and a relative path resolves against the process CWD — which for a
/// systemd service is `WorkingDirectory=`, default `/`. Nothing is created before
/// that check.
pub fn ensure_deploy_key(home: &Path, user_uid: u32, gid: u32) -> Result<DeployKey> {
if !home.is_absolute() {
anyhow::bail!(
"refusing to mint a deploy key under a RELATIVE service home ({}): a \
relative path resolves against the process CWD, which for a systemd \
service is `WorkingDirectory=` (default `/`)",
home.display()
);
}
let user = home
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("deploy")
.to_string();
let comment = format!("{user}@{}", hostname());
let ssh_dir = home.join(".ssh");
let created = generate_ed25519_openssh(&ssh_dir, &comment)?;
let private_path = ssh_dir.join(PRIV_FILE);
let public_path = ssh_dir.join(PUB_FILE);
// Robustness: if a private key exists but its `.pub` is missing (a truncated
// prior install), reconstruct the public half from the private key so callers
// always get a pubkey back.
if !public_path.exists() && private_path.exists() {
let priv_txt = std::fs::read_to_string(&private_path)
.with_context(|| format!("read {}", private_path.display()))?;
let key = ssh_key::PrivateKey::from_openssh(&priv_txt)
.context("parse existing OpenSSH private key")?;
let pubtxt = key
.public_key()
.to_openssh()
.context("encode public key (OpenSSH)")?;
std::fs::write(&public_path, format!("{pubtxt}\n"))
.with_context(|| format!("write {}", public_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&public_path, std::fs::Permissions::from_mode(0o644)).ok();
}
}
#[cfg(unix)]
{
// INCIDENT 2026-07-22 — this runs as ROOT inside `<service home>/.ssh` during
// a service install, so it must not be redirectable:
//
// * `lchown`, never `chown`: `chown(2)` FOLLOWS symlinks, so a link planted at
// `<home>/.ssh` (or at either key file) would hand a human's real `~/.ssh`
// — private key included — to the service account. `lchown` re-owns the link
// itself and can never leave the tree.
// * an absolute `home` only: a relative one resolves against the process CWD,
// which under systemd is `WorkingDirectory=` (default `/`).
//
// The blast-radius denylist itself lives ONCE in skidbladnir
// (`systemd::guard_chown_target`); this leaf is airgap-clean and does not
// depend on it, so it does the one thing it can do locally — refuse to chown
// at all unless the target is an explicit absolute path — rather than keeping
// a second copy of the denylist.
use std::os::unix::fs::lchown;
// `lchown` protects the FINAL component only — intermediate components are
// always resolved — so a symlinked `.ssh` would still redirect the two key-file
// chowns into whatever it points at. Refuse the whole re-own instead.
let ssh_dir_is_real = std::fs::symlink_metadata(&ssh_dir)
.map(|m| m.file_type().is_dir())
.unwrap_or(false);
if !ssh_dir_is_real {
anyhow::bail!(
"refusing to re-own {}: it is not a real directory (a symlink here \
redirects the chown out of the service tree)",
ssh_dir.display()
);
}
// The `.ssh` dir chown is the load-bearing one (the service user must be
// able to read the key); the per-file chowns are best-effort.
let _ = lchown(&ssh_dir, Some(user_uid), Some(gid));
let _ = lchown(&private_path, Some(user_uid), Some(gid));
let _ = lchown(&public_path, Some(user_uid), Some(gid));
}
#[cfg(not(unix))]
let _ = (user_uid, gid);
let pubkey = std::fs::read_to_string(&public_path)
.with_context(|| format!("read {}", public_path.display()))?
.trim_end()
.to_string();
Ok(DeployKey { pubkey, private_path, public_path, created })
}
// ─── tests: red-when-broken (valid ed25519 OpenSSH · 0600 · deterministic shape) ─
#[cfg(test)]
mod tests {
use super::*;
/// The minted private key must parse back as a real **ed25519** OpenSSH key,
/// and the public half must be the `ssh-ed25519 …` line for the same key.
#[test]
fn mints_a_valid_ed25519_openssh_keypair() {
let dir = tempfile::tempdir().unwrap();
let ssh = dir.path().join(".ssh");
let created = generate_ed25519_openssh(&ssh, "nornir@testhost").unwrap();
assert!(created, "first mint reports created=true");
let priv_txt = std::fs::read_to_string(ssh.join(PRIV_FILE)).unwrap();
// RED if we ever stop writing OpenSSH-armored keys (e.g. a shell-out to a
// different format sneaks back in).
assert!(
priv_txt.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----"),
"private key is OpenSSH-armored, got: {:?}",
&priv_txt[..priv_txt.len().min(40)]
);
let key = ssh_key::PrivateKey::from_openssh(&priv_txt).expect("parses as OpenSSH private key");
assert_eq!(key.algorithm(), ssh_key::Algorithm::Ed25519, "algorithm is ed25519");
let pub_txt = std::fs::read_to_string(ssh.join(PUB_FILE)).unwrap();
// Deterministic SHAPE: always the ssh-ed25519 prefix + our comment.
assert!(pub_txt.starts_with("ssh-ed25519 "), "pub is ssh-ed25519: {pub_txt}");
assert!(pub_txt.trim_end().ends_with("nornir@testhost"), "pub carries comment: {pub_txt}");
// Public half must correspond to the private key.
let parsed_pub = ssh_key::PublicKey::from_openssh(pub_txt.trim_end()).unwrap();
assert_eq!(parsed_pub.to_bytes().unwrap(), key.public_key().to_bytes().unwrap(), "pub matches priv");
}
/// The private key MUST be owner-only (0600) — a deploy key that is
/// world-readable is a leak. RED the instant the mode regresses.
#[cfg(unix)]
#[test]
fn private_key_is_0600_and_dir_0700() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let ssh = dir.path().join(".ssh");
generate_ed25519_openssh(&ssh, "dwarves@testhost").unwrap();
let pmode = std::fs::metadata(ssh.join(PRIV_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(pmode, 0o600, "private deploy key is 0600, got {pmode:o}");
let dmode = std::fs::metadata(&ssh).unwrap().permissions().mode() & 0o777;
assert_eq!(dmode, 0o700, ".ssh dir is 0700, got {dmode:o}");
let ubmode = std::fs::metadata(ssh.join(PUB_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(ubmode, 0o644, "public key is 0644, got {ubmode:o}");
}
/// Idempotent: a second call preserves the live key and reports created=false.
#[test]
fn idempotent_preserves_existing_key() {
let dir = tempfile::tempdir().unwrap();
let ssh = dir.path().join(".ssh");
assert!(generate_ed25519_openssh(&ssh, "nornir@h").unwrap());
let first = std::fs::read(ssh.join(PRIV_FILE)).unwrap();
assert!(!generate_ed25519_openssh(&ssh, "nornir@h").unwrap(), "second mint is a no-op");
let second = std::fs::read(ssh.join(PRIV_FILE)).unwrap();
assert_eq!(first, second, "existing deploy key is preserved byte-for-byte");
}
/// `ensure_deploy_key` mints `<home>/.ssh/id_ed25519`, derives the comment
/// from the home basename, and hands back the pubkey.
#[test]
fn ensure_deploy_key_writes_home_ssh_and_returns_pubkey() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home").join("nornir");
std::fs::create_dir_all(&home).unwrap();
// uid/gid of the current process — chown to self always succeeds (or is a
// harmless no-op); we never need root for the self case.
let (uid, gid) = current_ids();
let key = ensure_deploy_key(&home, uid, gid).unwrap();
assert!(key.created, "fresh home mints a key");
assert!(key.private_path.ends_with(".ssh/id_ed25519"), "priv under <home>/.ssh");
assert!(key.pubkey.starts_with("ssh-ed25519 "), "returned pubkey shape: {}", key.pubkey);
assert!(key.pubkey.contains("nornir@"), "comment derived from home basename: {}", key.pubkey);
// Second call is idempotent and still returns the same pubkey.
let again = ensure_deploy_key(&home, uid, gid).unwrap();
assert!(!again.created, "second ensure is a no-op");
assert_eq!(again.pubkey, key.pubkey, "same pubkey returned");
}
/// INCIDENT 2026-07-22 (RED-when-broken): `ensure_deploy_key` runs as **root**
/// during a service install, so its chowns must never be redirectable. If
/// `<home>/.ssh` is a SYMLINK the whole re-own is REFUSED — `chown(2)` would have
/// followed it and handed a human's real `~/.ssh`, private key included, to the
/// service account. (`lchown` alone is not enough: it protects the final path
/// component only, and the two key files sit *behind* the link.)
///
/// Proven without root by re-owning to a *different group this process already
/// belongs to* — the victim's gid must come out untouched.
#[cfg(unix)]
#[test]
fn a_symlinked_ssh_dir_is_refused_and_never_followed() {
use std::os::unix::fs::MetadataExt;
let (uid, gid) = current_ids();
let Some(alt_gid) = alternate_gid(gid) else {
eprintln!("skipped: this process belongs to only one group");
return;
};
let d = tempfile::tempdir().unwrap();
// Stand-in for a human's real `~/.ssh`, complete with a live private key.
let victim = d.path().join("home/alice/.ssh");
std::fs::create_dir_all(&victim).unwrap();
generate_ed25519_openssh(&victim, "alice@laptop").unwrap();
// The service home whose `.ssh` has been replaced by a link to it.
let home = d.path().join("home/dwarves");
std::fs::create_dir_all(&home).unwrap();
std::os::unix::fs::symlink(&victim, home.join(".ssh")).unwrap();
let dir_gid = std::fs::metadata(&victim).unwrap().gid();
let key_gid = std::fs::metadata(victim.join(PRIV_FILE)).unwrap().gid();
ensure_deploy_key(&home, uid, alt_gid)
.expect_err("a symlinked .ssh must be REFUSED, not followed");
assert!(
std::fs::symlink_metadata(home.join(".ssh")).unwrap().file_type().is_symlink(),
"the planted link is still a link"
);
assert_eq!(
std::fs::metadata(&victim).unwrap().gid(),
dir_gid,
"the human's real .ssh must NOT be re-owned through the symlink"
);
assert_eq!(
std::fs::metadata(victim.join(PRIV_FILE)).unwrap().gid(),
key_gid,
"the human's private key must NOT be re-owned through the symlink"
);
}
/// A RELATIVE home is refused outright: it resolves against the process CWD,
/// which for a systemd service is `WorkingDirectory=` — default `/`.
#[cfg(unix)]
#[test]
fn a_relative_home_is_refused_before_anything_is_created() {
let (uid, gid) = current_ids();
// Clear any residue first: a build from BEFORE this guard existed really
// did mint into the source tree here, and that leftover would fail the
// `!exists()` assertion below forever — turning a passing guard into a
// permanent red that looks like the guard is broken. The claim under test
// is "THIS call created nothing", not "nothing has ever existed here".
let rel = Path::new("state/dwarves");
let _ = std::fs::remove_dir_all(rel);
let err = ensure_deploy_key(rel, uid, gid)
.expect_err("a relative service home must be refused");
assert!(format!("{err:#}").contains("RELATIVE"), "{err:#}");
assert!(!rel.exists(), "a refused mint must not create anything in the CWD");
}
/// A supplementary gid this process is a member of (so an unprivileged chown to
/// it is permitted), or `None` when it belongs to just the one group.
#[cfg(unix)]
fn alternate_gid(own: u32) -> Option<u32> {
std::fs::read_to_string("/proc/self/status")
.ok()?
.lines()
.find_map(|l| l.strip_prefix("Groups:"))?
.split_whitespace()
.filter_map(|g| g.parse::<u32>().ok())
.find(|g| *g != own)
}
// The current owner's ids, read (no `libc` dev-dep) from a dir this process
// owns — chowning the test key to ourselves is a harmless no-op that still
// exercises the chown path without needing root.
#[cfg(unix)]
fn current_ids() -> (u32, u32) {
use std::os::unix::fs::MetadataExt;
let m = std::fs::metadata(std::env::temp_dir()).unwrap();
(m.uid(), m.gid())
}
#[cfg(not(unix))]
fn current_ids() -> (u32, u32) {
(0, 0)
}
}
// ─── functional-status emit wiring (feature ON only) ─────────────────────────
// Mirrors `selftest::wiring`: drive the REAL key-gen, emit an honest row per
// surface, then drain the process-global buffer and assert the rows landed —
// proving the emit bridge is wired for the sshkey leaf too.
#[cfg(all(test, feature = "testmatrix"))]
mod wiring {
use super::*;
#[test]
fn emits_honest_rows_for_deploy_key_gen() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home").join("dwarves");
std::fs::create_dir_all(&home).unwrap();
let key = ensure_deploy_key(&home, 0, 0).unwrap();
crate::selftest::emit(
"nornir-git",
"ensure_deploy_key_mints",
key.created && key.pubkey.starts_with("ssh-ed25519 "),
&format!("pub={}", key.pubkey),
);
let priv_txt = std::fs::read_to_string(&key.private_path).unwrap();
let parsed = ssh_key::PrivateKey::from_openssh(&priv_txt);
let is_ed25519 = parsed
.as_ref()
.map(|k| k.algorithm() == ssh_key::Algorithm::Ed25519)
.unwrap_or(false);
crate::assert_emit!(
"nornir-git",
"deploy_key_is_valid_ed25519",
is_ed25519,
"algorithm={:?}",
parsed.as_ref().map(|k| k.algorithm()).ok()
);
let again = ensure_deploy_key(&home, 0, 0).unwrap();
crate::assert_emit!(
"nornir-git",
"deploy_key_idempotent",
!again.created && again.pubkey == key.pubkey,
"second ensure preserved the key"
);
let rows = nornir_testmatrix::drain_functional_rows();
for want in ["ensure_deploy_key_mints", "deploy_key_is_valid_ed25519", "deploy_key_idempotent"] {
assert!(
rows.iter().any(|r| r.suite == "nornir-git" && r.test_name == want && r.status == "pass"),
"missing green row {want}: {rows:?}"
);
}
}
}