Skip to content

Commit 7a397b0

Browse files
feat: support x509 commit signing (#2844)
* feat: support x509 commit signing * fix changelog * add e2e test --------- Co-authored-by: extrawurst <776816+extrawurst@users.noreply.github.com> Co-authored-by: rusticorn <mail@rusticorn.com>
1 parent 5583cc9 commit 7a397b0

3 files changed

Lines changed: 294 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ jobs:
5353
run: |
5454
cargo build
5555
56+
# gpgsm: needed by the x509 signing e2e test (unix-only)
57+
- name: Install commit-signing tools (linux)
58+
if: matrix.os == 'ubuntu-latest'
59+
run: sudo apt-get update && sudo apt-get install -y gpgsm
60+
61+
- name: Install commit-signing tools (macos)
62+
if: matrix.os == 'macos-latest'
63+
run: brew install gnupg
64+
5665
- name: Run tests
5766
run: make test
5867

@@ -128,6 +137,9 @@ jobs:
128137
- name: Setup MUSL
129138
run: |
130139
sudo apt-get -qq install musl-tools
140+
# gpgsm: needed by the x509 signing e2e test
141+
- name: Install commit-signing tools
142+
run: sudo apt-get -qq install gpgsm
131143
- name: Build Debug
132144
run: |
133145
make build-linux-musl-debug

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
* support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514))
12+
1013
### Changed
1114
* use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting
1215
* open the external editor from the status diff view [[@WaterWhisperer](https://github.com/WaterWhisperer)] ([#2805](https://github.com/gitui-org/gitui/issues/2805))

asyncgit/src/sync/sign.rs

Lines changed: 279 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,16 +145,24 @@ impl SignBuilder {
145145
// Variants are described in the git config documentation
146146
// https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat
147147
match format.as_str() {
148-
"openpgp" => {
148+
"openpgp" | "x509" => {
149149
// Try to retrieve the gpg program from the git configuration,
150150
// moving from the least to the most specific config key,
151151
// defaulting to "gpg" if nothing is explicitly defined (per git's implementation)
152152
// https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgprogram
153-
// https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgprogram
154153
let program = config
155-
.get_string("gpg.openpgp.program")
154+
.get_string(
155+
format!("gpg.{format}.program").as_str(),
156+
)
156157
.or_else(|_| config.get_string("gpg.program"))
157-
.unwrap_or_else(|_| "gpg".to_string());
158+
.unwrap_or_else(|_| {
159+
(if format == "x509" {
160+
"gpgsm"
161+
} else {
162+
"gpg"
163+
})
164+
.to_string()
165+
});
158166

159167
// Optional signing key.
160168
// If 'user.signingKey' is not set, we'll use 'user.name' and 'user.email'
@@ -184,9 +192,6 @@ impl SignBuilder {
184192
signing_key,
185193
}))
186194
}
187-
"x509" => Err(SignBuilderError::MethodNotImplemented(
188-
String::from("x509"),
189-
)),
190195
"ssh" => {
191196
let ssh_signer = config
192197
.get_string("user.signingKey")
@@ -374,6 +379,8 @@ mod tests {
374379
use super::*;
375380
use crate::error::Result;
376381
use crate::sync::tests::repo_init_empty;
382+
#[cfg(unix)]
383+
use serial_test::serial;
377384

378385
#[test]
379386
fn test_invalid_signing_format() -> Result<()> {
@@ -471,4 +478,269 @@ mod tests {
471478

472479
Ok(())
473480
}
481+
482+
#[test]
483+
fn test_x509_program_defaults() -> Result<()> {
484+
let (_tmp_dir, repo) = repo_init_empty()?;
485+
486+
{
487+
let mut config = repo.config()?;
488+
config.set_str("gpg.format", "x509")?;
489+
}
490+
491+
let sign =
492+
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
493+
494+
// default x509 program should be gpgsm
495+
assert_eq!("gpgsm", sign.program());
496+
// default signing key should be "name <email>" when not specified
497+
assert_eq!("name <email>", sign.signing_key());
498+
499+
Ok(())
500+
}
501+
502+
#[test]
503+
fn test_x509_program_configs() -> Result<()> {
504+
let (_tmp_dir, repo) = repo_init_empty()?;
505+
506+
{
507+
let mut config = repo.config()?;
508+
config.set_str("gpg.format", "x509")?;
509+
config.set_str("gpg.program", "GPG_PROGRAM_TEST")?;
510+
}
511+
512+
let sign =
513+
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
514+
515+
// we get gpg.program, because gpg.x509.program is not set
516+
assert_eq!("GPG_PROGRAM_TEST", sign.program());
517+
518+
{
519+
let mut config = repo.config()?;
520+
config.set_str(
521+
"gpg.x509.program",
522+
"GPG_X509_PROGRAM_TEST",
523+
)?;
524+
}
525+
526+
let sign =
527+
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
528+
529+
// since gpg.x509.program is now set as well, it is more specific than
530+
// gpg.program and therefore takes precedence
531+
assert_eq!("GPG_X509_PROGRAM_TEST", sign.program());
532+
533+
Ok(())
534+
}
535+
536+
/// e2e x509 signing: set up a throwaway `gpgsm` identity, sign a real
537+
/// commit and verify it. Serial + unix-only: uses a process-wide `GNUPGHOME`.
538+
#[cfg(unix)]
539+
#[test]
540+
#[serial]
541+
fn test_x509_sign_and_verify_e2e() -> Result<()> {
542+
use std::os::unix::fs::PermissionsExt;
543+
use std::process::Command;
544+
545+
// note: openssl wants `version`, not `--version`
546+
fn tool_available(bin: &str, version_arg: &str) -> bool {
547+
Command::new(bin)
548+
.arg(version_arg)
549+
.stdout(std::process::Stdio::null())
550+
.stderr(std::process::Stdio::null())
551+
.status()
552+
.map(|s| s.success())
553+
.unwrap_or(false)
554+
}
555+
556+
assert!(
557+
tool_available("gpgsm", "--version"),
558+
"gpgsm is required for the x509 e2e test"
559+
);
560+
assert!(
561+
tool_available("openssl", "version"),
562+
"openssl is required for the x509 e2e test"
563+
);
564+
565+
let email = "gitui-x509-test@example.com";
566+
let gnupg = tempfile::tempdir()?;
567+
let home = gnupg.path();
568+
std::fs::set_permissions(
569+
home,
570+
std::fs::Permissions::from_mode(0o700),
571+
)?;
572+
573+
// pinentry that OKs everything: empty passphrase + auto-trust, no tty.
574+
let pinentry = home.join("fake-pinentry.sh");
575+
std::fs::write(
576+
&pinentry,
577+
"#!/bin/sh\necho \"OK ready\"\nwhile read -r cmd; do\n echo OK\n [ \"$cmd\" = BYE ] && exit 0\ndone\n",
578+
)?;
579+
std::fs::set_permissions(
580+
&pinentry,
581+
std::fs::Permissions::from_mode(0o700),
582+
)?;
583+
std::fs::write(
584+
home.join("gpg-agent.conf"),
585+
format!(
586+
"allow-loopback-pinentry\npinentry-program {}\n",
587+
pinentry.display()
588+
),
589+
)?;
590+
591+
// GPGSign inherits env, so point the child gpgsm at our keyring.
592+
std::env::set_var("GNUPGHOME", home);
593+
594+
let run = |program: &str, args: &[&str]| {
595+
let out = Command::new(program)
596+
.args(args)
597+
.env("GNUPGHOME", home)
598+
.output()
599+
.unwrap_or_else(|e| {
600+
panic!("failed to run {program}: {e}")
601+
});
602+
assert!(
603+
out.status.success(),
604+
"{program} {args:?} failed: {}",
605+
String::from_utf8_lossy(&out.stderr)
606+
);
607+
out
608+
};
609+
610+
let key = home.join("key.pem");
611+
let cert = home.join("cert.pem");
612+
let p12 = home.join("bundle.p12");
613+
run(
614+
"openssl",
615+
&[
616+
"req",
617+
"-x509",
618+
"-newkey",
619+
"rsa:2048",
620+
"-nodes",
621+
"-keyout",
622+
key.to_str().unwrap(),
623+
"-out",
624+
cert.to_str().unwrap(),
625+
"-days",
626+
"3650",
627+
"-subj",
628+
&format!("/CN=gitui test/emailAddress={email}"),
629+
],
630+
);
631+
run(
632+
"openssl",
633+
&[
634+
"pkcs12",
635+
"-export",
636+
"-inkey",
637+
key.to_str().unwrap(),
638+
"-in",
639+
cert.to_str().unwrap(),
640+
"-out",
641+
p12.to_str().unwrap(),
642+
"-passout",
643+
"pass:",
644+
// legacy PBE: gpgsm can't read OpenSSL 3's default PBES2/AES.
645+
"-keypbe",
646+
"PBE-SHA1-3DES",
647+
"-certpbe",
648+
"PBE-SHA1-3DES",
649+
"-macalg",
650+
"sha1",
651+
],
652+
);
653+
run(
654+
"gpgsm",
655+
&[
656+
"--batch",
657+
"--pinentry-mode",
658+
"loopback",
659+
"--passphrase",
660+
"",
661+
"--import",
662+
p12.to_str().unwrap(),
663+
],
664+
);
665+
666+
// trust our self-signed root ("S" relaxes CA checks) so gpgsm will sign.
667+
let listing = run(
668+
"gpgsm",
669+
&["--batch", "--with-colons", "--list-secret-keys"],
670+
);
671+
let listing = String::from_utf8_lossy(&listing.stdout);
672+
let fingerprint = listing
673+
.lines()
674+
.filter_map(|line| line.strip_prefix("fpr:"))
675+
.find_map(|rest| {
676+
rest.split(':').find(|field| {
677+
field.len() == 40
678+
&& field
679+
.bytes()
680+
.all(|b| b.is_ascii_hexdigit())
681+
})
682+
})
683+
.expect("could not determine cert fingerprint");
684+
std::fs::write(
685+
home.join("trustlist.txt"),
686+
format!("{fingerprint} S\n"),
687+
)?;
688+
// reload gpg-agent to read the new trustlist
689+
run("gpgconf", &["--kill", "gpg-agent"]);
690+
691+
let (_tmp_dir, repo) = repo_init_empty()?;
692+
{
693+
let mut config = repo.config()?;
694+
config.set_str("gpg.format", "x509")?;
695+
config.set_str("user.signingKey", email)?;
696+
}
697+
let signer =
698+
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
699+
assert_eq!("gpgsm", signer.program());
700+
701+
let sig = git2::Signature::now("gitui test", email)?;
702+
let tree = {
703+
let mut index = repo.index()?;
704+
let tree_id = index.write_tree()?;
705+
repo.find_tree(tree_id)?
706+
};
707+
let commit_id = create_signed_commit(
708+
&repo,
709+
&*signer,
710+
&sig,
711+
&sig,
712+
"x509 signed commit",
713+
&tree,
714+
&[],
715+
)?;
716+
717+
let (signature, signed_data) =
718+
repo.extract_signature(&commit_id, None)?;
719+
let signature = std::str::from_utf8(&signature).unwrap();
720+
assert!(
721+
signature.contains("BEGIN SIGNED MESSAGE"),
722+
"expected an armored CMS signature, got: {signature}"
723+
);
724+
725+
let sig_file = home.join("commit.sig");
726+
let data_file = home.join("commit.data");
727+
std::fs::write(&sig_file, signature)?;
728+
std::fs::write(&data_file, &*signed_data)?;
729+
let verify = run(
730+
"gpgsm",
731+
&[
732+
"--verify",
733+
sig_file.to_str().unwrap(),
734+
data_file.to_str().unwrap(),
735+
],
736+
);
737+
let verify_err = String::from_utf8_lossy(&verify.stderr);
738+
assert!(
739+
verify_err.contains("Good signature"),
740+
"gpgsm did not accept the signature: {verify_err}"
741+
);
742+
743+
std::env::remove_var("GNUPGHOME");
744+
Ok(())
745+
}
474746
}

0 commit comments

Comments
 (0)