feat: require signed pushes on served repos when a nonce seed is set
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
No reviews of this commit yet — record a verdict below.
Start a review
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