From 4acae06fc764b026a2e589d61288a9effe6386d2 Mon Sep 17 00:00:00 2001 From: Kaden Nelson Date: Thu, 8 Jan 2026 12:14:17 -0600 Subject: [PATCH 1/4] feat: support x509 commit signing --- CHANGELOG.md | 1 + asyncgit/src/sync/sign.rs | 73 +++++++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db667675c..072218a80a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ![blame-goto-line](assets/blame-goto-line.png) ### Added +* support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) * support choosing checkout branch method when status is not empty [[@fatpandac](https://github.com/fatpandac)] ([#2404](https://github.com/extrawurst/gitui/issues/2404)) * support pre-push hook [[@xlai89](https://github.com/xlai89)] ([#1933](https://github.com/extrawurst/gitui/issues/1933)) * message tab supports pageUp and pageDown [[@xlai89](https://github.com/xlai89)] ([#2623](https://github.com/extrawurst/gitui/issues/2623)) diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs index bde0fc53d2..8f5f248620 100644 --- a/asyncgit/src/sync/sign.rs +++ b/asyncgit/src/sync/sign.rs @@ -113,16 +113,24 @@ impl SignBuilder { // Variants are described in the git config documentation // https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat match format.as_str() { - "openpgp" => { + "openpgp" | "x509" => { // Try to retrieve the gpg program from the git configuration, // moving from the least to the most specific config key, // defaulting to "gpg" if nothing is explicitly defined (per git's implementation) // https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgprogram - // https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgprogram let program = config - .get_string("gpg.openpgp.program") + .get_string( + format!("gpg.{format}.program").as_str(), + ) .or_else(|_| config.get_string("gpg.program")) - .unwrap_or_else(|_| "gpg".to_string()); + .unwrap_or_else(|_| { + (if format == "x509" { + "gpgsm" + } else { + "gpg" + }) + .to_string() + }); // Optional signing key. // If 'user.signingKey' is not set, we'll use 'user.name' and 'user.email' @@ -152,9 +160,6 @@ impl SignBuilder { signing_key, })) } - "x509" => Err(SignBuilderError::MethodNotImplemented( - String::from("x509"), - )), "ssh" => { let ssh_signer = config .get_string("user.signingKey") @@ -439,4 +444,58 @@ mod tests { Ok(()) } + + #[test] + fn test_x509_program_defaults() -> Result<()> { + let (_tmp_dir, repo) = repo_init_empty()?; + + { + let mut config = repo.config()?; + config.set_str("gpg.format", "x509")?; + } + + let sign = + SignBuilder::from_gitconfig(&repo, &repo.config()?)?; + + // default x509 program should be gpgsm + assert_eq!("gpgsm", sign.program()); + // default signing key should be "name " when not specified + assert_eq!("name ", sign.signing_key()); + + Ok(()) + } + + #[test] + fn test_x509_program_configs() -> Result<()> { + let (_tmp_dir, repo) = repo_init_empty()?; + + { + let mut config = repo.config()?; + config.set_str("gpg.format", "x509")?; + config.set_str("gpg.program", "GPG_PROGRAM_TEST")?; + } + + let sign = + SignBuilder::from_gitconfig(&repo, &repo.config()?)?; + + // we get gpg.program, because gpg.x509.program is not set + assert_eq!("GPG_PROGRAM_TEST", sign.program()); + + { + let mut config = repo.config()?; + config.set_str( + "gpg.x509.program", + "GPG_X509_PROGRAM_TEST", + )?; + } + + let sign = + SignBuilder::from_gitconfig(&repo, &repo.config()?)?; + + // since gpg.x509.program is now set as well, it is more specific than + // gpg.program and therefore takes precedence + assert_eq!("GPG_X509_PROGRAM_TEST", sign.program()); + + Ok(()) + } } From d28e3cf926a287ceab85099ba3fec58c7562c9cf Mon Sep 17 00:00:00 2001 From: rusticorn Date: Wed, 1 Jul 2026 23:24:48 +0200 Subject: [PATCH 2/4] fix changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5568a2a07..68c2158509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +* support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) + ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting * open the external editor from the status diff view [[@WaterWhisperer](https://github.com/WaterWhisperer)] ([#2805](https://github.com/gitui-org/gitui/issues/2805)) @@ -38,7 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ![blame-goto-line](assets/blame-goto-line.png) ### Added -* support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) * support choosing checkout branch method when status is not empty [[@fatpandac](https://github.com/fatpandac)] ([#2404](https://github.com/extrawurst/gitui/issues/2404)) * support pre-push hook [[@xlai89](https://github.com/xlai89)] ([#1933](https://github.com/extrawurst/gitui/issues/1933)) * message tab supports pageUp and pageDown [[@xlai89](https://github.com/xlai89)] ([#2623](https://github.com/extrawurst/gitui/issues/2623)) From 822a8438442c039e456fff5a25f7a7bbd41709a9 Mon Sep 17 00:00:00 2001 From: rusticorn Date: Wed, 1 Jul 2026 23:32:43 +0200 Subject: [PATCH 3/4] add e2e test --- asyncgit/src/sync/sign.rs | 227 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs index 4dd47951a7..b2e86f57f0 100644 --- a/asyncgit/src/sync/sign.rs +++ b/asyncgit/src/sync/sign.rs @@ -379,6 +379,8 @@ mod tests { use super::*; use crate::error::Result; use crate::sync::tests::repo_init_empty; + #[cfg(target_os = "linux")] + use serial_test::serial; #[test] fn test_invalid_signing_format() -> Result<()> { @@ -530,4 +532,229 @@ mod tests { Ok(()) } + + /// End-to-end x509 signing: set up a throwaway `gpgsm` identity, sign a + /// real commit through [`SignBuilder`]/[`create_signed_commit`] and verify + /// the embedded CMS signature with `gpgsm --verify`. + /// + /// Linux-only and serial because it drives `gpg-agent` via a process-wide + /// `GNUPGHOME`. Asserts that `gpgsm`/`openssl` are installed. + #[cfg(target_os = "linux")] + #[test] + #[serial] + fn test_x509_sign_and_verify_e2e() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + // note: openssl wants `version`, not `--version` + fn tool_available(bin: &str, version_arg: &str) -> bool { + Command::new(bin) + .arg(version_arg) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + assert!( + tool_available("gpgsm", "--version"), + "gpgsm is required for the x509 e2e test" + ); + assert!( + tool_available("openssl", "version"), + "openssl is required for the x509 e2e test" + ); + + let email = "gitui-x509-test@example.com"; + let gnupg = tempfile::tempdir()?; + let home = gnupg.path(); + std::fs::set_permissions( + home, + std::fs::Permissions::from_mode(0o700), + )?; + + // fake pinentry: answers every prompt with OK, i.e. an empty passphrase + // and an automatic "yes" to the root-trust question, so gpgsm never + // blocks on a tty. + let pinentry = home.join("fake-pinentry.sh"); + std::fs::write( + &pinentry, + "#!/bin/sh\necho \"OK ready\"\nwhile read -r cmd; do\n echo OK\n [ \"$cmd\" = BYE ] && exit 0\ndone\n", + )?; + std::fs::set_permissions( + &pinentry, + std::fs::Permissions::from_mode(0o700), + )?; + std::fs::write( + home.join("gpg-agent.conf"), + format!( + "allow-loopback-pinentry\npinentry-program {}\n", + pinentry.display() + ), + )?; + + // GPGSign spawns `gpgsm` without an env override, so it has to find our + // throwaway keyring through the process environment. + std::env::set_var("GNUPGHOME", home); + + let run = |program: &str, args: &[&str]| { + let out = Command::new(program) + .args(args) + .env("GNUPGHOME", home) + .output() + .unwrap_or_else(|e| { + panic!("failed to run {program}: {e}") + }); + assert!( + out.status.success(), + "{program} {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + out + }; + + // self-signed x509 cert + key, bundled as PKCS#12 for gpgsm. + let key = home.join("key.pem"); + let cert = home.join("cert.pem"); + let p12 = home.join("bundle.p12"); + run( + "openssl", + &[ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + key.to_str().unwrap(), + "-out", + cert.to_str().unwrap(), + "-days", + "3650", + "-subj", + &format!("/CN=gitui test/emailAddress={email}"), + ], + ); + run( + "openssl", + &[ + "pkcs12", + "-export", + "-inkey", + key.to_str().unwrap(), + "-in", + cert.to_str().unwrap(), + "-out", + p12.to_str().unwrap(), + "-passout", + "pass:", + // OpenSSL 3 defaults to PBES2/AES which gpgsm can't + // decrypt; force the legacy PKCS#12 3DES PBE it reads. + "-keypbe", + "PBE-SHA1-3DES", + "-certpbe", + "PBE-SHA1-3DES", + "-macalg", + "sha1", + ], + ); + run( + "gpgsm", + &[ + "--batch", + "--pinentry-mode", + "loopback", + "--passphrase", + "", + "--import", + p12.to_str().unwrap(), + ], + ); + + // gpgsm refuses to sign with an untrusted root, so mark our + // self-signed cert trusted by writing its fingerprint into + // trustlist.txt ("S" relaxes the otherwise-strict CA checks). + let listing = run( + "gpgsm", + &["--batch", "--with-colons", "--list-secret-keys"], + ); + let listing = String::from_utf8_lossy(&listing.stdout); + let fingerprint = listing + .lines() + .filter_map(|line| line.strip_prefix("fpr:")) + .find_map(|rest| { + rest.split(':').find(|field| { + field.len() == 40 + && field + .bytes() + .all(|b| b.is_ascii_hexdigit()) + }) + }) + .expect("could not determine cert fingerprint"); + std::fs::write( + home.join("trustlist.txt"), + format!("{fingerprint} S\n"), + )?; + // reload gpg-agent so it picks up the new trustlist + run("gpgconf", &["--kill", "gpg-agent"]); + + // configure the repo for x509 signing and build the signer. + let (_tmp_dir, repo) = repo_init_empty()?; + { + let mut config = repo.config()?; + config.set_str("gpg.format", "x509")?; + config.set_str("user.signingKey", email)?; + } + let signer = + SignBuilder::from_gitconfig(&repo, &repo.config()?)?; + assert_eq!("gpgsm", signer.program()); + + // sign an initial commit through the production code path. + let sig = git2::Signature::now("gitui test", email)?; + let tree = { + let mut index = repo.index()?; + let tree_id = index.write_tree()?; + repo.find_tree(tree_id)? + }; + let commit_id = create_signed_commit( + &repo, + &*signer, + &sig, + &sig, + "x509 signed commit", + &tree, + &[], + )?; + + // the commit must carry a CMS signature that gpgsm accepts. + let (signature, signed_data) = + repo.extract_signature(&commit_id, None)?; + let signature = std::str::from_utf8(&signature).unwrap(); + assert!( + signature.contains("BEGIN SIGNED MESSAGE"), + "expected an armored CMS signature, got: {signature}" + ); + + let sig_file = home.join("commit.sig"); + let data_file = home.join("commit.data"); + std::fs::write(&sig_file, signature)?; + std::fs::write(&data_file, &*signed_data)?; + let verify = run( + "gpgsm", + &[ + "--verify", + sig_file.to_str().unwrap(), + data_file.to_str().unwrap(), + ], + ); + let verify_err = String::from_utf8_lossy(&verify.stderr); + assert!( + verify_err.contains("Good signature"), + "gpgsm did not accept the signature: {verify_err}" + ); + + std::env::remove_var("GNUPGHOME"); + Ok(()) + } } From 093ba7fdd07f5f0188037b379efca6fa16a17256 Mon Sep 17 00:00:00 2001 From: rusticorn Date: Wed, 1 Jul 2026 23:44:05 +0200 Subject: [PATCH 4/4] enable test also on macos --- .github/workflows/ci.yml | 12 ++++++++++++ asyncgit/src/sync/sign.rs | 32 +++++++++----------------------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e34dd8401b..eaf4b34458 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,15 @@ jobs: run: | cargo build + # gpgsm: needed by the x509 signing e2e test (unix-only) + - name: Install commit-signing tools (linux) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y gpgsm + + - name: Install commit-signing tools (macos) + if: matrix.os == 'macos-latest' + run: brew install gnupg + - name: Run tests run: make test @@ -128,6 +137,9 @@ jobs: - name: Setup MUSL run: | sudo apt-get -qq install musl-tools + # gpgsm: needed by the x509 signing e2e test + - name: Install commit-signing tools + run: sudo apt-get -qq install gpgsm - name: Build Debug run: | make build-linux-musl-debug diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs index b2e86f57f0..7b5e1de891 100644 --- a/asyncgit/src/sync/sign.rs +++ b/asyncgit/src/sync/sign.rs @@ -379,7 +379,7 @@ mod tests { use super::*; use crate::error::Result; use crate::sync::tests::repo_init_empty; - #[cfg(target_os = "linux")] + #[cfg(unix)] use serial_test::serial; #[test] @@ -533,13 +533,9 @@ mod tests { Ok(()) } - /// End-to-end x509 signing: set up a throwaway `gpgsm` identity, sign a - /// real commit through [`SignBuilder`]/[`create_signed_commit`] and verify - /// the embedded CMS signature with `gpgsm --verify`. - /// - /// Linux-only and serial because it drives `gpg-agent` via a process-wide - /// `GNUPGHOME`. Asserts that `gpgsm`/`openssl` are installed. - #[cfg(target_os = "linux")] + /// e2e x509 signing: set up a throwaway `gpgsm` identity, sign a real + /// commit and verify it. Serial + unix-only: uses a process-wide `GNUPGHOME`. + #[cfg(unix)] #[test] #[serial] fn test_x509_sign_and_verify_e2e() -> Result<()> { @@ -574,9 +570,7 @@ mod tests { std::fs::Permissions::from_mode(0o700), )?; - // fake pinentry: answers every prompt with OK, i.e. an empty passphrase - // and an automatic "yes" to the root-trust question, so gpgsm never - // blocks on a tty. + // pinentry that OKs everything: empty passphrase + auto-trust, no tty. let pinentry = home.join("fake-pinentry.sh"); std::fs::write( &pinentry, @@ -594,8 +588,7 @@ mod tests { ), )?; - // GPGSign spawns `gpgsm` without an env override, so it has to find our - // throwaway keyring through the process environment. + // GPGSign inherits env, so point the child gpgsm at our keyring. std::env::set_var("GNUPGHOME", home); let run = |program: &str, args: &[&str]| { @@ -614,7 +607,6 @@ mod tests { out }; - // self-signed x509 cert + key, bundled as PKCS#12 for gpgsm. let key = home.join("key.pem"); let cert = home.join("cert.pem"); let p12 = home.join("bundle.p12"); @@ -649,8 +641,7 @@ mod tests { p12.to_str().unwrap(), "-passout", "pass:", - // OpenSSL 3 defaults to PBES2/AES which gpgsm can't - // decrypt; force the legacy PKCS#12 3DES PBE it reads. + // legacy PBE: gpgsm can't read OpenSSL 3's default PBES2/AES. "-keypbe", "PBE-SHA1-3DES", "-certpbe", @@ -672,9 +663,7 @@ mod tests { ], ); - // gpgsm refuses to sign with an untrusted root, so mark our - // self-signed cert trusted by writing its fingerprint into - // trustlist.txt ("S" relaxes the otherwise-strict CA checks). + // trust our self-signed root ("S" relaxes CA checks) so gpgsm will sign. let listing = run( "gpgsm", &["--batch", "--with-colons", "--list-secret-keys"], @@ -696,10 +685,9 @@ mod tests { home.join("trustlist.txt"), format!("{fingerprint} S\n"), )?; - // reload gpg-agent so it picks up the new trustlist + // reload gpg-agent to read the new trustlist run("gpgconf", &["--kill", "gpg-agent"]); - // configure the repo for x509 signing and build the signer. let (_tmp_dir, repo) = repo_init_empty()?; { let mut config = repo.config()?; @@ -710,7 +698,6 @@ mod tests { SignBuilder::from_gitconfig(&repo, &repo.config()?)?; assert_eq!("gpgsm", signer.program()); - // sign an initial commit through the production code path. let sig = git2::Signature::now("gitui test", email)?; let tree = { let mut index = repo.index()?; @@ -727,7 +714,6 @@ mod tests { &[], )?; - // the commit must carry a CMS signature that gpgsm accepts. let (signature, signed_data) = repo.extract_signature(&commit_id, None)?; let signature = std::str::from_utf8(&signature).unwrap();