From eb65af631da2c180fa9ecb1a76701f9d914fa660 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Mon, 6 Oct 2025 14:45:25 +0200 Subject: [PATCH 1/5] doc: git-tag: stop focusing on GPG signed tags It looks like the documentation of `git tag` is focused a bit too much on GPG signed tags. This starts with the "NAME" section where the command is described with: "Create, list, delete or verify a tag object signed with GPG" while for example `git branch` is described with simply: "List, create, or delete branches" This could give the false impression that `git tag` only works with tag objects, not with lightweight tags, and that tag objects are always GPG signed. In the "DESCRIPTION" section, it looks like only "GnuPG signed tag objects" can be created by the `-s` and `-u ` options, and it seems `gpg.program` can only specify a "custom GnuPG binary". This goes on in the "OPTIONS" section too, especially about the `-s` and `-u ` options. The "CONFIGURATION" section also doesn't talk about how to configure the command to work with X.509 and SSH signatures. Let's rework all that to make sure users have a more accurate and balanced view of what the command can do. Helped-by: Patrick Steinhardt Signed-off-by: Christian Couder --- Documentation/git-tag.adoc | 48 ++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/Documentation/git-tag.adoc b/Documentation/git-tag.adoc index a4b1c0ec05a..3519e5b9b23 100644 --- a/Documentation/git-tag.adoc +++ b/Documentation/git-tag.adoc @@ -3,7 +3,7 @@ git-tag(1) NAME ---- -git-tag - Create, list, delete or verify a tag object signed with GPG +git-tag - Create, list, delete or verify tags SYNOPSIS @@ -38,15 +38,17 @@ and `-a`, `-s`, and `-u ` are absent, `-a` is implied. Otherwise, a tag reference that points directly at the given object (i.e., a lightweight tag) is created. -A GnuPG signed tag object will be created when `-s` or `-u -` is used. When `-u ` is not used, the -committer identity for the current user is used to find the -GnuPG key for signing. The configuration variable `gpg.program` -is used to specify custom GnuPG binary. +A cryptographically signed tag object will be created when `-s` or +`-u ` is used. The signing backend (GPG, X.509, SSH, etc.) is +controlled by the `gpg.format` configuration variable, defaulting to +OpenPGP. When `-u ` is not used, the committer identity for +the current user is used to find the key for signing. The +configuration variable `gpg.program` is used to specify a custom +signing binary. Tag objects (created with `-a`, `-s`, or `-u`) are called "annotated" tags; they contain a creation date, the tagger name and e-mail, a -tagging message, and an optional GnuPG signature. Whereas a +tagging message, and an optional cryptographic signature. Whereas a "lightweight" tag is simply a name for an object (usually a commit object). @@ -64,10 +66,12 @@ OPTIONS -s:: --sign:: - Make a GPG-signed tag, using the default e-mail address's key. - The default behavior of tag GPG-signing is controlled by `tag.gpgSign` - configuration variable if it exists, or disabled otherwise. - See linkgit:git-config[1]. + Make a cryptographically signed tag, using the default signing + key. The signing backend used depends on the `gpg.format` + configuration variable. The default key is determined by the + backend. For GPG, it's based on the committer's email address, + while for SSH it may be a specific key file or agent + identity. See linkgit:git-config[1]. --no-sign:: Override `tag.gpgSign` configuration variable that is @@ -75,7 +79,10 @@ OPTIONS -u :: --local-user=:: - Make a GPG-signed tag, using the given key. + Make a cryptographically signed tag using the given key. The + format of the and the backend used depend on the + `gpg.format` configuration variable. See + linkgit:git-config[1]. -f:: --force:: @@ -87,7 +94,7 @@ OPTIONS -v:: --verify:: - Verify the GPG signature of the given tag names. + Verify the signature of the given tag names. -n:: specifies how many lines from the annotation, if any, @@ -236,12 +243,23 @@ it in the repository configuration as follows: ------------------------------------- [user] - signingKey = + signingKey = ------------------------------------- +The signing backend can be chosen via the `gpg.format` configuration +variable, which defaults to `openpgp`. See linkgit:git-config[1] +for a list of other supported formats. + +The path to the program used for each signing backend can be specified +with the `gpg..program` configuration variable. For the +`openpgp` backend, `gpg.program` can be used as a synonym for +`gpg.openpgp.program`. See linkgit:git-config[1] for details. + `pager.tag` is only respected when listing tags, i.e., when `-l` is used or implied. The default is to use a pager. -See linkgit:git-config[1]. + +See linkgit:git-config[1] for more details and other configuration +variables. DISCUSSION ---------- -- GitLab From 640204ef26d73f578403d40ad1d99ae19baf4aa7 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Mon, 6 Oct 2025 12:49:23 +0200 Subject: [PATCH 2/5] lib-gpg: allow tests with GPGSM or GPGSSH prereq first When the 'GPG' prereq is lazily tested, `mkdir "$GNUPGHOME"` could fail if the "$GNUPGHOME" directory already exists. This can happen if the 'GPGSM' or the 'GPGSSH' prereq has been lazily tested before as they already create "$GNUPGHOME". To allow the GPGSM or the GPGSSH prereq to appear before the GPG prereq in some test scripts, let's refactor the creation and setup of the "$GNUPGHOME"` directory in a new prepare_gnupghome() function that uses `mkdir -p "$GNUPGHOME"`. This will be useful in a following commit. Unfortunately the new prepare_gnupghome() function cannot be used when lazily testing the GPG2 prereq, because that would expose existing, hidden bugs in "t1016-compatObjectFormat.sh", so let's just document that with a NEEDSWORK comment. Helped-by: Todd Zullinger Helped-by: Collin Funk Signed-off-by: Christian Couder --- t/lib-gpg.sh | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/t/lib-gpg.sh b/t/lib-gpg.sh index 937b876bd05..b99ae39a06b 100644 --- a/t/lib-gpg.sh +++ b/t/lib-gpg.sh @@ -9,6 +9,16 @@ GNUPGHOME="$(pwd)/gpghome" export GNUPGHOME +# All the "test_lazy_prereq GPG*" below should use +# `prepare_gnupghome()` either directly or through a call to +# `test_have_prereq GPG*`. That's because `gpg` and `gpgsm` +# only create the directory specified using "$GNUPGHOME" or +# `--homedir` if it's the default (usually "~/.gnupg"). +prepare_gnupghome() { + mkdir -p "$GNUPGHOME" && + chmod 0700 "$GNUPGHOME" +} + test_lazy_prereq GPG ' gpg_version=$(gpg --version 2>&1) test $? != 127 || exit 1 @@ -38,8 +48,7 @@ test_lazy_prereq GPG ' # To export ownertrust: # gpg --homedir /tmp/gpghome --export-ownertrust \ # > lib-gpg/ownertrust - mkdir "$GNUPGHOME" && - chmod 0700 "$GNUPGHOME" && + prepare_gnupghome && (gpgconf --kill all || : ) && gpg --homedir "${GNUPGHOME}" --import \ "$TEST_DIRECTORY"/lib-gpg/keyring.gpg && @@ -63,6 +72,14 @@ test_lazy_prereq GPG2 ' ;; *) (gpgconf --kill all || : ) && + + # NEEDSWORK: prepare_gnupghome() should definitely be + # called here, but it looks like it exposes a + # pre-existing, hidden bug by allowing some tests in + # t1016-compatObjectFormat.sh to run instead of being + # skipped. See: + # https://lore.kernel.org/git/ZoV8b2RvYxLOotSJ@teonanacatl.net/ + gpg --homedir "${GNUPGHOME}" --import \ "$TEST_DIRECTORY"/lib-gpg/keyring.gpg && gpg --homedir "${GNUPGHOME}" --import-ownertrust \ @@ -132,8 +149,7 @@ test_lazy_prereq GPGSSH ' test $? = 0 || exit 1; # Setup some keys and an allowed signers file - mkdir -p "${GNUPGHOME}" && - chmod 0700 "${GNUPGHOME}" && + prepare_gnupghome && (setfacl -k "${GNUPGHOME}" 2>/dev/null || true) && ssh-keygen -t ed25519 -N "" -C "git ed25519 key" -f "${GPGSSH_KEY_PRIMARY}" >/dev/null && ssh-keygen -t rsa -b 2048 -N "" -C "git rsa2048 key" -f "${GPGSSH_KEY_SECONDARY}" >/dev/null && -- GitLab From 8f788bafe1a1257aa68e3a4b02043995efbfd9d6 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Mon, 6 Oct 2025 15:58:33 +0200 Subject: [PATCH 3/5] t9350: properly count annotated tags In "t9350-fast-export.sh", these existing tests: - 'fast-export | fast-import when main is tagged' - 'cope with tagger-less tags' are checking the number of annotated tags in the test repo by comparing it with some hardcoded values. This could be an issue if some new tests that have some prerequisites add new annotated tags to the repo before these existing tests. When the prerequisites would be satisfied, the number of annotated tags would be different from when some prerequisites would not be satisfied. As we are going to add new tests that add new annotated tags in a following commit, let's properly count the number of annotated tag in the repo by incrementing a counter each time a new annotated tag is added, and then by comparing the number of annotated tags to the value of the counter when checking the number of annotated tags. This is a bit ugly, but it makes it explicit that some tests are interdependent. Alternative solutions, like moving the new tests to the end of the script, were considered, but were rejected because they would instead hide the technical debt and could confuse developers in the future. Signed-off-by: Christian Couder --- t/t9350-fast-export.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/t/t9350-fast-export.sh b/t/t9350-fast-export.sh index 8f85c69d62f..21ff26939c6 100755 --- a/t/t9350-fast-export.sh +++ b/t/t9350-fast-export.sh @@ -35,6 +35,7 @@ test_expect_success 'setup' ' git commit -m sitzt file2 && test_tick && git tag -a -m valentin muss && + ANNOTATED_TAG_COUNT=1 && git merge -s ours main ' @@ -229,7 +230,8 @@ EOF test_expect_success 'set up faked signed tag' ' - git fast-import output && - test $(grep -c "^tag " output) = 3 + test $(grep -c "^tag " output) = $ANNOTATED_TAG_COUNT ' @@ -506,12 +509,13 @@ test_expect_success 'cope with tagger-less tags' ' TAG=$(git hash-object --literally -t tag -w tag-content) && git update-ref refs/tags/sonnenschein $TAG && + ANNOTATED_TAG_COUNT=$((ANNOTATED_TAG_COUNT + 1)) && git fast-export -C -C --signed-tags=strip --all > output && - test $(grep -c "^tag " output) = 4 && + test $(grep -c "^tag " output) = $ANNOTATED_TAG_COUNT && ! grep "Unspecified Tagger" output && git fast-export -C -C --signed-tags=strip --all \ --fake-missing-tagger > output && - test $(grep -c "^tag " output) = 4 && + test $(grep -c "^tag " output) = $ANNOTATED_TAG_COUNT && grep "Unspecified Tagger" output ' -- GitLab From d62a43905c0febc7be5c6ea6841c839dafacad17 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Thu, 2 Oct 2025 11:49:09 +0200 Subject: [PATCH 4/5] fast-export: handle all kinds of tag signatures Currently the handle_tag() function in "builtin/fast-export.c" searches only for "\n-----BEGIN PGP SIGNATURE-----\n" in the tag message to find a tag signature. This doesn't handle all kinds of OpenPGP signatures as some can start with "-----BEGIN PGP MESSAGE-----" too, and this doesn't handle SSH and X.509 signatures either as they use "-----BEGIN SSH SIGNATURE-----" and "-----BEGIN SIGNED MESSAGE-----" respectively. To handle all these kinds of tag signatures supported by Git, let's use the parse_signed_buffer() function to properly find signatures in tag messages. Signed-off-by: Christian Couder --- builtin/fast-export.c | 7 +++---- t/t9350-fast-export.sh | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/builtin/fast-export.c b/builtin/fast-export.c index dc2486f9a83..7adbc55f0dc 100644 --- a/builtin/fast-export.c +++ b/builtin/fast-export.c @@ -931,9 +931,8 @@ static void handle_tag(const char *name, struct tag *tag) /* handle signed tags */ if (message) { - const char *signature = strstr(message, - "\n-----BEGIN PGP SIGNATURE-----\n"); - if (signature) + size_t sig_offset = parse_signed_buffer(message, message_size); + if (sig_offset < message_size) switch (signed_tag_mode) { case SIGN_ABORT: die("encountered signed tag %s; use " @@ -950,7 +949,7 @@ static void handle_tag(const char *name, struct tag *tag) oid_to_hex(&tag->object.oid)); /* fallthru */ case SIGN_STRIP: - message_size = signature + 1 - message; + message_size = sig_offset; break; } } diff --git a/t/t9350-fast-export.sh b/t/t9350-fast-export.sh index 21ff26939c6..3d153a4805b 100755 --- a/t/t9350-fast-export.sh +++ b/t/t9350-fast-export.sh @@ -279,6 +279,42 @@ test_expect_success 'signed-tags=warn-strip' ' test -s err ' +test_expect_success GPGSM 'setup X.509 signed tag' ' + test_config gpg.format x509 && + test_config user.signingkey $GIT_COMMITTER_EMAIL && + + git tag -s -m "X.509 signed tag" x509-signed $(git rev-parse HEAD) && + ANNOTATED_TAG_COUNT=$((ANNOTATED_TAG_COUNT + 1)) +' + +test_expect_success GPGSM 'signed-tags=verbatim with X.509' ' + git fast-export --signed-tags=verbatim x509-signed > output && + test_grep "SIGNED MESSAGE" output +' + +test_expect_success GPGSM 'signed-tags=strip with X.509' ' + git fast-export --signed-tags=strip x509-signed > output && + test_grep ! "SIGNED MESSAGE" output +' + +test_expect_success GPGSSH 'setup SSH signed tag' ' + test_config gpg.format ssh && + test_config user.signingkey "${GPGSSH_KEY_PRIMARY}" && + + git tag -s -m "SSH signed tag" ssh-signed $(git rev-parse HEAD) && + ANNOTATED_TAG_COUNT=$((ANNOTATED_TAG_COUNT + 1)) +' + +test_expect_success GPGSSH 'signed-tags=verbatim with SSH' ' + git fast-export --signed-tags=verbatim ssh-signed > output && + test_grep "SSH SIGNATURE" output +' + +test_expect_success GPGSSH 'signed-tags=strip with SSH' ' + git fast-export --signed-tags=strip ssh-signed > output && + test_grep ! "SSH SIGNATURE" output +' + test_expect_success GPG 'set up signed commit' ' # Generate a commit with both "gpgsig" and "encoding" set, so -- GitLab From 9094f37b467542692f8e5b2051d8b412de91bc87 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Mon, 6 Oct 2025 13:00:36 +0200 Subject: [PATCH 5/5] fast-import: add '--signed-tags=' option Recently, eaaddf5791 (fast-import: add '--signed-commits=' option, 2025-09-17) added support for controlling how signed commits are handled by `git fast-import`, but there is no option yet to decide about signed tags. To remediate that, let's add a '--signed-tags=' option to `git fast-import` too. With this, both `git fast-export` and `git fast-import` have both a '--signed-tags=' and a '--signed-commits=' supporting the same s. Signed-off-by: Christian Couder --- Documentation/git-fast-import.adoc | 5 ++ builtin/fast-import.c | 43 ++++++++++++++++ t/meson.build | 1 + t/t9306-fast-import-signed-tags.sh | 80 ++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100755 t/t9306-fast-import-signed-tags.sh diff --git a/Documentation/git-fast-import.adoc b/Documentation/git-fast-import.adoc index 85ed7a72703..b74179a6c89 100644 --- a/Documentation/git-fast-import.adoc +++ b/Documentation/git-fast-import.adoc @@ -66,6 +66,11 @@ fast-import stream! This option is enabled automatically for remote-helpers that use the `import` capability, as they are already trusted to run their own code. +--signed-tags=(verbatim|warn-verbatim|warn-strip|strip|abort):: + Specify how to handle signed tags. Behaves in the same way + as the same option in linkgit:git-fast-export[1], except that + default is 'verbatim' (instead of 'abort'). + --signed-commits=(verbatim|warn-verbatim|warn-strip|strip|abort):: Specify how to handle signed commits. Behaves in the same way as the same option in linkgit:git-fast-export[1], except that diff --git a/builtin/fast-import.c b/builtin/fast-import.c index 2010e78475b..60d6faa4655 100644 --- a/builtin/fast-import.c +++ b/builtin/fast-import.c @@ -188,6 +188,7 @@ static int global_argc; static const char **global_argv; static const char *global_prefix; +static enum sign_mode signed_tag_mode = SIGN_VERBATIM; static enum sign_mode signed_commit_mode = SIGN_VERBATIM; /* Memory pools */ @@ -2961,6 +2962,43 @@ static void parse_new_commit(const char *arg) b->last_commit = object_count_by_type[OBJ_COMMIT]; } +static void handle_tag_signature(struct strbuf *msg, const char *name) +{ + size_t sig_offset = parse_signed_buffer(msg->buf, msg->len); + + /* If there is no signature, there is nothing to do. */ + if (sig_offset >= msg->len) + return; + + switch (signed_tag_mode) { + + /* First, modes that don't change anything */ + case SIGN_ABORT: + die(_("encountered signed tag; use " + "--signed-tags= to handle it")); + case SIGN_WARN_VERBATIM: + warning(_("importing a tag signature verbatim for tag '%s'"), name); + /* fallthru */ + case SIGN_VERBATIM: + /* Nothing to do, the signature will be put into the imported tag. */ + break; + + /* Second, modes that remove the signature */ + case SIGN_WARN_STRIP: + warning(_("stripping a tag signature for tag '%s'"), name); + /* fallthru */ + case SIGN_STRIP: + /* Truncate the buffer to remove the signature */ + strbuf_setlen(msg, sig_offset); + break; + + /* Third, BUG */ + default: + BUG("invalid signed_tag_mode value %d from tag '%s'", + signed_tag_mode, name); + } +} + static void parse_new_tag(const char *arg) { static struct strbuf msg = STRBUF_INIT; @@ -3024,6 +3062,8 @@ static void parse_new_tag(const char *arg) /* tag payload/message */ parse_data(&msg, 0, NULL); + handle_tag_signature(&msg, t->name); + /* build the tag object */ strbuf_reset(&new_data); @@ -3544,6 +3584,9 @@ static int parse_one_option(const char *option) } else if (skip_prefix(option, "signed-commits=", &option)) { if (parse_sign_mode(option, &signed_commit_mode)) usagef(_("unknown --signed-commits mode '%s'"), option); + } else if (skip_prefix(option, "signed-tags=", &option)) { + if (parse_sign_mode(option, &signed_tag_mode)) + usagef(_("unknown --signed-tags mode '%s'"), option); } else if (!strcmp(option, "quiet")) { show_stats = 0; quiet = 1; diff --git a/t/meson.build b/t/meson.build index 11376b9e256..cb8c2b4b30b 100644 --- a/t/meson.build +++ b/t/meson.build @@ -1036,6 +1036,7 @@ integration_tests = [ 't9303-fast-import-compression.sh', 't9304-fast-import-marks.sh', 't9305-fast-import-signatures.sh', + 't9306-fast-import-signed-tags.sh', 't9350-fast-export.sh', 't9351-fast-export-anonymize.sh', 't9400-git-cvsserver-server.sh', diff --git a/t/t9306-fast-import-signed-tags.sh b/t/t9306-fast-import-signed-tags.sh new file mode 100755 index 00000000000..363619e7d1a --- /dev/null +++ b/t/t9306-fast-import-signed-tags.sh @@ -0,0 +1,80 @@ +#!/bin/sh + +test_description='git fast-import --signed-tags=' + +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main + +. ./test-lib.sh +. "$TEST_DIRECTORY/lib-gpg.sh" + +test_expect_success 'set up unsigned initial commit and import repo' ' + test_commit first && + git init new +' + +test_expect_success 'import no signed tag with --signed-tags=abort' ' + git fast-export --signed-tags=verbatim >output && + git -C new fast-import --quiet --signed-tags=abort output +' + +test_expect_success GPG 'import OpenPGP signed tag with --signed-tags=abort' ' + test_must_fail git -C new fast-import --quiet --signed-tags=abort log 2>&1 && + IMPORTED=$(git -C new rev-parse --verify refs/tags/openpgp-signed) && + test $OPENPGP_SIGNED = $IMPORTED && + test_must_be_empty log +' + +test_expect_success GPGSM 'setup X.509 signed tag' ' + test_config gpg.format x509 && + test_config user.signingkey $GIT_COMMITTER_EMAIL && + + git tag -s -m "X.509 signed tag" x509-signed first && + X509_SIGNED=$(git rev-parse --verify refs/tags/x509-signed) && + git fast-export --signed-tags=verbatim x509-signed >output +' + +test_expect_success GPGSM 'import X.509 signed tag with --signed-tags=warn-strip' ' + git -C new fast-import --quiet --signed-tags=warn-strip log 2>&1 && + test_grep "stripping a tag signature for tag '\''x509-signed'\''" log && + IMPORTED=$(git -C new rev-parse --verify refs/tags/x509-signed) && + test $X509_SIGNED != $IMPORTED && + git -C new cat-file -p x509-signed >out && + test_grep ! "SIGNED MESSAGE" out +' + +test_expect_success GPGSSH 'setup SSH signed tag' ' + test_config gpg.format ssh && + test_config user.signingkey "${GPGSSH_KEY_PRIMARY}" && + + git tag -s -m "SSH signed tag" ssh-signed first && + SSH_SIGNED=$(git rev-parse --verify refs/tags/ssh-signed) && + git fast-export --signed-tags=verbatim ssh-signed >output +' + +test_expect_success GPGSSH 'import SSH signed tag with --signed-tags=warn-verbatim' ' + git -C new fast-import --quiet --signed-tags=warn-verbatim log 2>&1 && + test_grep "importing a tag signature verbatim for tag '\''ssh-signed'\''" log && + IMPORTED=$(git -C new rev-parse --verify refs/tags/ssh-signed) && + test $SSH_SIGNED = $IMPORTED +' + +test_expect_success GPGSSH 'import SSH signed tag with --signed-tags=strip' ' + git -C new fast-import --quiet --signed-tags=strip log 2>&1 && + test_must_be_empty log && + IMPORTED=$(git -C new rev-parse --verify refs/tags/ssh-signed) && + test $SSH_SIGNED != $IMPORTED && + git -C new cat-file -p ssh-signed >out && + test_grep ! "SSH SIGNATURE" out +' + +test_done -- GitLab