git-ents.gitmain
⌘K
foforge
commit 094e4d2
setup, hook: restore signed-push support on the hosted root

receive.certNonceSeed was never wired back up after the redo, so receive-pack never advertised push-cert and git push --signed failed outright against git.ents.cloud. git ents setup --hosted now sets receive.certNonceSeed/certNonceSlop (idempotently, so a redeploy reuses the same seed), and the pre-receive hook verifies the resulting certificate against enrolled, active members once any member is enrolled — mirroring gate.bootstrap’s open window for the very first, unsigned push. `git ents bootstrap’s own pushes now sign under --signed=if-asked`, with local signing config set to match --key rather than relying on ambient global config.

Joseph D. Carpinelli · 29 days ago

Reviews

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

Start a review

verdict

crates/cli/git-ents/src/hook.rs @@ -85,15 +85,17 @@ )] use std::io::{BufRead, Read, Write}; +use std::path::Path; use ents_effect::run::run_one; -use ents_model::Effect; +use ents_model::{Effect, MemberState}; use ents_query::Query; use ents_receive::Mode; use gix::refs::FullName; use gix_hash::ObjectId; use gix_object::{CommitRef, Find, Kind}; use gix_ref_store::RefStoreRead; +use ssh_key::{PublicKey, SshSig}; use crate::error::{Error, Result}; use crate::root::HostedRoot; @@ -144,16 +146,24 @@ } /// Run as git's own `pre-receive` hook (see this module's own doc for the -/// design). Reads transitions from `input`, evaluates the gate against -/// each, and refuses the whole push (returns `Err`) if any fails under the -/// mandatory gate. Rejection reasons are written to `report`. +/// design). Reads transitions from `input`, requires a verified +/// signed-push certificate once any member is enrolled +/// ([`verify_push_certificate`]), evaluates the gate against each +/// transition, and refuses the whole push (returns `Err`) if any check +/// fails under the mandatory gate. Rejection reasons are written to +/// `report`. /// /// # Errors /// -/// [`Error::Refused`] if any transition's verdict fails; propagates a -/// parse or gate-evaluation failure otherwise. +/// [`Error::Refused`] if the push certificate is missing, stale, or +/// unverifiable, or if any transition's verdict fails; propagates a parse +/// or gate-evaluation failure otherwise. pub fn pre_receive(root: &HostedRoot, input: impl BufRead, mut report: impl Write) -> Result<()> { let transitions = parse_stdin_transitions(input)?; + if let Err(error) = verify_push_certificate(root) { + let _ = writeln!(report, "refused: {error}"); + return Err(error); + } let mut failures = Vec::new(); for transition in &transitions { let verdict = ents_gate::verify( @@ -176,6 +186,97 @@ } } +/// Require the push git is about to apply to carry a valid signed-push +/// certificate from an enrolled, active member, once any member is +/// enrolled — the transport-authentication counterpart of +/// `gate.bootstrap`'s own open window: before any member exists (a fresh +/// hosted root), every push, including the one that enrolls the first +/// member, is allowed unsigned, so bootstrapping is possible at all. +/// +/// A push certificate carries no meta-ref semantics and is never +/// consulted by `ents_gate::verify` (`gate.signature-artifact`); this is +/// the one place in the hosted root that reads one, and only to answer +/// "did an authorized member make this connection", not to decide +/// anything the gate itself decides from repository state. +fn verify_push_certificate(root: &HostedRoot) -> Result<()> { + let active: Vec<_> = crate::commands::members::list(&root.refs, &root.objects)? + .into_iter() + .map(|(_, member)| member) + .filter(|member| member.state == MemberState::Active) + .collect(); + if active.is_empty() { + return Ok(()); + } + let cert_oid = std::env::var("GIT_PUSH_CERT") + .ok() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + Error::Refused( + "this repository requires a signed push: rerun with `git push --signed`" + .to_owned(), + ) + })?; + if std::env::var("GIT_PUSH_CERT_NONCE_STATUS").ok().as_deref() != Some("OK") { + return Err(Error::Refused( + "push certificate nonce was missing or stale".to_owned(), + )); + } + let certificate = cat_blob(&root.path, &cert_oid)?; + if active + .iter() + .any(|member| certificate_verifies(&member.key, &certificate)) + { + Ok(()) + } else { + Err(Error::Refused( + "push is not signed by an authorized key".to_owned(), + )) + } +} + +/// Whether `certificate` (git's raw push-cert text, as recorded in the +/// blob `GIT_PUSH_CERT` names) carries a valid SSH signature over its own +/// signed payload, verified against `key` (an OpenSSH public key line) — +/// the transport-authentication counterpart of `ents_gate::signature`'s +/// identical commit-signature check, including its "git" SSHSIG +/// namespace (the same one git signs push certificates under). +fn certificate_verifies(key: &str, certificate: &str) -> bool { + const MARKER: &str = "-----BEGIN SSH SIGNATURE-----"; + const NAMESPACE: &str = "git"; + let Some(split) = certificate.find(MARKER) else { + return false; + }; + let (payload, signature) = certificate.split_at(split); + let Ok(key) = PublicKey::from_openssh(key) else { + return false; + }; + let Ok(sig) = SshSig::from_pem(signature) else { + return false; + }; + key.verify(NAMESPACE, payload.as_bytes(), &sig).is_ok() +} + +/// Read blob `oid` from `repo_path` as text: `GIT_PUSH_CERT` names the +/// object holding the raw certificate, not the certificate bytes +/// directly. +fn cat_blob(repo_path: &Path, oid: &str) -> Result<String> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["cat-file", "blob", oid]) + .output() + .map_err(|source| Error::Io { + path: repo_path.to_owned(), + source, + })?; + if !output.status.success() { + return Err(Error::Refused(format!( + "could not read push certificate blob {oid}" + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + /// Run as git's own `post-receive` hook: reconcile outstanding effect /// obligations and run every one of them via `executor`, writing each /// result back through the ordinary `receive` path
crates/cli/git-ents/tests/hosted_root.rs @@ -117,6 +117,9 @@ let key = common::write_key_in(clone_dir.path(), 21); build_member_commit(clone_dir.path(), &key, "jdc"); + // The very first push, before any member is enrolled, is admitted + // unsigned — the bootstrap window (`gate.bootstrap`)'s transport + // counterpart: nobody is enrolled yet to have signed it as. let push = git( clone_dir.path(), &["push", "origin", "refs/meta/member/jdc"], @@ -247,7 +250,13 @@ ) .expect("evaluates"); git_ents::mutate::outcome_to_result(outcome, None).expect("admin may set the epoch"); - let push = git(admin_clone.path(), &["push", "origin", "refs/meta/config"]); + // Admin is now an enrolled, active member, so this push must itself + // carry a valid signed-push certificate under the admin's key. + common::configure_signing(admin_clone.path(), &admin_key); + let push = git( + admin_clone.path(), + &["push", "--signed=if-asked", "origin", "refs/meta/config"], + ); assert!(push.status.success(), "{push:?}"); // Now a second, unenrolled signer tries to enroll a member directly — @@ -270,9 +279,20 @@ assert!(fetch.status.success(), "{fetch:?}"); build_member_commit(outsider_clone.path(), &outsider_key, "mallory"); + // Admin is already enrolled by this point, so the hosted root now + // requires every push to be signed; sign this one too, so the + // rejection below demonstrates the mandatory gate refusing an + // unauthorized (if honestly identified) signer, not merely an + // unsigned push. + common::configure_signing(outsider_clone.path(), &outsider_key); let push = git( outsider_clone.path(), - &["push", "origin", "refs/meta/member/mallory"], + &[ + "push", + "--signed=if-asked", + "origin", + "refs/meta/member/mallory", + ], ); assert!( !push.status.success(),
crates/cli/git-ents/src/commands/bootstrap.rs @@ -54,6 +54,10 @@ discovered } }; + // `push`'s `--signed=if-asked` needs git's own signing config to + // match the key this command signs the enrollment commits with — + // ambient global config may point at a different key, or none. + configure_push_signing(root, key.as_deref())?; members::add(root, username, None, key.clone())?; push(root, remote, username)?; let _ = writeln!(out, "enrolled {username} (self-admitting first push)"); @@ -66,14 +70,53 @@ Ok(()) } +/// Set `root`'s own `user.signingkey`/`gpg.format=ssh` to the key this +/// command signs enrollment commits with, so [`push`]'s `--signed=if-asked` +/// produces a push certificate under the same key rather than whatever +/// (or nothing) the ambient global config names. +fn configure_push_signing(root: &LocalRoot, key: Option<&std::path::Path>) -> Result<()> { + let repo = gix::open(&root.path)?; + let resolved = crate::sign::resolve_key_path(&repo, key)?; + for (name, value) in [ + ("user.signingkey", resolved.to_string_lossy().into_owned()), + ("gpg.format", "ssh".to_owned()), + ] { + let output = Command::new("git") + .arg("-C") + .arg(&root.path) + .args(["config", "--local", name, &value]) + .output() + .map_err(|source| Error::Io { + path: root.path.clone(), + source, + })?; + if !output.status.success() { + return Err(Error::Io { + path: root.path.clone(), + source: std::io::Error::other(format!( + "git config --local {name} {value} failed: {}", + String::from_utf8_lossy(&output.stderr) + )), + }); + } + } + Ok(()) +} + /// Push `username`'s member ref to `remote` via a real `git push`, so the /// remote's own hooks gate the enrollment exactly as any other push. +/// +/// `--signed=if-asked` signs the push whenever `remote` advertises +/// `push-cert` (the single-node hosted root does, once `git ents setup +/// --hosted` has run) and pushes unsigned otherwise — an operator's own +/// unenrolled key has nothing to sign *as* yet on the very first push of +/// all, so this cannot unconditionally require `--signed`. fn push(root: &LocalRoot, remote: &str, username: &str) -> Result<()> { let refspec = format!("refs/meta/member/{username}"); let output = Command::new("git") .arg("-C") .arg(&root.path) - .args(["push", remote, &refspec]) + .args(["push", "--signed=if-asked", remote, &refspec]) .output() .map_err(|source| Error::Io { path: root.path.clone(),
crates/cli/git-ents/src/commands/setup.rs @@ -20,7 +20,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use rand_core::OsRng; +use rand_core::{OsRng, RngCore as _}; use ssh_key::{Algorithm, LineEnding, PrivateKey}; use crate::error::{Error, Result}; @@ -53,9 +53,10 @@ /// Run `git ents setup --hosted` against the bare repository at `path`: /// resolve or generate a signing key for the hosted worker (recorded as -/// `path`'s own `user.signingkey`/`gpg.format=ssh`, same as [`run`]), and +/// `path`'s own `user.signingkey`/`gpg.format=ssh`, same as [`run`]), /// install this binary's `hook pre-receive`/`hook post-receive` as -/// `path`'s own git hooks (`roots.single-node-hosted`). +/// `path`'s own git hooks (`roots.single-node-hosted`), and require every +/// push to carry a verifiable signed-push certificate (below). /// /// `receive.denyCurrentBranch=updateInstead` is deliberately not set here: /// it is the local-root, checked-out-worktree edge case @@ -79,9 +80,42 @@ } write_pubkey(&resolved)?; install_hooks(path)?; + configure_signed_push(path)?; Ok(resolved) } +/// Make `receive-pack` advertise and require the `push-cert` capability: +/// without `receive.certNonceSeed` set, stock git never advertises it at +/// all, so `git push --signed` fails with "the receiving end does not +/// support --signed push" regardless of what `hook pre_receive` goes on +/// to verify. The seed itself only needs to stay stable across the two +/// requests one push makes (the capability advertisement and the push +/// itself) — not across pushes — but is generated once and reused on +/// every later boot anyway, so an in-flight push spanning a restart still +/// verifies. `certNonceSlop` tolerates the two requests landing in +/// different seconds. +fn configure_signed_push(path: &Path) -> Result<()> { + let seed = match get_local_config(path, "receive.certNonceSeed")? { + Some(existing) => existing, + None => generate_nonce_seed(), + }; + for (key, value) in [ + ("receive.certNonceSeed", seed.as_str()), + ("receive.certNonceSlop", "60"), + ] { + set_local_config(path, key, value)?; + } + Ok(()) +} + +/// 32 random bytes, hex-encoded — plenty of entropy for a nonce-signing +/// secret that never leaves this repository's local config. +fn generate_nonce_seed() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + /// Write the key's public half to `<key>.pub` — the front proxy publishes /// it at a well-known path so `git ents bootstrap` can discover the server /// identity to vouch for (`roots.web-signing`) without the operator @@ -192,6 +226,27 @@ Ok(()) } +/// Read `key` from `repo_path`'s own local config, or `None` if it is +/// unset — used to make [`configure_signed_push`] idempotent across boots +/// rather than mint a fresh nonce seed (and so a fresh push-cert +/// namespace) every deploy. +fn get_local_config(repo_path: &Path, key: &str) -> Result<Option<String>> { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["config", "--local", "--get", key]) + .output() + .map_err(|source| Error::Io { + path: repo_path.to_owned(), + source, + })?; + if !output.status.success() { + return Ok(None); + } + let value = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + Ok((!value.is_empty()).then_some(value)) +} + /// Generate a fresh, unencrypted ed25519 key at `path` (creating parent /// directories as needed) and return `path` unchanged. fn generate_key(path: &Path) -> Result<PathBuf> {
crates/cli/git-ents/tests/common/mod.rs @@ -10,6 +10,7 @@ #![allow(clippy::expect_used, reason = "integration test")] use std::path::{Path, PathBuf}; +use std::process::Command; use ssh_key::private::{Ed25519Keypair, KeypairData}; use ssh_key::{LineEnding, PrivateKey}; @@ -67,6 +68,27 @@ .expect("write key"); } +/// Configure `dir`'s own local git config to sign with `key` +/// (`user.signingkey` + `gpg.format=ssh`) — what a real operator's +/// `git ents setup` does for a clone, needed here so `git push +/// --signed=if-asked` against the hosted root (which now always +/// advertises `push-cert`) actually produces a certificate instead of +/// silently pushing unsigned. +pub fn configure_signing(dir: &Path, key: &Path) { + for (name, value) in [ + ("user.signingkey", key.to_str().expect("utf8 path")), + ("gpg.format", "ssh"), + ] { + let output = Command::new("git") + .arg("-C") + .arg(dir) + .args(["config", "--local", name, value]) + .output() + .expect("git runs"); + assert!(output.status.success(), "{output:?}"); + } +} + /// The path to the built `git-ents` binary under test — `cargo test` /// exposes this via `CARGO_BIN_EXE_<name>`. pub fn bin_path() -> PathBuf {