setup, hook: restore signed-push support on the hosted root
commit
094e4d2setup, 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.
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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 {