fast-import: add mode to sign commits with invalid signatures

With git-fast-import(1), handling of signed commits is controlled via the `--signed-commits=<mode>` option. When an invalid signature is encountered, a user may want the option to sign the commit again as opposed to just stripping the signature. To facilitate this, introduce a "sign-if-invalid" mode for the `--signed-commits` option. Optionally, a key ID may be explicitly provided in the form `sign-if-invalid[=<keyid>]` to specify which signing key should be used when signing invalid commit signatures. Note that to properly support interoperability mode when signing commit signatures, the commit buffer must be created in both the repository and compatability object formats to generate the appropriate signatures accordingly. As currently implemented, the commit buffer for the compatability object format is not reconstructed and thus signing commits in interoperability mode is not yet supported. Support may be added in the future. Signed-off-by: Justin Tobler <jltobler@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Justin Tobler committed Mar 12, 2026 at 20:39 UTC ee66c793f84ef1c84ec3fe732bb26394ebefd257
6 files changed +202 -85
Documentation/git-fast-import.adoc
+4
@@ -86,6 +86,10 @@ already trusted to run their own code.
86 * `strip-if-invalid` will check signatures and, if they are invalid,
87 will strip them and display a warning. The validation is performed
88 in the same way as linkgit:git-verify-commit[1] does it.
89 +* `sign-if-invalid[=<keyid>]`, similar to `strip-if-invalid`, verifies
90 + commit signatures and replaces invalid signatures with newly created ones.
91 + Valid signatures are left unchanged. If `<keyid>` is provided, that key is
92 + used for signing; otherwise the configured default signing key is used.
93
94 Options for Frontends
95 ~~~~~~~~~~~~~~~~~~~~~
builtin/fast-export.c
+7 -1
@@ -64,7 +64,7 @@ static int parse_opt_sign_mode(const struct option *opt,
64 if (unset)
65 return 0;
66
67 - if (parse_sign_mode(arg, val))
67 + if (parse_sign_mode(arg, val, NULL))
68 return error(_("unknown %s mode: %s"), opt->long_name, arg);
69
70 return 0;
@@ -825,6 +825,9 @@ static void handle_commit(struct commit *commit, struct rev_info *rev,
825 case SIGN_STRIP_IF_INVALID:
826 die(_("'strip-if-invalid' is not a valid mode for "
827 "git fast-export with --signed-commits=<mode>"));
828 + case SIGN_SIGN_IF_INVALID:
829 + die(_("'sign-if-invalid' is not a valid mode for "
830 + "git fast-export with --signed-commits=<mode>"));
831 default:
832 BUG("invalid signed_commit_mode value %d", signed_commit_mode);
833 }
@@ -970,6 +973,9 @@ static void handle_tag(const char *name, struct tag *tag)
973 case SIGN_STRIP_IF_INVALID:
974 die(_("'strip-if-invalid' is not a valid mode for "
975 "git fast-export with --signed-tags=<mode>"));
976 + case SIGN_SIGN_IF_INVALID:
977 + die(_("'sign-if-invalid' is not a valid mode for "
978 + "git fast-export with --signed-tags=<mode>"));
979 default:
980 BUG("invalid signed_commit_mode value %d", signed_commit_mode);
981 }
builtin/fast-import.c
+79 -22
@@ -190,6 +190,7 @@ static const char *global_prefix;
190
191 static enum sign_mode signed_tag_mode = SIGN_VERBATIM;
192 static enum sign_mode signed_commit_mode = SIGN_VERBATIM;
193 +static const char *signed_commit_keyid;
194
195 /* Memory pools */
196 static struct mem_pool fi_mem_pool = {
@@ -2836,26 +2837,15 @@ static void finalize_commit_buffer(struct strbuf *new_data,
2837 strbuf_addbuf(new_data, msg);
2838 }
2839
2839 -static void handle_strip_if_invalid(struct strbuf *new_data,
2840 - struct signature_data *sig_sha1,
2841 - struct signature_data *sig_sha256,
2842 - struct strbuf *msg)
2840 +static void warn_invalid_signature(struct signature_check *check,
2841 + const char *msg, enum sign_mode mode)
2842 {
2844 - struct strbuf tmp_buf = STRBUF_INIT;
2845 - struct signature_check signature_check = { 0 };
2846 - int ret;
2847 -
2848 - /* Check signature in a temporary commit buffer */
2849 - strbuf_addbuf(&tmp_buf, new_data);
2850 - finalize_commit_buffer(&tmp_buf, sig_sha1, sig_sha256, msg);
2851 - ret = verify_commit_buffer(tmp_buf.buf, tmp_buf.len, &signature_check);
2852 -
2853 - if (ret) {
2854 - const char *signer = signature_check.signer ?
2855 - signature_check.signer : _("unknown");
2856 - const char *subject;
2857 - int subject_len = find_commit_subject(msg->buf, &subject);
2843 + const char *signer = check->signer ? check->signer : _("unknown");
2844 + const char *subject;
2845 + int subject_len = find_commit_subject(msg, &subject);
2846
2847 + switch (mode) {
2848 + case SIGN_STRIP_IF_INVALID:
2849 if (subject_len > 100)
2850 warning(_("stripping invalid signature for commit '%.100s...'\n"
2851 " allegedly by %s"), subject, signer);
@@ -2865,6 +2855,67 @@ static void handle_strip_if_invalid(struct strbuf *new_data,
2855 else
2856 warning(_("stripping invalid signature for commit\n"
2857 " allegedly by %s"), signer);
2858 + break;
2859 + case SIGN_SIGN_IF_INVALID:
2860 + if (subject_len > 100)
2861 + warning(_("replacing invalid signature for commit '%.100s...'\n"
2862 + " allegedly by %s"), subject, signer);
2863 + else if (subject_len > 0)
2864 + warning(_("replacing invalid signature for commit '%.*s'\n"
2865 + " allegedly by %s"), subject_len, subject, signer);
2866 + else
2867 + warning(_("replacing invalid signature for commit\n"
2868 + " allegedly by %s"), signer);
2869 + break;
2870 + default:
2871 + BUG("unsupported signing mode");
2872 + }
2873 +}
2874 +
2875 +static void handle_signature_if_invalid(struct strbuf *new_data,
2876 + struct signature_data *sig_sha1,
2877 + struct signature_data *sig_sha256,
2878 + struct strbuf *msg,
2879 + enum sign_mode mode)
2880 +{
2881 + struct strbuf tmp_buf = STRBUF_INIT;
2882 + struct signature_check signature_check = { 0 };
2883 + int ret;
2884 +
2885 + /* Check signature in a temporary commit buffer */
2886 + strbuf_addbuf(&tmp_buf, new_data);
2887 + finalize_commit_buffer(&tmp_buf, sig_sha1, sig_sha256, msg);
2888 + ret = verify_commit_buffer(tmp_buf.buf, tmp_buf.len, &signature_check);
2889 +
2890 + if (ret) {
2891 + warn_invalid_signature(&signature_check, msg->buf, mode);
2892 +
2893 + if (mode == SIGN_SIGN_IF_INVALID) {
2894 + struct strbuf signature = STRBUF_INIT;
2895 + struct strbuf payload = STRBUF_INIT;
2896 +
2897 + /*
2898 + * NEEDSWORK: To properly support interoperability mode
2899 + * when signing commit signatures, the commit buffer
2900 + * must be provided in both the repository and
2901 + * compatibility object formats. As currently
2902 + * implemented, only the repository object format is
2903 + * considered meaning compatibility signatures cannot be
2904 + * generated. Thus, attempting to sign commit signatures
2905 + * in interoperability mode is currently unsupported.
2906 + */
2907 + if (the_repository->compat_hash_algo)
2908 + die(_("signing commits in interoperability mode is unsupported"));
2909 +
2910 + strbuf_addstr(&payload, signature_check.payload);
2911 + if (sign_buffer(&payload, &signature, signed_commit_keyid,
2912 + SIGN_BUFFER_USE_DEFAULT_KEY))
2913 + die(_("failed to sign commit object"));
2914 + add_header_signature(new_data, &signature, the_hash_algo);
2915 +
2916 + strbuf_release(&signature);
2917 + strbuf_release(&payload);
2918 + }
2919
2920 finalize_commit_buffer(new_data, NULL, NULL, msg);
2921 } else {
@@ -2927,6 +2978,7 @@ static void parse_new_commit(const char *arg)
2978 /* fallthru */
2979 case SIGN_VERBATIM:
2980 case SIGN_STRIP_IF_INVALID:
2981 + case SIGN_SIGN_IF_INVALID:
2982 import_one_signature(&sig_sha1, &sig_sha256, v);
2983 break;
2984
@@ -3011,9 +3063,11 @@ static void parse_new_commit(const char *arg)
3063 "encoding %s\n",
3064 encoding);
3065
3014 - if (signed_commit_mode == SIGN_STRIP_IF_INVALID &&
3066 + if ((signed_commit_mode == SIGN_STRIP_IF_INVALID ||
3067 + signed_commit_mode == SIGN_SIGN_IF_INVALID) &&
3068 (sig_sha1.hash_algo || sig_sha256.hash_algo))
3016 - handle_strip_if_invalid(&new_data, &sig_sha1, &sig_sha256, &msg);
3069 + handle_signature_if_invalid(&new_data, &sig_sha1, &sig_sha256,
3070 + &msg, signed_commit_mode);
3071 else
3072 finalize_commit_buffer(&new_data, &sig_sha1, &sig_sha256, &msg);
3073
@@ -3060,6 +3114,9 @@ static void handle_tag_signature(struct strbuf *msg, const char *name)
3114 case SIGN_STRIP_IF_INVALID:
3115 die(_("'strip-if-invalid' is not a valid mode for "
3116 "git fast-import with --signed-tags=<mode>"));
3117 + case SIGN_SIGN_IF_INVALID:
3118 + die(_("'sign-if-invalid' is not a valid mode for "
3119 + "git fast-import with --signed-tags=<mode>"));
3120 default:
3121 BUG("invalid signed_tag_mode value %d from tag '%s'",
3122 signed_tag_mode, name);
@@ -3649,10 +3706,10 @@ static int parse_one_option(const char *option)
3706 } else if (skip_prefix(option, "export-pack-edges=", &option)) {
3707 option_export_pack_edges(option);
3708 } else if (skip_prefix(option, "signed-commits=", &option)) {
3652 - if (parse_sign_mode(option, &signed_commit_mode))
3709 + if (parse_sign_mode(option, &signed_commit_mode, &signed_commit_keyid))
3710 usagef(_("unknown --signed-commits mode '%s'"), option);
3711 } else if (skip_prefix(option, "signed-tags=", &option)) {
3655 - if (parse_sign_mode(option, &signed_tag_mode))
3712 + if (parse_sign_mode(option, &signed_tag_mode, NULL))
3713 usagef(_("unknown --signed-tags mode '%s'"), option);
3714 } else if (!strcmp(option, "quiet")) {
3715 show_stats = 0;
gpg-interface.c
+15 -8
@@ -1152,21 +1152,28 @@ out:
1152 return ret;
1153 }
1154
1155 -int parse_sign_mode(const char *arg, enum sign_mode *mode)
1155 +int parse_sign_mode(const char *arg, enum sign_mode *mode, const char **keyid)
1156 {
1157 - if (!strcmp(arg, "abort"))
1157 + if (!strcmp(arg, "abort")) {
1158 *mode = SIGN_ABORT;
1159 - else if (!strcmp(arg, "verbatim") || !strcmp(arg, "ignore"))
1159 + } else if (!strcmp(arg, "verbatim") || !strcmp(arg, "ignore")) {
1160 *mode = SIGN_VERBATIM;
1161 - else if (!strcmp(arg, "warn-verbatim") || !strcmp(arg, "warn"))
1161 + } else if (!strcmp(arg, "warn-verbatim") || !strcmp(arg, "warn")) {
1162 *mode = SIGN_WARN_VERBATIM;
1163 - else if (!strcmp(arg, "warn-strip"))
1163 + } else if (!strcmp(arg, "warn-strip")) {
1164 *mode = SIGN_WARN_STRIP;
1165 - else if (!strcmp(arg, "strip"))
1165 + } else if (!strcmp(arg, "strip")) {
1166 *mode = SIGN_STRIP;
1167 - else if (!strcmp(arg, "strip-if-invalid"))
1167 + } else if (!strcmp(arg, "strip-if-invalid")) {
1168 *mode = SIGN_STRIP_IF_INVALID;
1169 - else
1169 + } else if (!strcmp(arg, "sign-if-invalid")) {
1170 + *mode = SIGN_SIGN_IF_INVALID;
1171 + } else if (skip_prefix(arg, "sign-if-invalid=", &arg)) {
1172 + *mode = SIGN_SIGN_IF_INVALID;
1173 + if (keyid)
1174 + *keyid = arg;
1175 + } else {
1176 return -1;
1177 + }
1178 return 0;
1179 }
gpg-interface.h
+5 -2
@@ -120,12 +120,15 @@ enum sign_mode {
120 SIGN_WARN_STRIP,
121 SIGN_STRIP,
122 SIGN_STRIP_IF_INVALID,
123 + SIGN_SIGN_IF_INVALID,
124 };
125
126 /*
127 * Return 0 if `arg` can be parsed into an `enum sign_mode`. Return -1
127 - * otherwise.
128 + * otherwise. If the parsed mode is SIGN_SIGN_IF_INVALID and GPG key provided in
129 + * the arguments in the form `sign-if-invalid=<keyid>`, the key-ID is parsed
130 + * into `char **keyid`.
131 */
129 -int parse_sign_mode(const char *arg, enum sign_mode *mode);
132 +int parse_sign_mode(const char *arg, enum sign_mode *mode, const char **keyid);
133
134 #endif
t/t9305-fast-import-signatures.sh
+92 -52
@@ -103,71 +103,111 @@ test_expect_success GPG 'strip both OpenPGP signatures with --signed-commits=war
103 test_line_count = 2 out
104 '
105
106 -test_expect_success GPG 'import commit with no signature with --signed-commits=strip-if-invalid' '
107 - git fast-export main >output &&
108 - git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
109 - test_must_be_empty log
110 -'
111 -
112 -test_expect_success GPG 'keep valid OpenPGP signature with --signed-commits=strip-if-invalid' '
113 - rm -rf new &&
114 - git init new &&
115 -
116 - git fast-export --signed-commits=verbatim openpgp-signing >output &&
117 - git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
118 - IMPORTED=$(git -C new rev-parse --verify refs/heads/openpgp-signing) &&
119 - test $OPENPGP_SIGNING = $IMPORTED &&
120 - git -C new cat-file commit "$IMPORTED" >actual &&
121 - test_grep -E "^gpgsig(-sha256)? " actual &&
122 - test_must_be_empty log
123 -'
124 -
125 -test_expect_success GPG 'strip signature invalidated by message change with --signed-commits=strip-if-invalid' '
106 +for mode in strip-if-invalid sign-if-invalid
107 +do
108 + test_expect_success GPG "import commit with no signature with --signed-commits=$mode" '
109 + git fast-export main >output &&
110 + git -C new fast-import --quiet --signed-commits=$mode <output >log 2>&1 &&
111 + test_must_be_empty log
112 + '
113 +
114 + test_expect_success GPG "keep valid OpenPGP signature with --signed-commits=$mode" '
115 + rm -rf new &&
116 + git init new &&
117 +
118 + git fast-export --signed-commits=verbatim openpgp-signing >output &&
119 + git -C new fast-import --quiet --signed-commits=$mode <output >log 2>&1 &&
120 + IMPORTED=$(git -C new rev-parse --verify refs/heads/openpgp-signing) &&
121 + test $OPENPGP_SIGNING = $IMPORTED &&
122 + git -C new cat-file commit "$IMPORTED" >actual &&
123 + test_grep -E "^gpgsig(-sha256)? " actual &&
124 + test_must_be_empty log
125 + '
126 +
127 + test_expect_success GPG "handle signature invalidated by message change with --signed-commits=$mode" '
128 + rm -rf new &&
129 + git init new &&
130 +
131 + git fast-export --signed-commits=verbatim openpgp-signing >output &&
132 +
133 + # Change the commit message, which invalidates the signature.
134 + # The commit message length should not change though, otherwise the
135 + # corresponding `data <length>` command would have to be changed too.
136 + sed "s/OpenPGP signed commit/OpenPGP forged commit/" output >modified &&
137 +
138 + git -C new fast-import --quiet --signed-commits=$mode <modified >log 2>&1 &&
139 +
140 + IMPORTED=$(git -C new rev-parse --verify refs/heads/openpgp-signing) &&
141 + test $OPENPGP_SIGNING != $IMPORTED &&
142 + git -C new cat-file commit "$IMPORTED" >actual &&
143 +
144 + if test "$mode" = strip-if-invalid
145 + then
146 + test_grep "stripping invalid signature" log &&
147 + test_grep ! -E "^gpgsig" actual
148 + else
149 + test_grep "replacing invalid signature" log &&
150 + test_grep -E "^gpgsig(-sha256)? " actual &&
151 + git -C new verify-commit "$IMPORTED"
152 + fi
153 + '
154 +
155 + test_expect_success GPGSM "keep valid X.509 signature with --signed-commits=$mode" '
156 + rm -rf new &&
157 + git init new &&
158 +
159 + git fast-export --signed-commits=verbatim x509-signing >output &&
160 + git -C new fast-import --quiet --signed-commits=$mode <output >log 2>&1 &&
161 + IMPORTED=$(git -C new rev-parse --verify refs/heads/x509-signing) &&
162 + test $X509_SIGNING = $IMPORTED &&
163 + git -C new cat-file commit "$IMPORTED" >actual &&
164 + test_grep -E "^gpgsig(-sha256)? " actual &&
165 + test_must_be_empty log
166 + '
167 +
168 + test_expect_success GPGSSH "keep valid SSH signature with --signed-commits=$mode" '
169 + rm -rf new &&
170 + git init new &&
171 +
172 + test_config -C new gpg.ssh.allowedSignersFile "${GPGSSH_ALLOWED_SIGNERS}" &&
173 +
174 + git fast-export --signed-commits=verbatim ssh-signing >output &&
175 + git -C new fast-import --quiet --signed-commits=$mode <output >log 2>&1 &&
176 + IMPORTED=$(git -C new rev-parse --verify refs/heads/ssh-signing) &&
177 + test $SSH_SIGNING = $IMPORTED &&
178 + git -C new cat-file commit "$IMPORTED" >actual &&
179 + test_grep -E "^gpgsig(-sha256)? " actual &&
180 + test_must_be_empty log
181 + '
182 +done
183 +
184 +test_expect_success GPGSSH "sign invalid commit with explicit keyid" '
185 rm -rf new &&
186 git init new &&
187
129 - git fast-export --signed-commits=verbatim openpgp-signing >output &&
188 + git fast-export --signed-commits=verbatim ssh-signing >output &&
189
190 # Change the commit message, which invalidates the signature.
191 # The commit message length should not change though, otherwise the
192 # corresponding `data <length>` command would have to be changed too.
134 - sed "s/OpenPGP signed commit/OpenPGP forged commit/" output >modified &&
135 -
136 - git -C new fast-import --quiet --signed-commits=strip-if-invalid <modified >log 2>&1 &&
137 -
138 - IMPORTED=$(git -C new rev-parse --verify refs/heads/openpgp-signing) &&
139 - test $OPENPGP_SIGNING != $IMPORTED &&
140 - git -C new cat-file commit "$IMPORTED" >actual &&
141 - test_grep ! -E "^gpgsig" actual &&
142 - test_grep "stripping invalid signature" log
143 -'
144 -
145 -test_expect_success GPGSM 'keep valid X.509 signature with --signed-commits=strip-if-invalid' '
146 - rm -rf new &&
147 - git init new &&
148 -
149 - git fast-export --signed-commits=verbatim x509-signing >output &&
150 - git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
151 - IMPORTED=$(git -C new rev-parse --verify refs/heads/x509-signing) &&
152 - test $X509_SIGNING = $IMPORTED &&
153 - git -C new cat-file commit "$IMPORTED" >actual &&
154 - test_grep -E "^gpgsig(-sha256)? " actual &&
155 - test_must_be_empty log
156 -'
157 -
158 -test_expect_success GPGSSH 'keep valid SSH signature with --signed-commits=strip-if-invalid' '
159 - rm -rf new &&
160 - git init new &&
193 + sed "s/SSH signed commit/SSH forged commit/" output >modified &&
194
195 + # Configure the target repository with an invalid default signing key.
196 + test_config -C new user.signingkey "not-a-real-key-id" &&
197 + test_config -C new gpg.format ssh &&
198 test_config -C new gpg.ssh.allowedSignersFile "${GPGSSH_ALLOWED_SIGNERS}" &&
199 + test_must_fail git -C new fast-import --quiet \
200 + --signed-commits=sign-if-invalid <modified >/dev/null 2>&1 &&
201 +
202 + # Import using explicitly provided signing key.
203 + git -C new fast-import --quiet \
204 + --signed-commits=sign-if-invalid="${GPGSSH_KEY_PRIMARY}" <modified &&
205
164 - git fast-export --signed-commits=verbatim ssh-signing >output &&
165 - git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
206 IMPORTED=$(git -C new rev-parse --verify refs/heads/ssh-signing) &&
167 - test $SSH_SIGNING = $IMPORTED &&
207 + test $SSH_SIGNING != $IMPORTED &&
208 git -C new cat-file commit "$IMPORTED" >actual &&
209 test_grep -E "^gpgsig(-sha256)? " actual &&
170 - test_must_be_empty log
210 + git -C new verify-commit "$IMPORTED"
211 '
212
213 test_done