feat: gate pushes on a signed-push certificate from an authorized signer
commit
956986efeat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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();
+}