git-ents.gitmain
⌘K
foforge
commit 59566fb
feat: require signed pushes on served repos when a nonce seed is set

Inject receive.certNonceSeed and core.hooksPath through GIT_CONFIG_* onto the http-backend spawn so they reach the receive-pack and pre-receive processes it runs. With CERT_NONCE_SEED unset the server stays open; once set, every repo demands a signed push the bundled pre-receive verifier checks. Confirmed end to end over HTTP: unsigned pushes are refused once signers exist, signed pushes by an authorized key pass.

feat: add --cert-nonce-seed/CERT_NONCE_SEED and --hooks-dir/GIT_ENTS_HOOKS_DIR feat: inject receive.certNonceSeed and core.hooksPath into the git backend feat: ship a pre-receive hook that runs the verifier build: install openssh-client and bundle hooks/ in the runtime image 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

crates/git-ents-server/Dockerfile @@ -16,8 +16,12 @@ # We do not need the Rust toolchain to run the binary! FROM debian:bookworm-slim AS runtime WORKDIR /app +# openssh-client provides the `ssh-keygen -Y verify` the pre-receive hook runs. RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ + && apt-get install -y --no-install-recommends git ca-certificates openssh-client \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/release/git-ents-server /usr/local/bin +COPY hooks /app/hooks +RUN chmod +x /app/hooks/pre-receive +ENV GIT_ENTS_HOOKS_DIR=/app/hooks ENTRYPOINT ["/usr/local/bin/git-ents-server"]
crates/git-ents-server/src/http.rs @@ -88,6 +88,18 @@ cmd.env("CONTENT_LENGTH", value); } + // Push these through `GIT_CONFIG_*` rather than `git -c` so they reach the + // `receive-pack` and `pre-receive` processes http-backend spawns, where the + // nonce and hook actually take effect. + let overrides = backend_config(&state); + if !overrides.is_empty() { + cmd.env("GIT_CONFIG_COUNT", overrides.len().to_string()); + for (index, (key, value)) in overrides.iter().enumerate() { + cmd.env(format!("GIT_CONFIG_KEY_{index}"), key); + cmd.env(format!("GIT_CONFIG_VALUE_{index}"), value); + } + } + let mut child = match cmd.spawn() { Ok(child) => child, Err(e) => { @@ -140,6 +152,20 @@ build_response(&stdout) } +/// The `git` config overrides applied to every backend invocation. Empty until +/// push authentication is wired: a seed enables the signed-push nonce, and the +/// hooks directory points the backend at the `pre-receive` verifier. +fn backend_config(state: &AppState) -> Vec<(&'static str, &str)> { + let mut overrides = Vec::new(); + if let Some(seed) = state.cert_nonce_seed.as_deref() { + overrides.push(("receive.certNonceSeed", seed)); + } + if let Some(hooks) = state.hooks_dir.as_deref().and_then(Path::to_str) { + overrides.push(("core.hooksPath", hooks)); + } + overrides +} + /// Translate a CGI response (header block, blank line, body) into HTTP. fn build_response(stdout: &[u8]) -> Response { let (header_block, body) = match find_subsequence(stdout, CGI_HEADER_SEP) { @@ -473,6 +499,31 @@ assert_eq!(valid_segment(segment), expected); } + fn state(cert_nonce_seed: Option<&str>, hooks_dir: Option<&str>) -> AppState { + AppState { + data_dir: PathBuf::from("/data"), + init_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())), + cert_nonce_seed: cert_nonce_seed.map(str::to_owned), + hooks_dir: hooks_dir.map(PathBuf::from), + } + } + + #[test] + fn backend_config_is_empty_without_authentication() { + assert!(backend_config(&state(None, None)).is_empty()); + } + + #[test] + fn backend_config_injects_nonce_seed_and_hooks_path() { + assert_eq!( + backend_config(&state(Some("seed"), Some("/app/hooks"))), + vec![ + ("receive.certNonceSeed", "seed"), + ("core.hooksPath", "/app/hooks"), + ] + ); + } + #[rstest] #[case("/repo.git/git-receive-pack", Some("repo.git"))] #[case("/org/repo.git/git-receive-pack", Some("org/repo.git"))]
crates/git-ents-server/src/main.rs @@ -38,6 +38,15 @@ #[arg(long, env = "GIT_PROJECT_ROOT", default_value = "/data/repos")] data_dir: PathBuf, + /// Secret seed for signed-push nonces. Setting it requires pushes to carry + /// a signed-push certificate, enabling authentication against the signers. + #[arg(long, env = "CERT_NONCE_SEED")] + cert_nonce_seed: Option<String>, + + /// Directory of git hooks (a `pre-receive`) applied to every served repo. + #[arg(long, env = "GIT_ENTS_HOOKS_DIR")] + hooks_dir: Option<PathBuf>, + /// Stop after handling this many requests. #[arg(long)] max_requests: Option<usize>, @@ -57,6 +66,12 @@ pub(crate) struct AppState { pub(crate) data_dir: PathBuf, pub(crate) init_lock: Arc<Mutex<()>>, + /// When set, injected as `receive.certNonceSeed` so the backend demands a + /// signed-push certificate the `pre-receive` hook can verify. + pub(crate) cert_nonce_seed: Option<String>, + /// When set, injected as `core.hooksPath` so every served repo runs the + /// bundled `pre-receive` verifier. + pub(crate) hooks_dir: Option<PathBuf>, } fn main() -> ExitCode { @@ -96,6 +111,8 @@ let state = AppState { data_dir: args.data_dir, init_lock: Arc::new(Mutex::new(())), + cert_nonce_seed: args.cert_nonce_seed, + hooks_dir: args.hooks_dir, }; // The git smart-HTTP protocol streams whole packfiles through the request
hooks/pre-receive @@ -1,0 +1,4 @@ +#!/bin/sh +# Gate pushes on a signed-push certificate from an authorized signer. Installed +# on every served repository via `core.hooksPath`; see `verify.rs`. +exec git-ents-server pre-receive