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>
This commit is contained in:
Justin Tobler
2026-03-11 12:31:47 -05:00
committed by Junio C Hamano
parent 65e7263031
commit 4d3a5e1be2
6 changed files with 195 additions and 79 deletions

View File

@@ -86,6 +86,10 @@ already trusted to run their own code.
* `strip-if-invalid` will check signatures and, if they are invalid,
will strip them and display a warning. The validation is performed
in the same way as linkgit:git-verify-commit[1] does it.
* `sign-if-invalid[=<keyid>]`, similar to `strip-if-invalid`, verifies
commit signatures and replaces invalid signatures with newly created ones.
Valid signatures are left unchanged. If `<keyid>` is provided, that key is
used for signing; otherwise the configured default signing key is used.
Options for Frontends
~~~~~~~~~~~~~~~~~~~~~

View File

@@ -64,7 +64,7 @@ static int parse_opt_sign_mode(const struct option *opt,
if (unset)
return 0;
if (parse_sign_mode(arg, val))
if (parse_sign_mode(arg, val, NULL))
return error(_("unknown %s mode: %s"), opt->long_name, arg);
return 0;
@@ -825,6 +825,9 @@ static void handle_commit(struct commit *commit, struct rev_info *rev,
case SIGN_STRIP_IF_INVALID:
die(_("'strip-if-invalid' is not a valid mode for "
"git fast-export with --signed-commits=<mode>"));
case SIGN_SIGN_IF_INVALID:
die(_("'sign-if-invalid' is not a valid mode for "
"git fast-export with --signed-commits=<mode>"));
default:
BUG("invalid signed_commit_mode value %d", signed_commit_mode);
}
@@ -970,6 +973,9 @@ static void handle_tag(const char *name, struct tag *tag)
case SIGN_STRIP_IF_INVALID:
die(_("'strip-if-invalid' is not a valid mode for "
"git fast-export with --signed-tags=<mode>"));
case SIGN_SIGN_IF_INVALID:
die(_("'sign-if-invalid' is not a valid mode for "
"git fast-export with --signed-tags=<mode>"));
default:
BUG("invalid signed_commit_mode value %d", signed_commit_mode);
}

View File

@@ -190,6 +190,7 @@ static const char *global_prefix;
static enum sign_mode signed_tag_mode = SIGN_VERBATIM;
static enum sign_mode signed_commit_mode = SIGN_VERBATIM;
static const char *signed_commit_keyid;
/* Memory pools */
static struct mem_pool fi_mem_pool = {
@@ -2836,10 +2837,46 @@ static void finalize_commit_buffer(struct strbuf *new_data,
strbuf_addbuf(new_data, msg);
}
static void handle_strip_if_invalid(struct strbuf *new_data,
struct signature_data *sig_sha1,
struct signature_data *sig_sha256,
struct strbuf *msg)
static void warn_invalid_signature(struct signature_check *check,
const char *msg, enum sign_mode mode)
{
const char *signer = check->signer ? check->signer : _("unknown");
const char *subject;
int subject_len = find_commit_subject(msg, &subject);
switch (mode) {
case SIGN_STRIP_IF_INVALID:
if (subject_len > 100)
warning(_("stripping invalid signature for commit '%.100s...'\n"
" allegedly by %s"), subject, signer);
else if (subject_len > 0)
warning(_("stripping invalid signature for commit '%.*s'\n"
" allegedly by %s"), subject_len, subject, signer);
else
warning(_("stripping invalid signature for commit\n"
" allegedly by %s"), signer);
break;
case SIGN_SIGN_IF_INVALID:
if (subject_len > 100)
warning(_("signing commit with invalid signature for '%.100s...'\n"
" allegedly by %s"), subject, signer);
else if (subject_len > 0)
warning(_("signing commit with invalid signature for '%.*s'\n"
" allegedly by %s"), subject_len, subject, signer);
else
warning(_("signing commit with invalid signature\n"
" allegedly by %s"), signer);
break;
default:
BUG("unsupported signing mode");
}
}
static void handle_signature_if_invalid(struct strbuf *new_data,
struct signature_data *sig_sha1,
struct signature_data *sig_sha256,
struct strbuf *msg,
enum sign_mode mode)
{
struct strbuf tmp_buf = STRBUF_INIT;
struct signature_check signature_check = { 0 };
@@ -2851,20 +2888,33 @@ static void handle_strip_if_invalid(struct strbuf *new_data,
ret = verify_commit_buffer(tmp_buf.buf, tmp_buf.len, &signature_check);
if (ret) {
const char *signer = signature_check.signer ?
signature_check.signer : _("unknown");
const char *subject;
int subject_len = find_commit_subject(msg->buf, &subject);
warn_invalid_signature(&signature_check, msg->buf, mode);
if (subject_len > 100)
warning(_("stripping invalid signature for commit '%.100s...'\n"
" allegedly by %s"), subject, signer);
else if (subject_len > 0)
warning(_("stripping invalid signature for commit '%.*s'\n"
" allegedly by %s"), subject_len, subject, signer);
else
warning(_("stripping invalid signature for commit\n"
" allegedly by %s"), signer);
if (mode == SIGN_SIGN_IF_INVALID) {
struct strbuf signature = STRBUF_INIT;
struct strbuf payload = STRBUF_INIT;
/*
* NEEDSWORK: To properly support interoperability mode
* when signing commit signatures, the commit buffer
* must be provided in both the repository and
* compatibility object formats. As currently
* implemented, only the repository object format is
* considered meaning compatibility signatures cannot be
* generated. Thus, attempting to sign commit signatures
* in interoperability mode is currently unsupported.
*/
if (the_repository->compat_hash_algo)
die(_("signing signatures in interoperability mode is unsupported"));
strbuf_addstr(&payload, signature_check.payload);
if (sign_buffer_with_key(&payload, &signature, signed_commit_keyid))
die(_("failed to sign commit object"));
add_header_signature(new_data, &signature, the_hash_algo);
strbuf_release(&signature);
strbuf_release(&payload);
}
finalize_commit_buffer(new_data, NULL, NULL, msg);
} else {
@@ -2927,6 +2977,7 @@ static void parse_new_commit(const char *arg)
/* fallthru */
case SIGN_VERBATIM:
case SIGN_STRIP_IF_INVALID:
case SIGN_SIGN_IF_INVALID:
import_one_signature(&sig_sha1, &sig_sha256, v);
break;
@@ -3011,9 +3062,11 @@ static void parse_new_commit(const char *arg)
"encoding %s\n",
encoding);
if (signed_commit_mode == SIGN_STRIP_IF_INVALID &&
if ((signed_commit_mode == SIGN_STRIP_IF_INVALID ||
signed_commit_mode == SIGN_SIGN_IF_INVALID) &&
(sig_sha1.hash_algo || sig_sha256.hash_algo))
handle_strip_if_invalid(&new_data, &sig_sha1, &sig_sha256, &msg);
handle_signature_if_invalid(&new_data, &sig_sha1, &sig_sha256,
&msg, signed_commit_mode);
else
finalize_commit_buffer(&new_data, &sig_sha1, &sig_sha256, &msg);
@@ -3060,6 +3113,9 @@ static void handle_tag_signature(struct strbuf *msg, const char *name)
case SIGN_STRIP_IF_INVALID:
die(_("'strip-if-invalid' is not a valid mode for "
"git fast-import with --signed-tags=<mode>"));
case SIGN_SIGN_IF_INVALID:
die(_("'sign-if-invalid' is not a valid mode for "
"git fast-import with --signed-tags=<mode>"));
default:
BUG("invalid signed_tag_mode value %d from tag '%s'",
signed_tag_mode, name);
@@ -3649,10 +3705,10 @@ static int parse_one_option(const char *option)
} else if (skip_prefix(option, "export-pack-edges=", &option)) {
option_export_pack_edges(option);
} else if (skip_prefix(option, "signed-commits=", &option)) {
if (parse_sign_mode(option, &signed_commit_mode))
if (parse_sign_mode(option, &signed_commit_mode, &signed_commit_keyid))
usagef(_("unknown --signed-commits mode '%s'"), option);
} else if (skip_prefix(option, "signed-tags=", &option)) {
if (parse_sign_mode(option, &signed_tag_mode))
if (parse_sign_mode(option, &signed_tag_mode, NULL))
usagef(_("unknown --signed-tags mode '%s'"), option);
} else if (!strcmp(option, "quiet")) {
show_stats = 0;

View File

@@ -1156,21 +1156,28 @@ out:
return ret;
}
int parse_sign_mode(const char *arg, enum sign_mode *mode)
int parse_sign_mode(const char *arg, enum sign_mode *mode, const char **keyid)
{
if (!strcmp(arg, "abort"))
if (!strcmp(arg, "abort")) {
*mode = SIGN_ABORT;
else if (!strcmp(arg, "verbatim") || !strcmp(arg, "ignore"))
} else if (!strcmp(arg, "verbatim") || !strcmp(arg, "ignore")) {
*mode = SIGN_VERBATIM;
else if (!strcmp(arg, "warn-verbatim") || !strcmp(arg, "warn"))
} else if (!strcmp(arg, "warn-verbatim") || !strcmp(arg, "warn")) {
*mode = SIGN_WARN_VERBATIM;
else if (!strcmp(arg, "warn-strip"))
} else if (!strcmp(arg, "warn-strip")) {
*mode = SIGN_WARN_STRIP;
else if (!strcmp(arg, "strip"))
} else if (!strcmp(arg, "strip")) {
*mode = SIGN_STRIP;
else if (!strcmp(arg, "strip-if-invalid"))
} else if (!strcmp(arg, "strip-if-invalid")) {
*mode = SIGN_STRIP_IF_INVALID;
else
} else if (!strcmp(arg, "sign-if-invalid")) {
*mode = SIGN_SIGN_IF_INVALID;
} else if (skip_prefix(arg, "sign-if-invalid=", &arg)) {
*mode = SIGN_SIGN_IF_INVALID;
if (keyid)
*keyid = arg;
} else {
return -1;
}
return 0;
}

View File

@@ -119,12 +119,15 @@ enum sign_mode {
SIGN_WARN_STRIP,
SIGN_STRIP,
SIGN_STRIP_IF_INVALID,
SIGN_SIGN_IF_INVALID,
};
/*
* Return 0 if `arg` can be parsed into an `enum sign_mode`. Return -1
* otherwise.
* otherwise. If the parsed mode is SIGN_SIGN_IF_INVALID and GPG key provided in
* the arguments in the form `sign-if-invalid=<keyid>`, the key-ID is parsed
* into `char **keyid`.
*/
int parse_sign_mode(const char *arg, enum sign_mode *mode);
int parse_sign_mode(const char *arg, enum sign_mode *mode, const char **keyid);
#endif

View File

@@ -103,26 +103,85 @@ test_expect_success GPG 'strip both OpenPGP signatures with --signed-commits=war
test_line_count = 2 out
'
test_expect_success GPG 'import commit with no signature with --signed-commits=strip-if-invalid' '
git fast-export main >output &&
git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
test_must_be_empty log
'
for mode in strip-if-invalid sign-if-invalid
do
test_expect_success GPG "import commit with no signature with --signed-commits=$mode" '
git fast-export main >output &&
git -C new fast-import --quiet --signed-commits=$mode <output >log 2>&1 &&
test_must_be_empty log
'
test_expect_success GPG 'keep valid OpenPGP signature with --signed-commits=strip-if-invalid' '
rm -rf new &&
git init new &&
test_expect_success GPG "keep valid OpenPGP signature with --signed-commits=$mode" '
rm -rf new &&
git init new &&
git fast-export --signed-commits=verbatim openpgp-signing >output &&
git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
IMPORTED=$(git -C new rev-parse --verify refs/heads/openpgp-signing) &&
test $OPENPGP_SIGNING = $IMPORTED &&
git -C new cat-file commit "$IMPORTED" >actual &&
test_grep -E "^gpgsig(-sha256)? " actual &&
test_must_be_empty log
'
git fast-export --signed-commits=verbatim openpgp-signing >output &&
git -C new fast-import --quiet --signed-commits=$mode <output >log 2>&1 &&
IMPORTED=$(git -C new rev-parse --verify refs/heads/openpgp-signing) &&
test $OPENPGP_SIGNING = $IMPORTED &&
git -C new cat-file commit "$IMPORTED" >actual &&
test_grep -E "^gpgsig(-sha256)? " actual &&
test_must_be_empty log
'
test_expect_success GPG 'strip signature invalidated by message change with --signed-commits=strip-if-invalid' '
test_expect_success GPG "handle signature invalidated by message change with --signed-commits=$mode" '
rm -rf new &&
git init new &&
git fast-export --signed-commits=verbatim openpgp-signing >output &&
# Change the commit message, which invalidates the signature.
# The commit message length should not change though, otherwise the
# corresponding `data <length>` command would have to be changed too.
sed "s/OpenPGP signed commit/OpenPGP forged commit/" output >modified &&
git -C new fast-import --quiet --signed-commits=$mode <modified >log 2>&1 &&
IMPORTED=$(git -C new rev-parse --verify refs/heads/openpgp-signing) &&
test $OPENPGP_SIGNING != $IMPORTED &&
git -C new cat-file commit "$IMPORTED" >actual &&
if test "$mode" = strip-if-invalid
then
test_grep "stripping invalid signature" log &&
test_grep ! -E "^gpgsig" actual
else
test_grep "signing commit with invalid signature" log &&
test_grep -E "^gpgsig(-sha256)? " actual &&
git -C new verify-commit "$IMPORTED"
fi
'
test_expect_success GPGSM "keep valid X.509 signature with --signed-commits=$mode" '
rm -rf new &&
git init new &&
git fast-export --signed-commits=verbatim x509-signing >output &&
git -C new fast-import --quiet --signed-commits=$mode <output >log 2>&1 &&
IMPORTED=$(git -C new rev-parse --verify refs/heads/x509-signing) &&
test $X509_SIGNING = $IMPORTED &&
git -C new cat-file commit "$IMPORTED" >actual &&
test_grep -E "^gpgsig(-sha256)? " actual &&
test_must_be_empty log
'
test_expect_success GPGSSH "keep valid SSH signature with --signed-commits=$mode" '
rm -rf new &&
git init new &&
test_config -C new gpg.ssh.allowedSignersFile "${GPGSSH_ALLOWED_SIGNERS}" &&
git fast-export --signed-commits=verbatim ssh-signing >output &&
git -C new fast-import --quiet --signed-commits=$mode <output >log 2>&1 &&
IMPORTED=$(git -C new rev-parse --verify refs/heads/ssh-signing) &&
test $SSH_SIGNING = $IMPORTED &&
git -C new cat-file commit "$IMPORTED" >actual &&
test_grep -E "^gpgsig(-sha256)? " actual &&
test_must_be_empty log
'
done
test_expect_success GPGSSH "sign invalid commit with explicit keyid" '
rm -rf new &&
git init new &&
@@ -133,41 +192,22 @@ test_expect_success GPG 'strip signature invalidated by message change with --si
# corresponding `data <length>` command would have to be changed too.
sed "s/OpenPGP signed commit/OpenPGP forged commit/" output >modified &&
git -C new fast-import --quiet --signed-commits=strip-if-invalid <modified >log 2>&1 &&
# Configure the target repository with an invalid default signing key.
test_config -C new user.signingkey "not-a-real-key-id" &&
test_config -C new gpg.format ssh &&
test_config -C new gpg.ssh.allowedSignersFile "${GPGSSH_ALLOWED_SIGNERS}" &&
test_must_fail git -C new fast-import --quiet \
--signed-commits=sign-if-invalid <modified >/dev/null 2>&1 &&
# Import using explicitly provided signing key.
git -C new fast-import --quiet \
--signed-commits=sign-if-invalid="${GPGSSH_KEY_PRIMARY}" <modified &&
IMPORTED=$(git -C new rev-parse --verify refs/heads/openpgp-signing) &&
test $OPENPGP_SIGNING != $IMPORTED &&
git -C new cat-file commit "$IMPORTED" >actual &&
test_grep ! -E "^gpgsig" actual &&
test_grep "stripping invalid signature" log
'
test_expect_success GPGSM 'keep valid X.509 signature with --signed-commits=strip-if-invalid' '
rm -rf new &&
git init new &&
git fast-export --signed-commits=verbatim x509-signing >output &&
git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
IMPORTED=$(git -C new rev-parse --verify refs/heads/x509-signing) &&
test $X509_SIGNING = $IMPORTED &&
git -C new cat-file commit "$IMPORTED" >actual &&
test_grep -E "^gpgsig(-sha256)? " actual &&
test_must_be_empty log
'
test_expect_success GPGSSH 'keep valid SSH signature with --signed-commits=strip-if-invalid' '
rm -rf new &&
git init new &&
test_config -C new gpg.ssh.allowedSignersFile "${GPGSSH_ALLOWED_SIGNERS}" &&
git fast-export --signed-commits=verbatim ssh-signing >output &&
git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
IMPORTED=$(git -C new rev-parse --verify refs/heads/ssh-signing) &&
test $SSH_SIGNING = $IMPORTED &&
git -C new cat-file commit "$IMPORTED" >actual &&
test_grep -E "^gpgsig(-sha256)? " actual &&
test_must_be_empty log
git -C new verify-commit "$IMPORTED"
'
test_done