//! Commit signature verification through `git verify-commit`. Parses git's output into a //! [`RawVerify`]. It does not decide ownership: forge-web matches the fingerprint to a user and //! checks the committer email. //! //! The commit object is read first, so an unsigned commit never runs `verify-commit`. Every //! commit id is checked as hex before it reaches git. use std::path::Path; use forge_core::store::signatures::SigKind; use crate::command::{GitCommand, GitEnv}; use crate::error::{Error, Result}; /// What git said about one commit's signature. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RawVerify { /// False when the commit has no signature header. Every other field is then empty. pub signed: bool, /// From the signature header: `Ssh` for an SSH signature, `Gpg` for a PGP one. pub kind: Option, /// git reported a good signature. pub good: bool, /// git reported a bad signature (`BADSIG`, or `Bad "git" signature`). pub bad: bool, /// The signing key is not known to git: `ERRSIG` or `NO_PUBKEY` for GPG, or `No principal /// matched` for SSH. pub missing_key: bool, /// GPG: the primary key fingerprint, or the signing key's if git gave no primary. SSH: the /// `SHA256:` fingerprint. pub fingerprint: Option, /// SSH only: the allowed-signers principal that matched. pub principal: Option, } impl RawVerify { fn unsigned() -> Self { Self { signed: false, kind: None, good: false, bad: false, missing_key: false, fingerprint: None, principal: None, } } } /// Rejects anything that is not a full hex object id, so no user text reaches git as an option. fn check_oid(oid: &str) -> Result<()> { let ok = (oid.len() == 40 || oid.len() == 64) && oid.bytes().all(|b| b.is_ascii_hexdigit()); if ok { Ok(()) } else { Err(Error::InvalidInput(format!("not a full object id: {oid}"))) } } /// The raw commit object (`git cat-file commit `). async fn commit_object(env: &GitEnv, repo: &Path, oid: &str) -> Result> { check_oid(oid)?; GitCommand::new(env, "cat-file") .git_dir(repo) .arg("commit") .user_arg(oid)? .output() .await } /// `None` when the commit has no `gpgsig` header. `Some(kind)` when it has one, where the kind is /// read from the armor line (`None` if the armor is neither SSH nor PGP). fn signature_header(object: &[u8]) -> Option> { for line in object.split(|&b| b == b'\n') { if line.is_empty() { // End of headers. break; } let value = line .strip_prefix(b"gpgsig ") .or_else(|| line.strip_prefix(b"gpgsig-sha256 ")); if let Some(value) = value { return Some(if value.starts_with(b"-----BEGIN SSH SIGNATURE-----") { Some(SigKind::Ssh) } else if value.starts_with(b"-----BEGIN PGP SIGNATURE-----") { Some(SigKind::Gpg) } else { None }); } } None } /// The committer email of a commit, without the angle brackets. pub async fn committer_email(env: &GitEnv, repo: &Path, oid: &str) -> Result { let object = commit_object(env, repo, oid).await?; for line in object.split(|&b| b == b'\n') { if line.is_empty() { break; } if let Some(who) = line.strip_prefix(b"committer ") { let who = String::from_utf8_lossy(who); let start = who.rfind('<'); let end = who.rfind('>'); return match (start, end) { (Some(s), Some(e)) if s < e => Ok(who[s + 1..e].trim().to_string()), _ => Err(Error::Failed(format!( "commit {oid} has no committer email" ))), }; } } Err(Error::Failed(format!("commit {oid} has no committer line"))) } /// Verifies the signature of `oid` in the repo at `repo` (a git dir). `allowed_signers` is the /// SSH allowed-signers file. `gnupg_home`, when given, is the keyring for GPG signatures. /// /// A non-zero exit from `verify-commit` is normal for a bad or unknown signature. The output is /// parsed either way. Only a failure to start git is an error. pub async fn verify_commit( env: &GitEnv, repo: &Path, oid: &str, allowed_signers: &Path, gnupg_home: Option<&Path>, ) -> Result { let object = commit_object(env, repo, oid).await?; let Some(kind) = signature_header(&object) else { return Ok(RawVerify::unsigned()); }; let allowed = allowed_signers .to_str() .ok_or_else(|| Error::InvalidInput("allowed signers path is not UTF-8".into()))?; let mut cmd = GitCommand::new(env, "verify-commit") .git_dir(repo) .config_checked(format!("gpg.ssh.allowedSignersFile={allowed}"))? .arg("--raw") .user_revs(&[oid])?; if let Some(home) = gnupg_home { cmd = cmd.env("GNUPGHOME", home.as_os_str()); } let out = cmd.into_tokio().output().await?; let mut text = String::from_utf8_lossy(&out.stdout).into_owned(); text.push('\n'); text.push_str(&String::from_utf8_lossy(&out.stderr)); Ok(parse_output(&text, kind)) } /// Reads git's verification output. GPG status lines have the `[GNUPG:]` prefix. SSH results /// are the `Good "git" signature` / `Bad "git" signature` sentences. fn parse_output(text: &str, kind: Option) -> RawVerify { let mut raw = RawVerify { signed: true, kind, ..RawVerify::unsigned() }; for line in text.lines() { let line = line.trim(); if let Some(status) = line.strip_prefix("[GNUPG:] ") { let mut parts = status.split_whitespace(); match parts.next() { Some("VALIDSIG") => { // VALIDSIG [] let fields: Vec<&str> = parts.collect(); let fpr = fields.get(9).or_else(|| fields.first()); if let Some(fpr) = fpr { raw.fingerprint = Some((*fpr).to_string()); } } Some("GOODSIG") => raw.good = true, Some("BADSIG") => raw.bad = true, Some("ERRSIG") | Some("NO_PUBKEY") => raw.missing_key = true, _ => {} } } else if line.starts_with("No principal matched") { raw.missing_key = true; } else if let Some(rest) = line.strip_prefix("Good \"git\" signature") { apply_ssh_sentence(&mut raw, rest); // Without a principal the key matched no allowed signer. git still calls the // signature good, but nobody vouches for the key, so it counts as an unknown key. if raw.principal.is_some() { raw.good = true; } else { raw.missing_key = true; } } else if let Some(rest) = line.strip_prefix("Bad \"git\" signature") { raw.bad = true; apply_ssh_sentence(&mut raw, rest); } } raw } /// `rest` is what follows `Good "git" signature`: either ` for with key /// SHA256:` or ` with key SHA256:`. fn apply_ssh_sentence(raw: &mut RawVerify, rest: &str) { if let Some(idx) = rest.strip_prefix(" for ").and_then(|s| s.find(" with ")) { let after_for = &rest[" for ".len()..]; raw.principal = Some(after_for[..idx].to_string()); } if let Some(idx) = rest.find("SHA256:") { let token = rest[idx..].split_whitespace().next().unwrap_or_default(); raw.fingerprint = Some(token.to_string()); } } #[cfg(test)] mod tests { use super::*; use std::process::Command; /// A git invocation for test setup only, with no user or system config. fn git(home: &Path, dir: &Path, args: &[&str]) -> String { let out = Command::new("git") .arg("-C") .arg(dir) .args(args) .env_clear() .env("PATH", "/usr/local/bin:/usr/bin:/bin") .env("HOME", home) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", home.join("gitconfig")) .output() .expect("run git"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() } fn env_for(home: &Path) -> GitEnv { GitEnv { git_bin: "/usr/bin/git".into(), data_dir: home.to_path_buf(), } } /// Makes a repo with one unsigned commit by `email`, and returns (work dir, git dir). fn repo_with_base(home: &Path, email: &str) -> (std::path::PathBuf, std::path::PathBuf) { let work = home.join("work"); std::fs::create_dir_all(&work).unwrap(); git(home, &work, &["init", "-q", "-b", "main"]); git(home, &work, &["config", "user.name", "Test"]); git(home, &work, &["config", "user.email", email]); std::fs::write(work.join("a.txt"), "a\n").unwrap(); git(home, &work, &["add", "a.txt"]); git( home, &work, &["commit", "-q", "--no-gpg-sign", "-m", "base"], ); let git_dir = work.join(".git"); (work, git_dir) } fn ssh_keygen(home: &Path, name: &str, email: &str) -> std::path::PathBuf { let key = home.join(name); let status = Command::new("ssh-keygen") .args(["-q", "-t", "ed25519", "-N", "", "-C", email, "-f"]) .arg(&key) .status() .expect("run ssh-keygen"); assert!(status.success()); key } /// `allowed_signers` line for `email` from a `.pub` file. fn allowed_line(pub_path: &Path, email: &str) -> String { let text = std::fs::read_to_string(pub_path).unwrap(); let mut fields = text.split_whitespace(); let algo = fields.next().unwrap(); let blob = fields.next().unwrap(); format!("{email} namespaces=\"git\" {algo} {blob}\n") } /// The SHA256 fingerprint of a `.pub` file, as `ssh-keygen -lf` prints it. fn ssh_fingerprint(pub_path: &Path) -> String { let out = Command::new("ssh-keygen") .arg("-lf") .arg(pub_path) .output() .unwrap(); let text = String::from_utf8_lossy(&out.stdout).into_owned(); text.split_whitespace().nth(1).unwrap().to_string() } fn signed_commit(home: &Path, work: &Path, key_pub: &Path, email: &str) { std::fs::write(work.join("b.txt"), email).unwrap(); git(home, work, &["add", "b.txt"]); git( home, work, &[ "-c", "gpg.format=ssh", "-c", &format!("user.signingkey={}", key_pub.display()), "-c", "commit.gpgsign=true", "-c", &format!("user.email={email}"), "commit", "-q", "-m", "signed", ], ); } #[tokio::test] async fn ssh_good_signature_is_parsed() { let home = tempfile::tempdir().unwrap(); let home = home.path(); let email = "alice@example.com"; ssh_keygen(home, "alice", email); let pub_path = home.join("alice.pub"); let (work, git_dir) = repo_with_base(home, email); signed_commit(home, &work, &pub_path, email); let oid = git(home, &work, &["rev-parse", "HEAD"]); let signers = home.join("allowed_signers"); std::fs::write(&signers, allowed_line(&pub_path, email)).unwrap(); let raw = verify_commit(&env_for(home), &git_dir, &oid, &signers, None) .await .unwrap(); assert!(raw.signed); assert_eq!(raw.kind, Some(SigKind::Ssh)); assert!(raw.good, "{raw:?}"); assert!(!raw.bad && !raw.missing_key); assert_eq!(raw.principal.as_deref(), Some(email)); assert_eq!(raw.fingerprint, Some(ssh_fingerprint(&pub_path))); assert_eq!( committer_email(&env_for(home), &git_dir, &oid) .await .unwrap(), email ); } #[tokio::test] async fn ssh_signer_not_in_allowed_signers_is_missing_key() { let home = tempfile::tempdir().unwrap(); let home = home.path(); let email = "alice@example.com"; ssh_keygen(home, "alice", email); let pub_path = home.join("alice.pub"); let (work, git_dir) = repo_with_base(home, email); signed_commit(home, &work, &pub_path, email); let oid = git(home, &work, &["rev-parse", "HEAD"]); let signers = home.join("allowed_signers"); std::fs::write(&signers, "").unwrap(); let raw = verify_commit(&env_for(home), &git_dir, &oid, &signers, None) .await .unwrap(); assert!(raw.signed); assert!(!raw.good); assert!(raw.missing_key, "{raw:?}"); } #[tokio::test] async fn unsigned_commit_is_reported_without_verify() { let home = tempfile::tempdir().unwrap(); let home = home.path(); let email = "alice@example.com"; let (work, git_dir) = repo_with_base(home, email); let oid = git(home, &work, &["rev-parse", "HEAD"]); // The signers file does not exist. An unsigned commit must not reach verify-commit. let signers = home.join("does-not-exist"); let raw = verify_commit(&env_for(home), &git_dir, &oid, &signers, None) .await .unwrap(); assert_eq!(raw, RawVerify::unsigned()); } #[tokio::test] async fn bad_oid_is_rejected_before_git() { let home = tempfile::tempdir().unwrap(); let env = env_for(home.path()); let err = verify_commit( &env, home.path(), "--output=x", &home.path().join("s"), None, ) .await; assert!(matches!(err, Err(Error::InvalidInput(_)))); } #[test] fn parses_gpg_status_lines() { 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"; let raw = parse_output(text, Some(SigKind::Gpg)); assert!(raw.good && !raw.bad && !raw.missing_key); assert_eq!(raw.fingerprint.as_deref(), Some("PRIMARY")); let raw = parse_output("[GNUPG:] NO_PUBKEY 1\n", Some(SigKind::Gpg)); assert!(raw.missing_key && !raw.good); let raw = parse_output("[GNUPG:] BADSIG 1 Alice\n", Some(SigKind::Gpg)); assert!(raw.bad && !raw.good); } #[test] fn parses_ssh_sentences() { let text = "Good \"git\" signature for alice@example.com with ED25519 key SHA256:abc+def\n"; let raw = parse_output(text, Some(SigKind::Ssh)); assert!(raw.good); assert_eq!(raw.principal.as_deref(), Some("alice@example.com")); assert_eq!(raw.fingerprint.as_deref(), Some("SHA256:abc+def")); let raw = parse_output( "Good \"git\" signature with ED25519 key SHA256:xyz\n", Some(SigKind::Ssh), ); assert!(!raw.good && raw.missing_key); assert_eq!(raw.principal, None); assert_eq!(raw.fingerprint.as_deref(), Some("SHA256:xyz")); } #[test] fn signature_header_kinds() { let ssh = b"tree x\nauthor a\ncommitter a 1 +0000\ngpgsig -----BEGIN SSH SIGNATURE-----\n y\n\nmsg"; assert_eq!(signature_header(ssh), Some(Some(SigKind::Ssh))); let pgp = b"tree x\ngpgsig-sha256 -----BEGIN PGP SIGNATURE-----\n\nmsg"; assert_eq!(signature_header(pgp), Some(Some(SigKind::Gpg))); let none = b"tree x\ncommitter a 1 +0000\n\ngpgsig in body"; assert_eq!(signature_header(none), None); } /// GPG round trip. Skipped when `gpg` is not installed. #[tokio::test] async fn gpg_good_signature_is_parsed() { if Command::new("gpg").arg("--version").output().is_err() { eprintln!("skipping gpg test: gpg is not installed"); return; } let home = tempfile::tempdir().unwrap(); let home = home.path(); let gnupg = home.join("gnupg"); std::fs::create_dir_all(&gnupg).unwrap(); std::fs::set_permissions(&gnupg, std::os::unix::fs::PermissionsExt::from_mode(0o700)) .unwrap(); let email = "alice@example.com"; let generated = Command::new("gpg") .env("GNUPGHOME", &gnupg) .args([ "--batch", "--passphrase", "", "--quick-gen-key", email, "default", "default", "never", ]) .output() .unwrap(); assert!( generated.status.success(), "{}", String::from_utf8_lossy(&generated.stderr) ); let listing = Command::new("gpg") .env("GNUPGHOME", &gnupg) .args(["--batch", "--with-colons", "--list-keys", email]) .output() .unwrap(); let listing = String::from_utf8_lossy(&listing.stdout).into_owned(); let primary = listing .lines() .find(|l| l.starts_with("fpr:")) .and_then(|l| l.split(':').nth(9)) .unwrap() .to_string(); let (work, git_dir) = repo_with_base(home, email); std::fs::write(work.join("b.txt"), "b").unwrap(); git(home, &work, &["add", "b.txt"]); let commit = Command::new("git") .arg("-C") .arg(&work) .args([ "-c", &format!("user.signingkey={primary}"), "-c", "commit.gpgsign=true", "-c", &format!("user.email={email}"), "commit", "-q", "-m", "gpg signed", ]) .env_clear() .env("PATH", "/usr/local/bin:/usr/bin:/bin") .env("HOME", home) .env("GNUPGHOME", &gnupg) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", home.join("gitconfig")) .output() .unwrap(); assert!( commit.status.success(), "{}", String::from_utf8_lossy(&commit.stderr) ); let oid = git(home, &work, &["rev-parse", "HEAD"]); let signers = home.join("unused"); let raw = verify_commit( &env_for(home), &git_dir, &oid, &signers, Some(gnupg.as_path()), ) .await .unwrap(); // Stop the agent that gpg started for this keyring. let _ = Command::new("gpgconf") .env("GNUPGHOME", &gnupg) .args(["--kill", "gpg-agent"]) .output(); assert_eq!(raw.kind, Some(SigKind::Gpg)); assert!(raw.good, "{raw:?}"); assert_eq!(raw.fingerprint.as_deref(), Some(primary.as_str())); } }