1use sha1::{Digest, Sha1};
25
26use crate::signing::{GpgConfig, SignatureCheck};
27
28const HMAC_BLOCK_SIZE: usize = 64;
30
31pub const NONCE_OK: &str = "OK";
33
34#[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
50fn 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
84pub struct CertRefUpdate {
86 pub old_oid: String,
88 pub new_oid: String,
90 pub refname: String,
92}
93
94#[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
130pub struct PushCertEnv {
133 pub cert_oid: String,
135 pub signer: String,
137 pub key: String,
139 pub status: char,
141 pub nonce: Option<String>,
143 pub nonce_status: Option<String>,
145}
146
147impl PushCertEnv {
148 #[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#[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
192pub 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 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}