git-ents.gitmain
⌘K
foforge
commit 1d6b8f7
feat: parse push certificates off the wire in the native receive-pack

The /_native/ receive-pack endpoint now implements the push-cert section of the pack protocol, closing the gap that limited native pushes to the bootstrap window: the receive-pack advertisement carries a push-cert=<nonce> capability, and a signing client answers with the push-cert pkt-line block in place of the plain command list — certificate header echoing the nonce, the authoritative command lines, and the SSH signature — which is reassembled into the exact payload the client signed and handed to IngestPack as the PushRequest certificate. The nonce is session-scoped anti-replay, never durable state: a keyed hash of a per-process secret, the repository, and a timestamp, verified by recomputation within a slop window before anything is staged. A stock git push --signed (the push.gpgSign mechanics) now lands end-to-end with uniform-strong attestation enforced at the wire, and without a configured server signing key the endpoint fails closed rather than accepting a push it cannot record.

test: extend the native-protocol integration test with a real signed push from an enrolled member (accepted, op record chained with the certificate embedded by OID) and an unsigned push rejected once enrolled Assisted-by: Claude:claude-sonnet-5

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/src/native_git.rs @@ -10,21 +10,29 @@ //! backend the plan describes as WS0, shipped first and load-bearing (hooks, //! the checks queue, signed-push nonces); replacing it outright to satisfy //! WS3 would be the larger, riskier change for no correctness gain over -//! mounting the native path beside it. This module is that native path: -//! `GET .../info/refs` and `POST .../git-upload-pack` are fully wired -//! end-to-end (a stock `git clone`/`fetch` works against them with zero -//! client configuration). `POST .../git-receive-pack` ingests real pushes -//! through [`git_protocol::IngestPack`] — the same staged-then-atomic-then- -//! promoted ordering and attestation check the unit tests in -//! `git-protocol` exercise directly — but does not yet parse a push -//! certificate off the wire (see `push_cert` below), so a push only -//! succeeds during a repository's bootstrap window. Wiring real -//! `git push --signed` end-to-end over this endpoint is follow-on work; -//! `IngestPack::receive` itself already enforces attestation regardless of -//! transport. +//! mounting the native path beside it. This module is that native path, +//! wired end-to-end: `GET .../info/refs` and `POST .../git-upload-pack` +//! serve a stock `git clone`/`fetch` with zero client configuration, and +//! `POST .../git-receive-pack` ingests real pushes — including a stock +//! `git push --signed` (`push.gpgSign`) — through +//! [`git_protocol::IngestPack`], the same staged-then-atomic-then-promoted +//! ordering and attestation check the unit tests in `git-protocol` +//! exercise directly. +//! +//! The push-certificate wire protocol: the receive-pack advertisement +//! carries a `push-cert=<nonce>` capability; a signing client answers with +//! a `push-cert` pkt-line block in place of the plain command list — +//! certificate header (echoing the nonce), the commands themselves, and an +//! SSH signature — which [`parse_receive_request`] reassembles into the +//! exact payload the client signed. The nonce is session-scoped +//! anti-replay, never durable state (`docs/scale-out.adoc`, "Attested +//! push"): it is a keyed hash of a per-process secret, the repository, and +//! a timestamp, verified by recomputation within a slop window rather than +//! by storing anything. use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; use axum::body::Bytes; use axum::extract::State; @@ -34,7 +42,7 @@ use git_protocol::native::{BackendResolver, NativeBackend, RepoBackends}; use git_protocol::{ AdSpec, Advertise as _, GeneratePack as _, IngestPack as _, Negotiate as _, NegotiationState, - PushRequest, RepoId, + PushCertificate, PushRequest, RepoId, }; use gix_hash::ObjectId; @@ -73,10 +81,11 @@ fn backend(state: &AppState) -> NativeBackend<DiskResolver> { let signer: Arc<dyn git_protocol::attestation::OpSigner> = match &state.web_signing_key { Some(key) => Arc::new(git_protocol::attestation::SshOpSigner::new(key.clone())), - // No server signing key configured: op records still build, but - // fail to sign — acceptable for the additive native path, whose - // push side is already limited to the bootstrap window (see the - // module doc comment). + // No server signing key configured: op records fail to sign, so + // every otherwise-acceptable push over this endpoint is rejected — + // fail-closed, since an accepted push without its op record would + // break the "universal server op record" rule. Reads are + // unaffected. None => Arc::new(git_protocol::attestation::SshOpSigner::new(PathBuf::from( "/dev/null", ))), @@ -117,9 +126,10 @@ } }; + let nonce = if receive { issue_nonce(repo_rel) } else { None }; let mut body = pkt_line(format!("# service={service}\n").as_bytes()); body.extend_from_slice(FLUSH_PKT); - body.extend(advertisement_lines(&ad, receive)); + body.extend(advertisement_lines(&ad, receive, nonce.as_deref())); Response::builder() .header( @@ -180,36 +190,63 @@ async fn receive_pack(state: &AppState, repo_rel: &str, body: &[u8]) -> Response { let (commands, pack) = split_commands(body); - let ref_edits: Vec<RefEdit> = commands + let parsed = parse_receive_request(&commands); + let names: Vec<RefName> = parsed + .ref_edits .iter() - .filter_map(|line| parse_command(line)) + .map(|edit| edit.name.clone()) .collect(); - let names: Vec<RefName> = ref_edits.iter().map(|edit| edit.name.clone()).collect(); + + // The nonce echo check — transport-level anti-replay, ahead of the + // signature/authorization checks IngestPack::receive itself makes. A + // certificate whose nonce is not one this process recently issued for + // this repository is a replayed (or cross-repo) certificate, rejected + // before anything is staged. + if parsed.cert.is_some() { + let nonce_ok = parsed + .nonce + .as_deref() + .is_some_and(|nonce| nonce_valid(repo_rel, nonce)); + if !nonce_ok { + return report_status( + &names, + &Ok(git_protocol::PushOutcome::Rejected { + reason: "push certificate nonce was missing or stale".to_owned(), + }), + ); + } + } let push = PushRequest { repo: RepoId::new(repo_rel), - ref_edits, + ref_edits: parsed.ref_edits, pack: PackStream::new(std::io::Cursor::new(pack.to_vec())), - // Not yet parsed off the wire — see the module doc comment. A push - // only succeeds during the bootstrap window until this is wired. - push_cert: None, + push_cert: parsed.cert.map(PushCertificate::new), }; let outcome = backend(state).receive(push); + report_status(&names, &outcome) +} +/// The report-status response for one push: `unpack ok`, then one `ok`/`ng` +/// line per ref. +fn report_status( + names: &[RefName], + outcome: &git_protocol::Result<git_protocol::PushOutcome>, +) -> Response { let mut out = pkt_line(b"unpack ok\n"); match outcome { Ok(git_protocol::PushOutcome::Accepted { .. }) => { - for name in &names { + for name in names { out.extend(pkt_line(format!("ok {name}\n").as_bytes())); } } Ok(git_protocol::PushOutcome::Rejected { reason }) => { - for name in &names { + for name in names { out.extend(pkt_line(format!("ng {name} {reason}\n").as_bytes())); } } Err(error) => { - for name in &names { + for name in names { out.extend(pkt_line(format!("ng {name} {error}\n").as_bytes())); } } @@ -357,15 +394,149 @@ }) } +/// A parsed receive-pack request: the ref edits it asks for, plus — when +/// the client answered the `push-cert` capability — the reassembled +/// certificate text (exactly the bytes the client signed, then its +/// signature) and the nonce it echoed. +struct ParsedReceive { + ref_edits: Vec<RefEdit>, + cert: Option<String>, + nonce: Option<String>, +} + +/// Parse a receive-pack request's pkt-lines: either a plain command list, +/// or a `push-cert` block (`pack-protocol.txt`: a `push-cert` line carrying +/// the client's capabilities, the certificate header ending at a blank +/// line, the command lines, the SSH signature block, then +/// `push-cert-end`). With a certificate, the commands inside it are the +/// authoritative ones — a client using `push-cert` sends no plain command +/// list at all. +fn parse_receive_request(commands: &[Vec<u8>]) -> ParsedReceive { + let first_is_cert = commands.first().is_some_and(|line| { + String::from_utf8_lossy(line) + .split('\0') + .next() + .unwrap_or_default() + .trim_end() + == "push-cert" + }); + if !first_is_cert { + return ParsedReceive { + ref_edits: commands + .iter() + .filter_map(|line| parse_command(line)) + .collect(), + cert: None, + nonce: None, + }; + } + + let mut cert = String::new(); + let mut nonce = None; + let mut ref_edits = Vec::new(); + let mut past_header = false; + let mut in_signature = false; + for line in commands.iter().skip(1) { + let text = String::from_utf8_lossy(line); + if text.trim_end() == "push-cert-end" { + break; + } + // Certificate pkt-lines carry their own LF; concatenating them + // as-is reconstructs the exact payload the client signed. + cert.push_str(&text); + let trimmed = text.trim_end_matches('\n'); + if !past_header { + if let Some(value) = trimmed.strip_prefix("nonce ") { + nonce = Some(value.to_owned()); + } + if trimmed.is_empty() { + past_header = true; + } + continue; + } + if trimmed.starts_with("-----BEGIN") { + in_signature = true; + } + if !in_signature && let Some(edit) = parse_command(line) { + ref_edits.push(edit); + } + } + ParsedReceive { + ref_edits, + cert: Some(cert), + nonce, + } +} + +/// How long an issued nonce stays echoable. The advertisement and the push +/// are two HTTP requests seconds apart; anything older is a replay. The +/// nonce is session-scoped anti-replay only, never durable state. +const NONCE_SLOP_SECONDS: u64 = 300; + +/// The per-process secret nonces are keyed with. Process-scoped on +/// purpose: a nonce must survive exactly the advertisement→push window +/// within one server process, nothing longer. +fn nonce_secret() -> &'static str { + static SECRET: OnceLock<String> = OnceLock::new(); + SECRET.get_or_init(|| uuid::Uuid::new_v4().to_string()) +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default() +} + +/// The keyed hash binding a nonce to this process, `repo`, and `stamp`. +/// The secret is prepended, so recomputing it requires holding the secret. +fn nonce_mac(repo: &str, stamp: u64) -> Option<String> { + let data = format!("{}:{repo}:{stamp}", nonce_secret()); + gix_object::compute_hash( + gix_hash::Kind::Sha1, + gix_object::Kind::Blob, + data.as_bytes(), + ) + .ok() + .map(|oid| oid.to_hex().to_string()) +} + +/// A fresh nonce for `repo`, advertised as `push-cert=<nonce>` and expected +/// back in the certificate's `nonce` header. +fn issue_nonce(repo: &str) -> Option<String> { + let stamp = unix_now(); + nonce_mac(repo, stamp).map(|mac| format!("{stamp}-{mac}")) +} + +/// Whether `nonce` is one this process issued for `repo` within the slop +/// window — verified by recomputation, storing nothing. +fn nonce_valid(repo: &str, nonce: &str) -> bool { + let Some((stamp, mac)) = nonce.split_once('-') else { + return false; + }; + let Ok(stamp) = stamp.parse::<u64>() else { + return false; + }; + nonce_mac(repo, stamp).as_deref() == Some(mac) + && unix_now().saturating_sub(stamp) <= NONCE_SLOP_SECONDS +} + /// The advertised ref lines: `HEAD` first (carrying capabilities and, when /// resolved, a `symref=HEAD:<name>` hint so `git clone` knows its default /// branch) if resolved, then every other ref. -fn advertisement_lines(ad: &git_protocol::RefAdvertisement, receive: bool) -> Vec<u8> { +fn advertisement_lines( + ad: &git_protocol::RefAdvertisement, + receive: bool, + push_cert_nonce: Option<&str>, +) -> Vec<u8> { let mut caps = if receive { "report-status delete-refs ofs-delta agent=git-ents/1.0".to_owned() } else { "ofs-delta agent=git-ents/1.0".to_owned() }; + if let Some(nonce) = push_cert_nonce { + caps = format!("{caps} push-cert={nonce}"); + } if let Some(head) = &ad.head { caps = format!("{caps} symref=HEAD:{head}"); }
crates/git-ents-server/tests/native_protocol.rs @@ -7,15 +7,14 @@ )] //! End-to-end coverage for the native `git-protocol` smart-HTTP path -//! (`crate::native_git`), mounted under `/_native/`. `clone`/`fetch` is -//! proven against a stock `git` binary with zero client configuration, per -//! WS3's read-path interop requirement. The write path is exercised too -//! (a real `git push` during the bootstrap window, since the native -//! endpoint does not yet parse a push certificate off the wire — see -//! `native_git`'s module doc comment); attested-push acceptance/rejection -//! is covered at the trait level in `crates/git-protocol`'s own tests, -//! which construct a `PushRequest` directly with a real signed certificate, -//! per this workstream's test plan. +//! (`crate::native_git`), mounted under `/_native/`, all against a stock +//! `git` binary. `clone`/`fetch` is proven with zero client configuration, +//! per WS3's read-path interop requirement. The write path is proven at +//! every attestation stage: a bootstrap-window push (no members enrolled), +//! an unsigned push rejected once a member is enrolled, and a real +//! `git push --signed` (SSH key, the `push.gpgSign` mechanics) accepted +//! with its op record — client certificate embedded by OID — chained under +//! `refs/meta/ops/log`. use std::net::{TcpListener, TcpStream}; use std::path::Path; @@ -144,6 +143,140 @@ ); } +// @relation(protocol.git, auth.signed-push, role=Verifies) +#[test] +fn signed_push_is_accepted_and_unsigned_rejected_once_a_member_is_enrolled() { + let data = tempfile::tempdir().unwrap(); + let scratch = tempfile::tempdir().unwrap(); + let server_key = keygen(scratch.path(), "op-signing-key"); + let member_key = keygen(scratch.path(), "member-key"); + let member_pub = std::fs::read_to_string(scratch.path().join("member-key.pub")).unwrap(); + let port = free_port(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_git-ents-server")) + .arg("--port") + .arg(port.to_string()) + .arg("--data-dir") + .arg(data.path()) + .arg("--web-signing-key") + .arg(&server_key) + .spawn() + .unwrap(); + wait_for_port(port); + let url = format!("http://127.0.0.1:{port}/_native/attested.git"); + + // Bootstrap: first push with no members enrolled creates the repo. + let src = tempfile::tempdir().unwrap(); + run_git(Some(src.path()), &["init", "-q", "-b", "main"]); + std::fs::write(src.path().join("README.md"), "hello ents\n").unwrap(); + run_git(Some(src.path()), &["add", "."]); + run_git(Some(src.path()), &["commit", "-q", "-m", "initial"]); + run_git(Some(src.path()), &["push", "-q", &url, "main"]); + + // Enroll the member key directly on the served bare repo — the same + // refs/meta/member layout pre-receive trusts. + let repo_on_disk = data.path().join("attested.git"); + git_member::members::store( + &repo_on_disk, + &git_member::members::Member { + principal: "alice".to_owned(), + valid_after: None, + valid_before: None, + trust: git_member::members::Trust::Keys( + std::iter::once(("fp1".to_owned(), member_pub)).collect(), + ), + provenance: git_member::members::Provenance::AdminRegistered, + account: None, + role: None, + }, + ) + .unwrap(); + let op_log_before = rev_parse_ref(&repo_on_disk, "refs/meta/ops/log"); + assert!( + !op_log_before.is_empty(), + "bootstrap push must have logged an op record" + ); + + // A second commit: unsigned push must now be rejected... + std::fs::write(src.path().join("README.md"), "hello again\n").unwrap(); + run_git(Some(src.path()), &["add", "."]); + run_git(Some(src.path()), &["commit", "-q", "-m", "second"]); + let second = rev_parse(src.path()); + let unsigned = git_command(Some(src.path()), &["push", "-q", &url, "main"]) + .output() + .unwrap(); + assert!( + !unsigned.status.success(), + "unsigned push must be rejected once a member is enrolled: {}", + String::from_utf8_lossy(&unsigned.stderr) + ); + + // ...and the same push signed with the enrolled key must land. + run_git( + Some(src.path()), + &[ + "-c", + "gpg.format=ssh", + "-c", + &format!("user.signingKey={}", member_key.display()), + "push", + "-q", + "--signed", + &url, + "main", + ], + ); + + let landed = rev_parse_ref(&repo_on_disk, "refs/heads/main"); + let op_log_after = rev_parse_ref(&repo_on_disk, "refs/meta/ops/log"); + let op_record = git_command( + Some(&repo_on_disk), + &["cat-file", "-p", "refs/meta/ops/log"], + ) + .output() + .unwrap(); + let op_record_text = String::from_utf8_lossy(&op_record.stdout).into_owned(); + let cert_oid = op_record_text + .lines() + .find_map(|line| line.strip_prefix("push-cert ")) + .unwrap_or_default() + .to_owned(); + let cert = git_command(Some(&repo_on_disk), &["cat-file", "blob", &cert_oid]) + .output() + .unwrap(); + + child.kill().unwrap(); + let _wait = child.wait(); + + assert_eq!(landed, second, "the signed push must have landed"); + assert_ne!( + op_log_after, op_log_before, + "the signed push must have chained a new op record" + ); + let cert_text = String::from_utf8_lossy(&cert.stdout); + assert!( + cert_text.contains("BEGIN SSH SIGNATURE"), + "the embedded push certificate must carry the client's signature: {cert_text}" + ); + assert!( + cert_text.contains(&second), + "the embedded push certificate must name the pushed commit: {cert_text}" + ); +} + +/// Generate an ed25519 keypair at `base/<name>`, returning the private key +/// path. +fn keygen(base: &Path, name: &str) -> std::path::PathBuf { + let key = base.join(name); + let status = Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-f"]) + .arg(&key) + .status() + .unwrap(); + assert!(status.success(), "ssh-keygen failed"); + key +} + fn free_port() -> u16 { let probe = TcpListener::bind("127.0.0.1:0").unwrap(); let port = probe.local_addr().unwrap().port();