forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
19.1 KiB
1//! Commit signature verification through `git verify-commit`. Parses git's output into a
2//! [`RawVerify`]. It does not decide ownership: forge-web matches the fingerprint to a user and
3//! checks the committer email.
4//!
5//! The commit object is read first, so an unsigned commit never runs `verify-commit`. Every
6//! commit id is checked as hex before it reaches git.
7
8use std::path::Path;
9
10use forge_core::store::signatures::SigKind;
11
12use crate::command::{GitCommand, GitEnv};
13use crate::error::{Error, Result};
14
15/// What git said about one commit's signature.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct RawVerify {
18 /// False when the commit has no signature header. Every other field is then empty.
19 pub signed: bool,
20 /// From the signature header: `Ssh` for an SSH signature, `Gpg` for a PGP one.
21 pub kind: Option<SigKind>,
22 /// git reported a good signature.
23 pub good: bool,
24 /// git reported a bad signature (`BADSIG`, or `Bad "git" signature`).
25 pub bad: bool,
26 /// The signing key is not known to git: `ERRSIG` or `NO_PUBKEY` for GPG, or `No principal
27 /// matched` for SSH.
28 pub missing_key: bool,
29 /// GPG: the primary key fingerprint, or the signing key's if git gave no primary. SSH: the
30 /// `SHA256:` fingerprint.
31 pub fingerprint: Option<String>,
32 /// SSH only: the allowed-signers principal that matched.
33 pub principal: Option<String>,
34}
35
36impl RawVerify {
37 fn unsigned() -> Self {
38 Self {
39 signed: false,
40 kind: None,
41 good: false,
42 bad: false,
43 missing_key: false,
44 fingerprint: None,
45 principal: None,
46 }
47 }
48}
49
50/// Rejects anything that is not a full hex object id, so no user text reaches git as an option.
51fn check_oid(oid: &str) -> Result<()> {
52 let ok = (oid.len() == 40 || oid.len() == 64) && oid.bytes().all(|b| b.is_ascii_hexdigit());
53 if ok {
54 Ok(())
55 } else {
56 Err(Error::InvalidInput(format!("not a full object id: {oid}")))
57 }
58}
59
60/// The raw commit object (`git cat-file commit <oid>`).
61async fn commit_object(env: &GitEnv, repo: &Path, oid: &str) -> Result<Vec<u8>> {
62 check_oid(oid)?;
63 GitCommand::new(env, "cat-file")
64 .git_dir(repo)
65 .arg("commit")
66 .user_arg(oid)?
67 .output()
68 .await
69}
70
71/// `None` when the commit has no `gpgsig` header. `Some(kind)` when it has one, where the kind is
72/// read from the armor line (`None` if the armor is neither SSH nor PGP).
73fn signature_header(object: &[u8]) -> Option<Option<SigKind>> {
74 for line in object.split(|&b| b == b'\n') {
75 if line.is_empty() {
76 // End of headers.
77 break;
78 }
79 let value = line
80 .strip_prefix(b"gpgsig ")
81 .or_else(|| line.strip_prefix(b"gpgsig-sha256 "));
82 if let Some(value) = value {
83 return Some(if value.starts_with(b"-----BEGIN SSH SIGNATURE-----") {
84 Some(SigKind::Ssh)
85 } else if value.starts_with(b"-----BEGIN PGP SIGNATURE-----") {
86 Some(SigKind::Gpg)
87 } else {
88 None
89 });
90 }
91 }
92 None
93}
94
95/// The committer email of a commit, without the angle brackets.
96pub async fn committer_email(env: &GitEnv, repo: &Path, oid: &str) -> Result<String> {
97 let object = commit_object(env, repo, oid).await?;
98 for line in object.split(|&b| b == b'\n') {
99 if line.is_empty() {
100 break;
101 }
102 if let Some(who) = line.strip_prefix(b"committer ") {
103 let who = String::from_utf8_lossy(who);
104 let start = who.rfind('<');
105 let end = who.rfind('>');
106 return match (start, end) {
107 (Some(s), Some(e)) if s < e => Ok(who[s + 1..e].trim().to_string()),
108 _ => Err(Error::Failed(format!(
109 "commit {oid} has no committer email"
110 ))),
111 };
112 }
113 }
114 Err(Error::Failed(format!("commit {oid} has no committer line")))
115}
116
117/// Verifies the signature of `oid` in the repo at `repo` (a git dir). `allowed_signers` is the
118/// SSH allowed-signers file. `gnupg_home`, when given, is the keyring for GPG signatures.
119///
120/// A non-zero exit from `verify-commit` is normal for a bad or unknown signature. The output is
121/// parsed either way. Only a failure to start git is an error.
122pub async fn verify_commit(
123 env: &GitEnv,
124 repo: &Path,
125 oid: &str,
126 allowed_signers: &Path,
127 gnupg_home: Option<&Path>,
128) -> Result<RawVerify> {
129 let object = commit_object(env, repo, oid).await?;
130 let Some(kind) = signature_header(&object) else {
131 return Ok(RawVerify::unsigned());
132 };
133 let allowed = allowed_signers
134 .to_str()
135 .ok_or_else(|| Error::InvalidInput("allowed signers path is not UTF-8".into()))?;
136 let mut cmd = GitCommand::new(env, "verify-commit")
137 .git_dir(repo)
138 .config_checked(format!("gpg.ssh.allowedSignersFile={allowed}"))?
139 .arg("--raw")
140 .user_revs(&[oid])?;
141 if let Some(home) = gnupg_home {
142 cmd = cmd.env("GNUPGHOME", home.as_os_str());
143 }
144 let out = cmd.into_tokio().output().await?;
145 let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
146 text.push('\n');
147 text.push_str(&String::from_utf8_lossy(&out.stderr));
148 Ok(parse_output(&text, kind))
149}
150
151/// Reads git's verification output. GPG status lines have the `[GNUPG:]` prefix. SSH results
152/// are the `Good "git" signature` / `Bad "git" signature` sentences.
153fn parse_output(text: &str, kind: Option<SigKind>) -> RawVerify {
154 let mut raw = RawVerify {
155 signed: true,
156 kind,
157 ..RawVerify::unsigned()
158 };
159 for line in text.lines() {
160 let line = line.trim();
161 if let Some(status) = line.strip_prefix("[GNUPG:] ") {
162 let mut parts = status.split_whitespace();
163 match parts.next() {
164 Some("VALIDSIG") => {
165 // VALIDSIG <fpr> <date> <ts> <exp> <ver> <res> <pk> <hash> <class> [<primary>]
166 let fields: Vec<&str> = parts.collect();
167 let fpr = fields.get(9).or_else(|| fields.first());
168 if let Some(fpr) = fpr {
169 raw.fingerprint = Some((*fpr).to_string());
170 }
171 }
172 Some("GOODSIG") => raw.good = true,
173 Some("BADSIG") => raw.bad = true,
174 Some("ERRSIG") | Some("NO_PUBKEY") => raw.missing_key = true,
175 _ => {}
176 }
177 } else if line.starts_with("No principal matched") {
178 raw.missing_key = true;
179 } else if let Some(rest) = line.strip_prefix("Good \"git\" signature") {
180 apply_ssh_sentence(&mut raw, rest);
181 // Without a principal the key matched no allowed signer. git still calls the
182 // signature good, but nobody vouches for the key, so it counts as an unknown key.
183 if raw.principal.is_some() {
184 raw.good = true;
185 } else {
186 raw.missing_key = true;
187 }
188 } else if let Some(rest) = line.strip_prefix("Bad \"git\" signature") {
189 raw.bad = true;
190 apply_ssh_sentence(&mut raw, rest);
191 }
192 }
193 raw
194}
195
196/// `rest` is what follows `Good "git" signature`: either ` for <principal> with <type> key
197/// SHA256:<fp>` or ` with <type> key SHA256:<fp>`.
198fn apply_ssh_sentence(raw: &mut RawVerify, rest: &str) {
199 if let Some(idx) = rest.strip_prefix(" for ").and_then(|s| s.find(" with ")) {
200 let after_for = &rest[" for ".len()..];
201 raw.principal = Some(after_for[..idx].to_string());
202 }
203 if let Some(idx) = rest.find("SHA256:") {
204 let token = rest[idx..].split_whitespace().next().unwrap_or_default();
205 raw.fingerprint = Some(token.to_string());
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use std::process::Command;
213
214 /// A git invocation for test setup only, with no user or system config.
215 fn git(home: &Path, dir: &Path, args: &[&str]) -> String {
216 let out = Command::new("git")
217 .arg("-C")
218 .arg(dir)
219 .args(args)
220 .env_clear()
221 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
222 .env("HOME", home)
223 .env("GIT_CONFIG_NOSYSTEM", "1")
224 .env("GIT_CONFIG_GLOBAL", home.join("gitconfig"))
225 .output()
226 .expect("run git");
227 assert!(
228 out.status.success(),
229 "git {args:?} failed: {}",
230 String::from_utf8_lossy(&out.stderr)
231 );
232 String::from_utf8_lossy(&out.stdout).trim().to_string()
233 }
234
235 fn env_for(home: &Path) -> GitEnv {
236 GitEnv {
237 git_bin: "/usr/bin/git".into(),
238 data_dir: home.to_path_buf(),
239 }
240 }
241
242 /// Makes a repo with one unsigned commit by `email`, and returns (work dir, git dir).
243 fn repo_with_base(home: &Path, email: &str) -> (std::path::PathBuf, std::path::PathBuf) {
244 let work = home.join("work");
245 std::fs::create_dir_all(&work).unwrap();
246 git(home, &work, &["init", "-q", "-b", "main"]);
247 git(home, &work, &["config", "user.name", "Test"]);
248 git(home, &work, &["config", "user.email", email]);
249 std::fs::write(work.join("a.txt"), "a\n").unwrap();
250 git(home, &work, &["add", "a.txt"]);
251 git(
252 home,
253 &work,
254 &["commit", "-q", "--no-gpg-sign", "-m", "base"],
255 );
256 let git_dir = work.join(".git");
257 (work, git_dir)
258 }
259
260 fn ssh_keygen(home: &Path, name: &str, email: &str) -> std::path::PathBuf {
261 let key = home.join(name);
262 let status = Command::new("ssh-keygen")
263 .args(["-q", "-t", "ed25519", "-N", "", "-C", email, "-f"])
264 .arg(&key)
265 .status()
266 .expect("run ssh-keygen");
267 assert!(status.success());
268 key
269 }
270
271 /// `allowed_signers` line for `email` from a `.pub` file.
272 fn allowed_line(pub_path: &Path, email: &str) -> String {
273 let text = std::fs::read_to_string(pub_path).unwrap();
274 let mut fields = text.split_whitespace();
275 let algo = fields.next().unwrap();
276 let blob = fields.next().unwrap();
277 format!("{email} namespaces=\"git\" {algo} {blob}\n")
278 }
279
280 /// The SHA256 fingerprint of a `.pub` file, as `ssh-keygen -lf` prints it.
281 fn ssh_fingerprint(pub_path: &Path) -> String {
282 let out = Command::new("ssh-keygen")
283 .arg("-lf")
284 .arg(pub_path)
285 .output()
286 .unwrap();
287 let text = String::from_utf8_lossy(&out.stdout).into_owned();
288 text.split_whitespace().nth(1).unwrap().to_string()
289 }
290
291 fn signed_commit(home: &Path, work: &Path, key_pub: &Path, email: &str) {
292 std::fs::write(work.join("b.txt"), email).unwrap();
293 git(home, work, &["add", "b.txt"]);
294 git(
295 home,
296 work,
297 &[
298 "-c",
299 "gpg.format=ssh",
300 "-c",
301 &format!("user.signingkey={}", key_pub.display()),
302 "-c",
303 "commit.gpgsign=true",
304 "-c",
305 &format!("user.email={email}"),
306 "commit",
307 "-q",
308 "-m",
309 "signed",
310 ],
311 );
312 }
313
314 #[tokio::test]
315 async fn ssh_good_signature_is_parsed() {
316 let home = tempfile::tempdir().unwrap();
317 let home = home.path();
318 let email = "alice@example.com";
319 ssh_keygen(home, "alice", email);
320 let pub_path = home.join("alice.pub");
321 let (work, git_dir) = repo_with_base(home, email);
322 signed_commit(home, &work, &pub_path, email);
323 let oid = git(home, &work, &["rev-parse", "HEAD"]);
324 let signers = home.join("allowed_signers");
325 std::fs::write(&signers, allowed_line(&pub_path, email)).unwrap();
326
327 let raw = verify_commit(&env_for(home), &git_dir, &oid, &signers, None)
328 .await
329 .unwrap();
330 assert!(raw.signed);
331 assert_eq!(raw.kind, Some(SigKind::Ssh));
332 assert!(raw.good, "{raw:?}");
333 assert!(!raw.bad && !raw.missing_key);
334 assert_eq!(raw.principal.as_deref(), Some(email));
335 assert_eq!(raw.fingerprint, Some(ssh_fingerprint(&pub_path)));
336 assert_eq!(
337 committer_email(&env_for(home), &git_dir, &oid)
338 .await
339 .unwrap(),
340 email
341 );
342 }
343
344 #[tokio::test]
345 async fn ssh_signer_not_in_allowed_signers_is_missing_key() {
346 let home = tempfile::tempdir().unwrap();
347 let home = home.path();
348 let email = "alice@example.com";
349 ssh_keygen(home, "alice", email);
350 let pub_path = home.join("alice.pub");
351 let (work, git_dir) = repo_with_base(home, email);
352 signed_commit(home, &work, &pub_path, email);
353 let oid = git(home, &work, &["rev-parse", "HEAD"]);
354 let signers = home.join("allowed_signers");
355 std::fs::write(&signers, "").unwrap();
356
357 let raw = verify_commit(&env_for(home), &git_dir, &oid, &signers, None)
358 .await
359 .unwrap();
360 assert!(raw.signed);
361 assert!(!raw.good);
362 assert!(raw.missing_key, "{raw:?}");
363 }
364
365 #[tokio::test]
366 async fn unsigned_commit_is_reported_without_verify() {
367 let home = tempfile::tempdir().unwrap();
368 let home = home.path();
369 let email = "alice@example.com";
370 let (work, git_dir) = repo_with_base(home, email);
371 let oid = git(home, &work, &["rev-parse", "HEAD"]);
372 // The signers file does not exist. An unsigned commit must not reach verify-commit.
373 let signers = home.join("does-not-exist");
374
375 let raw = verify_commit(&env_for(home), &git_dir, &oid, &signers, None)
376 .await
377 .unwrap();
378 assert_eq!(raw, RawVerify::unsigned());
379 }
380
381 #[tokio::test]
382 async fn bad_oid_is_rejected_before_git() {
383 let home = tempfile::tempdir().unwrap();
384 let env = env_for(home.path());
385 let err = verify_commit(
386 &env,
387 home.path(),
388 "--output=x",
389 &home.path().join("s"),
390 None,
391 )
392 .await;
393 assert!(matches!(err, Err(Error::InvalidInput(_))));
394 }
395
396 #[test]
397 fn parses_gpg_status_lines() {
398 let text = "[GNUPG:] NEWSIG\n[GNUPG:] VALIDSIG AAAA 2026-01-01 1 0 4 0 1 10 00 PRIMARY\n[GNUPG:] GOODSIG 1 Alice\n";
399 let raw = parse_output(text, Some(SigKind::Gpg));
400 assert!(raw.good && !raw.bad && !raw.missing_key);
401 assert_eq!(raw.fingerprint.as_deref(), Some("PRIMARY"));
402
403 let raw = parse_output("[GNUPG:] NO_PUBKEY 1\n", Some(SigKind::Gpg));
404 assert!(raw.missing_key && !raw.good);
405
406 let raw = parse_output("[GNUPG:] BADSIG 1 Alice\n", Some(SigKind::Gpg));
407 assert!(raw.bad && !raw.good);
408 }
409
410 #[test]
411 fn parses_ssh_sentences() {
412 let text = "Good \"git\" signature for alice@example.com with ED25519 key SHA256:abc+def\n";
413 let raw = parse_output(text, Some(SigKind::Ssh));
414 assert!(raw.good);
415 assert_eq!(raw.principal.as_deref(), Some("alice@example.com"));
416 assert_eq!(raw.fingerprint.as_deref(), Some("SHA256:abc+def"));
417
418 let raw = parse_output(
419 "Good \"git\" signature with ED25519 key SHA256:xyz\n",
420 Some(SigKind::Ssh),
421 );
422 assert!(!raw.good && raw.missing_key);
423 assert_eq!(raw.principal, None);
424 assert_eq!(raw.fingerprint.as_deref(), Some("SHA256:xyz"));
425 }
426
427 #[test]
428 fn signature_header_kinds() {
429 let ssh = b"tree x\nauthor a\ncommitter a <a@x> 1 +0000\ngpgsig -----BEGIN SSH SIGNATURE-----\n y\n\nmsg";
430 assert_eq!(signature_header(ssh), Some(Some(SigKind::Ssh)));
431 let pgp = b"tree x\ngpgsig-sha256 -----BEGIN PGP SIGNATURE-----\n\nmsg";
432 assert_eq!(signature_header(pgp), Some(Some(SigKind::Gpg)));
433 let none = b"tree x\ncommitter a <a@x> 1 +0000\n\ngpgsig in body";
434 assert_eq!(signature_header(none), None);
435 }
436
437 /// GPG round trip. Skipped when `gpg` is not installed.
438 #[tokio::test]
439 async fn gpg_good_signature_is_parsed() {
440 if Command::new("gpg").arg("--version").output().is_err() {
441 eprintln!("skipping gpg test: gpg is not installed");
442 return;
443 }
444 let home = tempfile::tempdir().unwrap();
445 let home = home.path();
446 let gnupg = home.join("gnupg");
447 std::fs::create_dir_all(&gnupg).unwrap();
448 std::fs::set_permissions(&gnupg, std::os::unix::fs::PermissionsExt::from_mode(0o700))
449 .unwrap();
450 let email = "alice@example.com";
451 let generated = Command::new("gpg")
452 .env("GNUPGHOME", &gnupg)
453 .args([
454 "--batch",
455 "--passphrase",
456 "",
457 "--quick-gen-key",
458 email,
459 "default",
460 "default",
461 "never",
462 ])
463 .output()
464 .unwrap();
465 assert!(
466 generated.status.success(),
467 "{}",
468 String::from_utf8_lossy(&generated.stderr)
469 );
470 let listing = Command::new("gpg")
471 .env("GNUPGHOME", &gnupg)
472 .args(["--batch", "--with-colons", "--list-keys", email])
473 .output()
474 .unwrap();
475 let listing = String::from_utf8_lossy(&listing.stdout).into_owned();
476 let primary = listing
477 .lines()
478 .find(|l| l.starts_with("fpr:"))
479 .and_then(|l| l.split(':').nth(9))
480 .unwrap()
481 .to_string();
482
483 let (work, git_dir) = repo_with_base(home, email);
484 std::fs::write(work.join("b.txt"), "b").unwrap();
485 git(home, &work, &["add", "b.txt"]);
486 let commit = Command::new("git")
487 .arg("-C")
488 .arg(&work)
489 .args([
490 "-c",
491 &format!("user.signingkey={primary}"),
492 "-c",
493 "commit.gpgsign=true",
494 "-c",
495 &format!("user.email={email}"),
496 "commit",
497 "-q",
498 "-m",
499 "gpg signed",
500 ])
501 .env_clear()
502 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
503 .env("HOME", home)
504 .env("GNUPGHOME", &gnupg)
505 .env("GIT_CONFIG_NOSYSTEM", "1")
506 .env("GIT_CONFIG_GLOBAL", home.join("gitconfig"))
507 .output()
508 .unwrap();
509 assert!(
510 commit.status.success(),
511 "{}",
512 String::from_utf8_lossy(&commit.stderr)
513 );
514 let oid = git(home, &work, &["rev-parse", "HEAD"]);
515 let signers = home.join("unused");
516
517 let raw = verify_commit(
518 &env_for(home),
519 &git_dir,
520 &oid,
521 &signers,
522 Some(gnupg.as_path()),
523 )
524 .await
525 .unwrap();
526 // Stop the agent that gpg started for this keyring.
527 let _ = Command::new("gpgconf")
528 .env("GNUPGHOME", &gnupg)
529 .args(["--kill", "gpg-agent"])
530 .output();
531 assert_eq!(raw.kind, Some(SigKind::Gpg));
532 assert!(raw.good, "{raw:?}");
533 assert_eq!(raw.fingerprint.as_deref(), Some(primary.as_str()));
534 }
535}