Skip to main content

grit_lib/
push_cert.rs

1//! Signed-push certificate generation and verification.
2//!
3//! Port of the parts of Git's `send-pack.c:generate_push_cert` (client side) and
4//! `builtin/receive-pack.c` (server side: nonce HMAC, cert blob, `check_nonce`,
5//! and the `GIT_PUSH_CERT*` hook environment) that the grit CLI needs to drive
6//! `git push --signed` over the local and smart transports.
7//!
8//! A push certificate is a text payload of the form
9//!
10//! ```text
11//! certificate version 0.1
12//! pusher <key-id> <epoch> <tz>
13//! pushee <url>
14//! nonce <nonce>
15//! push-option <opt>          (zero or more)
16//!
17//! <old-oid> <new-oid> <refname>   (one per updated ref)
18//! ```
19//!
20//! followed by a detached signature (gpg/gpgsm/ssh) over the payload — exactly
21//! the layout [`crate::signing::parse_signed_buffer`] / [`crate::signing::verify_tag`]
22//! already understand (signature appended, not header-embedded).
23
24use sha1::{Digest, Sha1};
25
26use crate::signing::{GpgConfig, SignatureCheck};
27
28/// SHA-1 HMAC block size (RFC 2104). Git uses `the_hash_algo->blksz`.
29const HMAC_BLOCK_SIZE: usize = 64;
30
31/// `NONCE_OK` from receive-pack: the certificate nonce matched what we issued.
32pub const NONCE_OK: &str = "OK";
33
34/// Compute the push-cert nonce HMAC-SHA1 over `<path>:<stamp>` keyed by `seed`,
35/// returning Git's `"<stamp>-<hex-hmac>"` form (`receive-pack.c:prepare_push_cert_nonce`).
36///
37/// `path` is the receiver's service directory (its git dir) and `stamp` is the
38/// receiver's wall-clock epoch seconds at the moment the advertisement is built.
39#[must_use]
40pub fn prepare_push_cert_nonce(path: &str, stamp: i64, seed: &str) -> String {
41    let text = format!("{path}:{stamp}");
42    let mac = hmac_sha1(seed.as_bytes(), text.as_bytes());
43    let mut hex = String::with_capacity(40);
44    for b in mac {
45        hex.push_str(&format!("{b:02x}"));
46    }
47    format!("{stamp}-{hex}")
48}
49
50/// RFC 2104 HMAC-SHA1, matching `receive-pack.c:hmac_hash`.
51fn hmac_sha1(key_in: &[u8], text: &[u8]) -> [u8; 20] {
52    let mut key = [0u8; HMAC_BLOCK_SIZE];
53    if key_in.len() > HMAC_BLOCK_SIZE {
54        let mut hasher = Sha1::new();
55        hasher.update(key_in);
56        let digest = hasher.finalize();
57        key[..20].copy_from_slice(&digest);
58    } else {
59        key[..key_in.len()].copy_from_slice(key_in);
60    }
61
62    let mut k_ipad = [0u8; HMAC_BLOCK_SIZE];
63    let mut k_opad = [0u8; HMAC_BLOCK_SIZE];
64    for i in 0..HMAC_BLOCK_SIZE {
65        k_ipad[i] = key[i] ^ 0x36;
66        k_opad[i] = key[i] ^ 0x5c;
67    }
68
69    let mut inner = Sha1::new();
70    inner.update(k_ipad);
71    inner.update(text);
72    let inner_digest = inner.finalize();
73
74    let mut outer = Sha1::new();
75    outer.update(k_opad);
76    outer.update(inner_digest);
77    let outer_digest = outer.finalize();
78
79    let mut out = [0u8; 20];
80    out.copy_from_slice(&outer_digest);
81    out
82}
83
84/// A single ref update line in a push certificate.
85pub struct CertRefUpdate {
86    /// Old OID (40 zeros for a create).
87    pub old_oid: String,
88    /// New OID (40 zeros for a delete).
89    pub new_oid: String,
90    /// Full ref name (`refs/heads/...`).
91    pub refname: String,
92}
93
94/// Build the unsigned push-certificate payload (`send-pack.c:generate_push_cert`).
95///
96/// `pusher` is the signing key id (Git uses `get_signing_key_id()`, falling back
97/// to the committer ident "Name <email>"). `date` is `"<epoch> <tz>"`. `url` and
98/// `nonce` are omitted when empty. Returns `None` when there are no updates to send.
99#[must_use]
100pub fn build_push_cert_payload(
101    pusher: &str,
102    date: &str,
103    url: Option<&str>,
104    nonce: Option<&str>,
105    push_options: &[String],
106    updates: &[CertRefUpdate],
107) -> Option<Vec<u8>> {
108    if updates.is_empty() {
109        return None;
110    }
111    let mut cert = String::new();
112    cert.push_str("certificate version 0.1\n");
113    cert.push_str(&format!("pusher {pusher} {date}\n"));
114    if let Some(u) = url.filter(|u| !u.is_empty()) {
115        cert.push_str(&format!("pushee {u}\n"));
116    }
117    if let Some(n) = nonce.filter(|n| !n.is_empty()) {
118        cert.push_str(&format!("nonce {n}\n"));
119    }
120    for opt in push_options {
121        cert.push_str(&format!("push-option {opt}\n"));
122    }
123    cert.push('\n');
124    for u in updates {
125        cert.push_str(&format!("{} {} {}\n", u.old_oid, u.new_oid, u.refname));
126    }
127    Some(cert.into_bytes())
128}
129
130/// The hook-visible certificate environment, mirroring receive-pack's
131/// `GIT_PUSH_CERT*` variables.
132pub struct PushCertEnv {
133    /// `GIT_PUSH_CERT` — OID of the blob the cert was stored as.
134    pub cert_oid: String,
135    /// `GIT_PUSH_CERT_SIGNER` — `%GS` signer (may be empty).
136    pub signer: String,
137    /// `GIT_PUSH_CERT_KEY` — `%GK` key id (may be empty).
138    pub key: String,
139    /// `GIT_PUSH_CERT_STATUS` — single-char `%G?` result.
140    pub status: char,
141    /// `GIT_PUSH_CERT_NONCE` — the nonce we issued (None when no seed).
142    pub nonce: Option<String>,
143    /// `GIT_PUSH_CERT_NONCE_STATUS` — `OK`/`SLOP`/`BAD`/... (None when no seed).
144    pub nonce_status: Option<String>,
145}
146
147impl PushCertEnv {
148    /// Materialize the variables as `(name, value)` pairs for hook execution.
149    #[must_use]
150    pub fn to_env_pairs(&self) -> Vec<(String, String)> {
151        let mut env = vec![
152            ("GIT_PUSH_CERT".to_owned(), self.cert_oid.clone()),
153            ("GIT_PUSH_CERT_SIGNER".to_owned(), self.signer.clone()),
154            ("GIT_PUSH_CERT_KEY".to_owned(), self.key.clone()),
155            ("GIT_PUSH_CERT_STATUS".to_owned(), self.status.to_string()),
156        ];
157        if let (Some(nonce), Some(nonce_status)) = (&self.nonce, &self.nonce_status) {
158            env.push(("GIT_PUSH_CERT_NONCE".to_owned(), nonce.clone()));
159            env.push((
160                "GIT_PUSH_CERT_NONCE_STATUS".to_owned(),
161                nonce_status.clone(),
162            ));
163        }
164        env
165    }
166}
167
168/// Compute the receiver-side `GIT_PUSH_CERT*` environment for a signed push,
169/// reusing the existing detached-signature verification.
170///
171/// `signed_cert` is the full `<payload><signature>` buffer; `cert_oid` is the OID
172/// it was stored under as a blob; `issued_nonce` is the nonce the receiver
173/// advertised (the same value embedded in the cert by the local in-process push,
174/// so the nonce status is `OK`).
175#[must_use]
176pub fn cert_env_from_check(
177    check: &SignatureCheck,
178    cert_oid: String,
179    issued_nonce: Option<String>,
180) -> PushCertEnv {
181    let nonce_status = issued_nonce.as_ref().map(|_| NONCE_OK.to_owned());
182    PushCertEnv {
183        cert_oid,
184        signer: check.signer.clone().unwrap_or_default(),
185        key: check.key.clone().unwrap_or_default(),
186        status: check.result,
187        nonce: issued_nonce,
188        nonce_status,
189    }
190}
191
192/// Verify a stored push certificate, deriving the signer/key/status from the
193/// detached signature exactly as `git verify-tag` does.
194///
195/// # Errors
196///
197/// Returns an error when the verifier program cannot be run.
198pub fn verify_push_cert(
199    cfg: &GpgConfig,
200    signed_cert: &[u8],
201) -> crate::error::Result<SignatureCheck> {
202    crate::signing::verify_tag(cfg, signed_cert)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn nonce_matches_git_format() {
211        // Shape: "<stamp>-<40 hex>", and is stable for the same inputs.
212        let n = prepare_push_cert_nonce("/srv/repo.git", 1_700_000_000, "sekrit");
213        let (stamp, hex) = n.split_once('-').expect("nonce has a dash");
214        assert_eq!(stamp, "1700000000");
215        assert_eq!(hex.len(), 40);
216        assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()));
217        let again = prepare_push_cert_nonce("/srv/repo.git", 1_700_000_000, "sekrit");
218        assert_eq!(n, again);
219    }
220
221    #[test]
222    fn nonce_changes_with_seed_and_path() {
223        let a = prepare_push_cert_nonce("/srv/repo.git", 100, "sekrit");
224        let b = prepare_push_cert_nonce("/srv/repo.git", 100, "other");
225        let c = prepare_push_cert_nonce("/other/repo.git", 100, "sekrit");
226        assert_ne!(a, b);
227        assert_ne!(a, c);
228    }
229
230    #[test]
231    fn payload_has_expected_lines() {
232        let updates = vec![CertRefUpdate {
233            old_oid: "0".repeat(40),
234            new_oid: "1".repeat(40),
235            refname: "refs/heads/main".to_owned(),
236        }];
237        let payload = build_push_cert_payload(
238            "A U Thor <author@example.com>",
239            "1700000000 +0000",
240            Some("/srv/repo.git"),
241            Some("1700000000-deadbeef"),
242            &[],
243            &updates,
244        )
245        .expect("payload built");
246        let text = String::from_utf8(payload).expect("utf8");
247        assert!(text.starts_with("certificate version 0.1\n"));
248        assert!(text.contains("pusher A U Thor <author@example.com> 1700000000 +0000\n"));
249        assert!(text.contains("pushee /srv/repo.git\n"));
250        assert!(text.contains("nonce 1700000000-deadbeef\n"));
251        assert!(text.contains(&format!(
252            "{} {} refs/heads/main\n",
253            "0".repeat(40),
254            "1".repeat(40)
255        )));
256    }
257
258    #[test]
259    fn payload_none_without_updates() {
260        assert!(build_push_cert_payload("x", "0 +0000", None, None, &[], &[]).is_none());
261    }
262}