Git development
 help / color / mirror / Atom feed
From: Scott Chacon <scott@gitbutler.net>
To: git@vger.kernel.org
Subject: [RFC PATCH 2/4] tag: add --hash=sha256 to sign a tree-sha256 header
Date: Fri,  2 Oct 2026 10:18:44 +0200	[thread overview]
Message-ID: <20261002081846.25144-3-scott@gitbutler.net> (raw)
In-Reply-To: <20261002081846.25144-1-scott@gitbutler.net>

Teach "git tag -s" and "git tag -u" a "--hash=sha256" option that
puts the tree-sha256 of the tagged tree in a header after "tagger":

  object <commit>
  type commit
  tag <name>
  tagger <ident>
  tree-sha256 <hex>

Being in the header, it is part of the signed payload, and so the
signature now covers the contents of the tagged tree directly. 

"--hash=sha256" without signing is an error, as there is nothing to
gain from an unsigned digest. It also makes "git tag" create a tag
object, so that it isn't silently dropped when making a lightweight
tag. "--hash=none" is accepted so that a later patch can let it
override a configured default.

---
 Documentation/git-tag.adoc    | 11 ++++-
 builtin/tag.c                 | 29 +++++++++++--
 t/meson.build                 |  1 +
 t/t7032-tree-sha256-signed.sh | 76 +++++++++++++++++++++++++++++++++++
 tree-sha256.c                 |  9 +++++
 tree-sha256.h                 |  6 +++
 6 files changed, 128 insertions(+), 4 deletions(-)
 create mode 100755 t/t7032-tree-sha256-signed.sh

diff --git a/Documentation/git-tag.adoc b/Documentation/git-tag.adoc
index cea3202fdb..8901090a6d 100644
--- a/Documentation/git-tag.adoc
+++ b/Documentation/git-tag.adoc
@@ -9,7 +9,7 @@ git-tag - Create, list, delete or verify tags
 SYNOPSIS
 --------
 [synopsis]
-git tag [-a | -s | -u <key-id>] [-f] [-m <msg> | -F <file>] [-e]
+git tag [-a | -s | -u <key-id>] [--hash=<algorithm>] [-f] [-m <msg> | -F <file>] [-e]
 	[(--trailer <token>[(=|:)<value>])...]
 	<tagname> [<commit> | <object>]
 git tag -d <tagname>...
@@ -84,6 +84,15 @@ OPTIONS
 	`gpg.format` configuration variable. See
 	linkgit:git-config[1].
 
+`--hash=<algorithm>`::
+	When signing, add a `tree-sha256` header holding a SHA-256
+	digest of every file in the tagged object's tree, including the
+	contents of checked-out submodules, so that the signature covers
+	the content directly rather than only its SHA-1 object names.
+	_<algorithm>_ is `sha256`, or `none` (the default).
+	Giving `--hash=sha256` without signing is an error. All
+	submodules must be checked out.
+
 `-f`::
 `--force`::
 	Replace an existing tag with the given name (instead of failing)
diff --git a/builtin/tag.c b/builtin/tag.c
index 06c125b53c..9bc4c946d1 100644
--- a/builtin/tag.c
+++ b/builtin/tag.c
@@ -33,9 +33,10 @@
 #include "write-or-die.h"
 #include "object-file-convert.h"
 #include "trailer.h"
+#include "tree-sha256.h"
 
 static const char * const git_tag_usage[] = {
-	N_("git tag [-a | -s | -u <key-id>] [-f] [-m <msg> | -F <file>] [-e]\n"
+	N_("git tag [-a | -s | -u <key-id>] [--hash=<algorithm>] [-f] [-m <msg> | -F <file>] [-e]\n"
 	   "        [(--trailer <token>[(=|:)<value>])...]\n"
 	   "        <tagname> [<commit> | <object>]"),
 	N_("git tag -d <tagname>..."),
@@ -281,6 +282,7 @@ struct create_tag_options {
 	unsigned int message_given:1;
 	unsigned int use_editor:1;
 	unsigned int sign;
+	unsigned int tree_hash;
 	enum {
 		CLEANUP_NONE,
 		CLEANUP_SPACE,
@@ -316,11 +318,19 @@ static void create_tag(const struct object_id *object, const char *object_ref,
 		    "object %s\n"
 		    "type %s\n"
 		    "tag %s\n"
-		    "tagger %s\n\n",
+		    "tagger %s\n",
 		    oid_to_hex(object),
 		    type_name(type),
 		    tag,
 		    git_committer_info(IDENT_STRICT));
+	if (opt->sign && opt->tree_hash) {
+		strbuf_addstr(&header, TREE_SHA256_HEADER " ");
+		if (tree_sha256_hex(the_repository, object, &header))
+			die(_("unable to compute %s for %s"),
+			    TREE_SHA256_HEADER, object_ref);
+		strbuf_addch(&header, '\n');
+	}
+	strbuf_addch(&header, '\n');
 
 	should_edit = opt->use_editor || !opt->message_given;
 	if (should_edit || trailer_args->nr) {
@@ -468,6 +478,7 @@ int cmd_tag(int argc,
 	int cmdmode = 0, create_tag_object = 0;
 	char *msgfile = NULL;
 	const char *keyid = NULL;
+	const char *hash_arg = NULL;
 	struct msg_arg msg = { .buf = STRBUF_INIT };
 	struct ref_transaction *transaction;
 	struct strbuf err = STRBUF_INIT;
@@ -503,6 +514,8 @@ int cmd_tag(int argc,
 			   N_("add custom trailer(s)")),
 		OPT_BOOL('e', "edit", &edit_flag, N_("force edit of tag message")),
 		OPT_BOOL('s', "sign", &opt.sign, N_("annotated and GPG-signed tag")),
+		OPT_STRING(0, "hash", &hash_arg, N_("algorithm"),
+			   N_("sign a tree-sha256 header of the tagged tree (sha256 or none)")),
 		OPT_CLEANUP(&cleanup_arg),
 		OPT_STRING('u', "local-user", &keyid, N_("key-id"),
 					N_("use another key to sign the tag")),
@@ -556,6 +569,14 @@ int cmd_tag(int argc,
 
 	argc = parse_options(argc, argv, prefix, options, git_tag_usage, 0);
 
+	if (hash_arg) {
+		int tree_hash = parse_signing_hash(hash_arg);
+		if (tree_hash < 0)
+			die(_("unsupported --hash value '%s' (use 'sha256' or 'none')"),
+			    hash_arg);
+		opt.tree_hash = tree_hash;
+	}
+
 	if (!cmdmode) {
 		if (argc == 0)
 			cmdmode = 'l';
@@ -579,7 +600,7 @@ int cmd_tag(int argc,
 		set_signing_key(keyid);
 	}
 	create_tag_object = (opt.sign || annotate || msg.given || msgfile ||
-			     edit_flag || trailer_args.nr);
+			     edit_flag || trailer_args.nr || opt.tree_hash);
 
 	if ((create_tag_object || force) && (cmdmode != 0))
 		usage_with_options(git_tag_usage, options);
@@ -683,6 +704,8 @@ int cmd_tag(int argc,
 	if (create_tag_object) {
 		if (force_sign_annotate && !annotate)
 			opt.sign = 1;
+		if (opt.tree_hash && !opt.sign)
+			die(_("--hash=%s requires a signed tag (-s or -u)"), hash_arg);
 		path = repo_git_path(the_repository, "TAG_EDITMSG");
 		create_tag(&object, object_ref, tag, &buf, &opt, &prev, &object,
 			   &trailer_args, path);
diff --git a/t/meson.build b/t/meson.build
index 8ff3dbe69d..ff83368847 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -877,6 +877,7 @@ integration_tests = [
   't7012-skip-worktree-writing.sh',
   't7030-verify-tag.sh',
   't7031-verify-tag-signed-ssh.sh',
+  't7032-tree-sha256-signed.sh',
   't7060-wtstatus.sh',
   't7061-wtstatus-ignore.sh',
   't7062-wtstatus-ignorecase.sh',
diff --git a/t/t7032-tree-sha256-signed.sh b/t/t7032-tree-sha256-signed.sh
new file mode 100755
index 0000000000..083f25e665
--- /dev/null
+++ b/t/t7032-tree-sha256-signed.sh
@@ -0,0 +1,76 @@
+#!/bin/sh
+
+test_description='signed tags and commits with a tree-sha256 header'
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+GNUPGHOME_NOT_USED=$GNUPGHOME
+. "$TEST_DIRECTORY/lib-gpg.sh"
+
+# Print the value of the tree-sha256 header of <type> <object>, if any.
+header_of () {
+	git cat-file "$1" "$2" >object &&
+	sed -n "/^$/q; s/^tree-sha256 //p" object
+}
+
+test_expect_success GPGSSH 'setup' '
+	git config --global gpg.format ssh &&
+	git config --global gpg.ssh.allowedSignersFile "${GPGSSH_ALLOWED_SIGNERS}" &&
+	git config --global user.signingkey "${GPGSSH_KEY_PRIMARY}" &&
+	mkdir dir &&
+	echo one >dir/file &&
+	echo two >file &&
+	git add dir file &&
+	test_tick &&
+	git commit -m initial &&
+	test-tool tree-sha256 HEAD >expect
+'
+
+test_expect_success GPGSSH 'tag -s --hash=sha256 signs a tree-sha256 header' '
+	git tag -s --hash=sha256 -m release v1 &&
+	header_of tag v1 >actual &&
+	test_cmp expect actual &&
+	sed -n "4,5p" object >lines &&
+	test_grep "^tagger " lines &&
+	test_grep "^tree-sha256 " lines &&
+	git tag -v v1 &&
+	git fsck --strict
+'
+
+test_expect_success GPGSSH 'tag -u --hash=sha256 signs a tree-sha256 header' '
+	git tag -u "${GPGSSH_KEY_PRIMARY}" --hash=sha256 -m release v2 &&
+	header_of tag v2 >actual &&
+	test_cmp expect actual &&
+	git tag -v v2
+'
+
+test_expect_success GPGSSH 'tag -s without --hash has no header' '
+	git tag -s -m release v3 &&
+	header_of tag v3 >actual &&
+	test_must_be_empty actual &&
+	git tag -s --hash=none -m release v4 &&
+	header_of tag v4 >actual &&
+	test_must_be_empty actual
+'
+
+test_expect_success GPGSSH 'tag --hash=sha256 requires signing' '
+	test_must_fail git tag --hash=sha256 -m release v5 2>err &&
+	test_grep "requires a signed tag" err &&
+	test_must_fail git tag --hash=sha256 v5 2>err &&
+	test_grep "requires a signed tag" err &&
+	test_must_fail git rev-parse --verify v5
+'
+
+test_expect_success GPGSSH 'tag --hash rejects unknown algorithms' '
+	test_must_fail git tag -s --hash=md5 -m release v5 2>err &&
+	test_grep "unsupported --hash value" err &&
+	test_must_fail git rev-parse --verify v5
+'
+
+test_expect_success GPGSSH 'tag --hash=sha256 needs an object with a tree' '
+	test_must_fail git tag -s --hash=sha256 -m blob v5 HEAD:file &&
+	test_must_fail git rev-parse --verify v5
+'
+
+test_done
diff --git a/tree-sha256.c b/tree-sha256.c
index fb58962232..90f0306521 100644
--- a/tree-sha256.c
+++ b/tree-sha256.c
@@ -236,3 +236,12 @@ int tree_sha256_hex(struct repository *r, const struct object_id *oid,
 	oid_array_clear(&chain);
 	return ret;
 }
+
+int parse_signing_hash(const char *value)
+{
+	if (!strcasecmp(value, "sha256"))
+		return 1;
+	if (!strcasecmp(value, "none"))
+		return 0;
+	return -1;
+}
diff --git a/tree-sha256.h b/tree-sha256.h
index 6d3e5018aa..dc070129ea 100644
--- a/tree-sha256.h
+++ b/tree-sha256.h
@@ -27,4 +27,10 @@ struct strbuf;
 int tree_sha256_hex(struct repository *r, const struct object_id *oid,
 		    struct strbuf *hex);
 
+/*
+ * Parse the value of a --hash=<algorithm> option. Returns 1 for
+ * "sha256", 0 for "none", and -1 for anything else.
+ */
+int parse_signing_hash(const char *value);
+
 #endif /* TREE_SHA256_H */
-- 
2.50.1 (Apple Git-155)


  parent reply	other threads:[~2026-10-02  8:18 UTC|newest]

Thread overview: 22+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-02  8:18 [RFC PATCH 0/4] sign a SHA-256 digest of the tree in commits and tags Scott Chacon
2026-10-02  8:18 ` [RFC PATCH 1/4] tree-sha256: hash the contents of a tree with SHA-256 Scott Chacon
2026-10-02 15:45   ` Junio C Hamano
2026-10-02  8:18 ` Scott Chacon [this message]
2026-10-02 15:49   ` [RFC PATCH 2/4] tag: add --hash=sha256 to sign a tree-sha256 header Junio C Hamano
2026-10-02  8:18 ` [RFC PATCH 3/4] commit: " Scott Chacon
2026-10-02  8:18 ` [RFC PATCH 4/4] gpg: add gpg.treeHash to sign a tree-sha256 header by default Scott Chacon
2026-10-02 15:52 ` [RFC PATCH 0/4] sign a SHA-256 digest of the tree in commits and tags Junio C Hamano
2026-10-02 19:06 ` brian m. carlson
2026-10-05  9:32   ` Scott Chacon
2026-10-05 12:41     ` Patrick Steinhardt
2026-10-05 14:16       ` Scott Chacon
2026-10-05 22:57         ` brian m. carlson
2026-10-06 13:36         ` Johannes Schindelin
2026-10-06 16:16       ` Kristoffer Haugsbakk
2026-10-06 21:55         ` brian m. carlson
2026-10-06 22:38           ` Junio C Hamano
2026-10-06 23:40             ` brian m. carlson
2026-10-06  9:00   ` Christian Couder
2026-10-06 22:26     ` brian m. carlson
2026-10-07 12:26       ` Christian Couder
2026-10-07 21:07         ` brian m. carlson

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261002081846.25144-3-scott@gitbutler.net \
    --to=scott@gitbutler.net \
    --cc=git@vger.kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox