@@ -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\n echo \" OK ready\" \n while read -r cmd; do\n echo OK\n [ \" $cmd\" = BYE ] && exit 0\n done\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\n pinentry-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