git-ents.gitmain
⌘K
foforge
commit 956986e
feat: gate pushes on a signed-push certificate from an authorized signer

Add a git-ents-server pre-receive subcommand for use as a git hook. With no signers in refs/meta/auth it stays open so the trust list can be bootstrapped; once any signer is listed it requires git push --signed, an accepted nonce, and an SSH signature that ssh-keygen -Y verify trusts against the authorized keys. The wildcard principal means authentication is key-set membership, not a key-to-identity binding.

feat: add pre-receive subcommand verifying signed pushes against the signer set feat: depend on the git-ents crate for the authorized-signer set test: cover accept, unknown-key, unsigned, and bootstrap push paths end to end Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -823,6 +823,7 @@ "axum", "clap", "clap_mangen", + "git-ents", "gix-actor", "gix-date", "gix-hash",
crates/git-ents-server/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] +git-ents = { path = "../git-ents" } acdc-parser = { workspace = true } acdc-converters-core = { workspace = true } acdc-converters-html = { workspace = true }
crates/git-ents-server/src/main.rs @@ -2,6 +2,7 @@ mod asciidoc; mod http; +mod verify; mod web; use std::net::SocketAddr; @@ -13,7 +14,7 @@ use axum::Router; use axum::extract::DefaultBodyLimit; use axum::routing::get; -use clap::{CommandFactory, Parser}; +use clap::{CommandFactory, Parser, Subcommand}; use tokio::sync::{Mutex, Notify}; #[derive(Parser)] @@ -22,6 +23,9 @@ about = "Helpful guardians of your git trees." )] struct Args { + #[command(subcommand)] + command: Option<Command>, + /// Generate man pages into the given directory. #[arg(long, value_name = "DIR")] generate_man: Option<PathBuf>, @@ -39,6 +43,14 @@ max_requests: Option<usize>, } +/// Subcommands that run instead of serving HTTP. +#[derive(Subcommand)] +enum Command { + /// Verify a signed push against the authorized signers (a git `pre-receive` + /// hook). + PreReceive, +} + /// Shared handler state: where the bare repositories live, plus a lock that /// serializes repository creation so concurrent first pushes cannot race. #[derive(Clone)] @@ -50,6 +62,16 @@ fn main() -> ExitCode { let args = Args::parse(); + if let Some(Command::PreReceive) = args.command { + return match verify::pre_receive() { + Ok(()) => ExitCode::SUCCESS, + Err(reason) => { + eprintln!("error: {reason}"); + ExitCode::FAILURE + } + }; + } + if let Some(dir) = args.generate_man { let cmd = Args::command(); if let Err(e) = clap_mangen::generate_to(cmd, dir) {
crates/git-ents-server/src/verify.rs @@ -1,0 +1,146 @@ +//! The `pre-receive` verifier: a git hook that gates pushes on a signature from +//! an authorized signer. +//! +//! When the trust list at `refs/meta/auth` is empty the server is still in its +//! open bootstrap window and every push is allowed, so the first signer can be +//! pushed in. Once any signer is listed, a push must carry a signed-push +//! certificate (`git push --signed`) whose anti-replay nonce git accepted and +//! whose signature verifies against one of those keys. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use git_ents::signers::{self, Signer}; + +/// Verify the push git is about to apply, returning `Ok(())` to accept it or +/// `Err(reason)` to reject it. The push certificate is read from the +/// environment git populates for the hook. +pub fn pre_receive() -> Result<(), String> { + let repo = std::env::current_dir().map_err(|e| format!("cannot resolve repository: {e}"))?; + let authorized = signers::load(&repo); + if authorized.is_empty() { + // No trust list pushed yet: stay open so the first signer can be added. + return Ok(()); + } + + let cert_oid = env("GIT_PUSH_CERT") + .filter(|oid| !oid.is_empty()) + .ok_or_else(|| { + "this repository requires a signed push: rerun with `git push --signed`".to_owned() + })?; + if env("GIT_PUSH_CERT_NONCE_STATUS").as_deref() != Some("OK") { + return Err("push certificate nonce was missing or stale".to_owned()); + } + + let certificate = cat_blob(&repo, &cert_oid)?; + verify_certificate(&authorized, &certificate) +} + +/// Split the certificate into its signed payload and SSH signature, then accept +/// it only when `ssh-keygen -Y verify` trusts the signature against one of the +/// authorized keys. +fn verify_certificate(authorized: &[Signer], certificate: &str) -> Result<(), String> { + const MARKER: &str = "-----BEGIN SSH SIGNATURE-----"; + let split = certificate + .find(MARKER) + .ok_or_else(|| "push certificate carries no SSH signature".to_owned())?; + let (payload, signature) = certificate.split_at(split); + let principal = signer_principal(certificate); + + let workdir = TempDir::new()?; + let allowed_path = workdir.path().join("allowed_signers"); + let signature_path = workdir.path().join("cert.sig"); + write_file( + &allowed_path, + signers::allowed_signers(authorized).as_bytes(), + )?; + write_file(&signature_path, signature.as_bytes())?; + + let mut child = Command::new("ssh-keygen") + .args(["-Y", "verify", "-n", "git", "-I", principal, "-f"]) + .arg(&allowed_path) + .arg("-s") + .arg(&signature_path) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("could not run ssh-keygen: {e}"))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(payload.as_bytes()) + .map_err(|e| format!("could not hand the certificate to ssh-keygen: {e}"))?; + } + let status = child + .wait() + .map_err(|e| format!("ssh-keygen did not complete: {e}"))?; + if status.success() { + Ok(()) + } else { + Err("push is not signed by an authorized key".to_owned()) + } +} + +/// The pusher's email from the certificate's `pusher` line, used as the +/// `ssh-keygen` principal. The authorized set uses a wildcard principal, so any +/// non-empty identity matches; `git` is a harmless fallback. +fn signer_principal(certificate: &str) -> &str { + certificate + .lines() + .find_map(|line| line.strip_prefix("pusher ")) + .and_then(|rest| rest.split_once('<')) + .and_then(|(_, rest)| rest.split_once('>')) + .map(|(email, _)| email) + .unwrap_or("git") +} + +fn env(key: &str) -> Option<String> { + std::env::var(key).ok() +} + +/// Read the blob `oid` from `repo` as text. +fn cat_blob(repo: &Path, oid: &str) -> Result<String, String> { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["cat-file", "blob", oid]) + .output() + .map_err(|e| format!("could not read push certificate: {e}"))?; + if !output.status.success() { + return Err("could not read the push certificate from the object store".to_owned()); + } + String::from_utf8(output.stdout) + .map_err(|_invalid| "push certificate is not valid UTF-8".to_owned()) +} + +fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> { + std::fs::write(path, bytes).map_err(|e| format!("could not write {}: {e}", path.display())) +} + +/// A uniquely named temporary directory removed when dropped, holding the short +/// files `ssh-keygen` needs to read from disk. +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Result<Self, String> { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("git-ents-verify-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(|e| format!("could not create temp dir: {e}"))?; + Ok(Self(dir)) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + match std::fs::remove_dir_all(&self.0) { + Ok(()) | Err(_) => {} + } + } +}
crates/git-ents-server/tests/pre_receive.rs @@ -1,0 +1,212 @@ +#![allow( + missing_docs, + clippy::unwrap_used, + clippy::panic, + clippy::arithmetic_side_effects, + clippy::unused_result_ok, + reason = "integration test binary" +)] + +//! End-to-end coverage for the `pre-receive` signed-push verifier: a real +//! `git push --signed` over the `file://` transport against a bare repo whose +//! hook is the compiled `git-ents-server pre-receive` subcommand. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const BIN: &str = env!("CARGO_BIN_EXE_git-ents-server"); + +/// Run a command and assert it succeeds, returning trimmed stdout. +fn ok(dir: &Path, program: &str, args: &[&str]) -> String { + let output = git_env(dir, program, args) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{program} {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() +} + +/// Build a command in `dir` with a fixed committer/pusher identity. +fn git_env(dir: &Path, program: &str, args: &[&str]) -> Command { + let mut command = Command::new(program); + command + .current_dir(dir) + .args(args) + .env("GIT_AUTHOR_NAME", "Tester") + .env("GIT_AUTHOR_EMAIL", "tester@example.com") + .env("GIT_COMMITTER_NAME", "Tester") + .env("GIT_COMMITTER_EMAIL", "tester@example.com"); + command +} + +fn unique_dir(tag: &str) -> PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = + std::env::temp_dir().join(format!("git-ents-prerecv-{tag}-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Generate an ed25519 keypair at `base/<name>`, returning the public key path. +fn keygen(base: &Path, name: &str) -> PathBuf { + let key = base.join(name); + let status = Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-C", name, "-f"]) + .arg(&key) + .status() + .unwrap(); + assert!(status.success(), "ssh-keygen failed"); + base.join(format!("{name}.pub")) +} + +/// Create a bare server repo wired to the `pre-receive` verifier, listing the +/// public keys at `authorized` as signers. +fn server_repo(base: &Path, authorized: &[&Path]) -> PathBuf { + let repo = base.join("srv.git"); + ok( + base, + "git", + &["init", "--bare", "-q", repo.to_str().unwrap()], + ); + ok( + &repo, + "git", + &["config", "receive.certNonceSeed", "test-seed"], + ); + + let hook = repo.join("hooks").join("pre-receive"); + std::fs::write(&hook, format!("#!/bin/sh\nexec \"{BIN}\" pre-receive\n")).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + if !authorized.is_empty() { + let mut tree_entries = String::new(); + for (index, pubkey) in authorized.iter().enumerate() { + let key = std::fs::read_to_string(pubkey).unwrap(); + let oid = hash_object(&repo, key.as_bytes()); + tree_entries.push_str(&format!("100644 blob {oid}\tkey-{index}\n")); + } + let signers_tree = mktree(&repo, &tree_entries); + let root_tree = mktree(&repo, &format!("040000 tree {signers_tree}\tsigners\n")); + let commit = ok(&repo, "git", &["commit-tree", &root_tree, "-m", "auth"]); + ok(&repo, "git", &["update-ref", "refs/meta/auth", &commit]); + } + repo +} + +fn hash_object(repo: &Path, bytes: &[u8]) -> String { + pipe(repo, &["hash-object", "-w", "--stdin"], bytes) +} + +fn mktree(repo: &Path, spec: &str) -> String { + pipe(repo, &["mktree"], spec.as_bytes()) +} + +/// Run `git <args>` in `repo`, feeding `input` on stdin, returning trimmed stdout. +fn pipe(repo: &Path, args: &[&str], input: &[u8]) -> String { + use std::io::Write; + let mut child = git_env(repo, "git", args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "git {args:?} failed"); + String::from_utf8_lossy(&output.stdout).trim().to_owned() +} + +/// Create a work repo with one commit, signing with `signing_key` if given. +fn work_repo(base: &Path, signing_key: Option<&Path>) -> PathBuf { + let repo = base.join("work"); + std::fs::create_dir_all(&repo).unwrap(); + ok(&repo, "git", &["init", "-q", "-b", "main"]); + if let Some(key) = signing_key { + ok(&repo, "git", &["config", "gpg.format", "ssh"]); + ok( + &repo, + "git", + &["config", "user.signingkey", key.to_str().unwrap()], + ); + } + std::fs::write(repo.join("file.txt"), "hello\n").unwrap(); + ok(&repo, "git", &["add", "file.txt"]); + ok(&repo, "git", &["commit", "-q", "-m", "initial"]); + repo +} + +/// Attempt a push, returning whether it succeeded. +fn push(work: &Path, server: &Path, signed: bool) -> bool { + let url = format!("file://{}", server.display()); + let mut args = vec!["push"]; + if signed { + args.push("--signed"); + } + args.extend_from_slice(&[url.as_str(), "main:refs/heads/main"]); + git_env(work, "git", &args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success() +} + +#[test] +fn accepts_a_push_signed_by_an_authorized_key() { + let base = unique_dir("accept"); + let pubkey = keygen(&base, "id"); + let server = server_repo(&base, &[&pubkey]); + let work = work_repo(&base, Some(&pubkey)); + + assert!( + push(&work, &server, true), + "authorized signed push was rejected" + ); + std::fs::remove_dir_all(&base).ok(); +} + +#[test] +fn rejects_a_push_signed_by_an_unknown_key() { + let base = unique_dir("unknown"); + let authorized = keygen(&base, "authorized"); + let intruder = keygen(&base, "intruder"); + let server = server_repo(&base, &[&authorized]); + let work = work_repo(&base, Some(&intruder)); + + assert!( + !push(&work, &server, true), + "push by an unknown key was accepted" + ); + std::fs::remove_dir_all(&base).ok(); +} + +#[test] +fn rejects_an_unsigned_push_when_signers_exist() { + let base = unique_dir("unsigned"); + let pubkey = keygen(&base, "id"); + let server = server_repo(&base, &[&pubkey]); + let work = work_repo(&base, None); + + assert!(!push(&work, &server, false), "unsigned push was accepted"); + std::fs::remove_dir_all(&base).ok(); +} + +#[test] +fn accepts_any_push_before_signers_are_configured() { + let base = unique_dir("bootstrap"); + let server = server_repo(&base, &[]); + let work = work_repo(&base, None); + + assert!(push(&work, &server, false), "bootstrap push was rejected"); + std::fs::remove_dir_all(&base).ok(); +}