[v15.0/forgejo] fix: get tag must return the tag signature instead of commit signature (#12395)

**Backport:** https://codeberg.org/forgejo/forgejo/pulls/12351

## Fix: `GET /api/v1/repos/{owner}/{repo}/git/tags/{sha}` returns empty verification for signed tags

### Problem

When an annotated tag is signed (GPG or SSH) but the underlying commit is **not** signed, the API endpoint `GET /repos/{owner}/{repo}/git/tags/{sha}` returns an empty `verification.signature` field.

This is because `ToAnnotatedTag` was calling `ToVerification(ctx, c)` with the **commit** object, which checks the commit's signature — not the tag's own signature. Since the commit is unsigned, the API returns `signature: ""` and `verified: false`.

This causes issues for tools that rely on the tag signature from the API to validate that a tag push event is from a trusted source.

### Fix

`ToAnnotatedTag` now checks if the tag has its own signature (`t.Signature != nil`). If so, it uses `ParseTagWithSignature` to verify the tag's signature and populates the `verification` field from the tag. Otherwise, it falls back to the commit signature (existing behavior for unsigned/lightweight tags).

Co-authored-by: steven.guiheux <steven.guiheux@ovhcloud.com>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12395
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
This commit is contained in:
forgejo-backport-action 2026-05-03 06:38:17 +02:00 committed by Mathieu Fenniak
parent b2c9c14dea
commit d3dd397001
3 changed files with 122 additions and 3 deletions

View file

@ -122,7 +122,7 @@ func GetAnnotatedTag(ctx *context.APIContext) {
ctx.Error(http.StatusBadRequest, "GetAnnotatedTag", err)
}
convertedAnnotatedTag, err := convert.ToAnnotatedTag(ctx, ctx.Repo.Repository, tag, commit)
convertedAnnotatedTag, err := convert.ToAnnotatedTag(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, tag, commit)
if err != nil {
ctx.Error(http.StatusInternalServerError, "ToAnnotatedTag", err)
return

View file

@ -397,12 +397,32 @@ func ToTeams(ctx context.Context, teams []*organization.Team, loadOrgs bool) ([]
}
// ToAnnotatedTag convert git.Tag to api.AnnotatedTag
func ToAnnotatedTag(ctx context.Context, repo *repo_model.Repository, t *git.Tag, c *git.Commit) (*api.AnnotatedTag, error) {
func ToAnnotatedTag(ctx context.Context, gitRepo *git.Repository, repo *repo_model.Repository, t *git.Tag, c *git.Commit) (*api.AnnotatedTag, error) {
archiveDownloadCount, err := repo_model.GetArchiveDownloadCountForTagName(ctx, repo.ID, t.Name)
if err != nil {
return nil, err
}
// Use the tag's own signature if the tag is signed, otherwise fall back to commit signature.
var verification *api.PayloadCommitVerification
if t.Signature != nil {
verif := asymkey_model.ParseTagWithSignature(ctx, gitRepo, t)
verification = &api.PayloadCommitVerification{
Verified: verif.Verified,
Reason: verif.Reason,
Signature: t.Signature.Signature,
Payload: t.Signature.Payload,
}
if verif.SigningUser != nil {
verification.Signer = &api.PayloadUser{
Name: verif.SigningUser.Name,
Email: verif.SigningEmail,
}
}
} else {
verification = ToVerification(ctx, c)
}
return &api.AnnotatedTag{
Tag: t.Name,
SHA: t.ID.String(),
@ -410,7 +430,7 @@ func ToAnnotatedTag(ctx context.Context, repo *repo_model.Repository, t *git.Tag
Message: t.Message,
URL: util.URLJoin(repo.APIURL(), "git/tags", t.ID.String()),
Tagger: ToCommitUser(t.Tagger),
Verification: ToVerification(ctx, c),
Verification: verification,
ArchiveDownloadCount: archiveDownloadCount,
}, nil
}

View file

@ -5,14 +5,17 @@ package convert
import (
"testing"
"time"
actions_model "forgejo.org/models/actions"
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/git"
api "forgejo.org/modules/structs"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -106,6 +109,102 @@ uf51WIBywxztet6vi+jYJK1jFoY4iA==
})
}
func TestToAnnotatedTag(t *testing.T) {
defer unittest.OverrideFixtures("models/fixtures/TestParseCommitWithSSHSignature")()
require.NoError(t, unittest.PrepareTestDatabase())
// Align user email for predictable test results (same as TestToVerification).
userModel := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
userModel.Email = "secret-email@example.com"
db.GetEngine(t.Context()).ID(userModel.ID).Cols("email").Update(userModel)
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
sha1 := git.Sha1ObjectFormat
tagSHA := sha1.EmptyObjectID()
commitSHA := git.MustIDFromString("e20aa0bcd2878f65a93de68a3eed9045d6efdd74")
tagger := &git.Signature{Name: "user2", Email: "user2@example.com", When: time.Unix(1699707877, 0)}
t.Run("Unsigned tag falls back to commit signature (GPG)", func(t *testing.T) {
tag := &git.Tag{
Name: "v2.0.0",
ID: tagSHA,
Object: commitSHA,
Type: "commit",
Tagger: tagger,
Message: "Lightweight tag\n",
// No Signature → unsigned tag
}
commitPayload := `tree e20aa0bcd2878f65a93de68a3eed9045d6efdd74
parent 5cd9b9847563eb730d63d23c1f1b84868e52ae7d
author user2 <user2+committer@example.com> 1759956520 -0600
committer user2 <user2+committer@example.com> 1759956520 -0600
Add content
`
commitGPGSig := `-----BEGIN PGP SIGNATURE-----
iQEzBAABCgAdFiEEdlqhn25IEoMmvK5vmDaXTfEZWRMFAmjmzigACgkQmDaXTfEZ
WROC4ggAs8mD8csA6FV5e2v/4HcxuaZKCN+D8Gvku2JUigODQCA+NOX0FF2jDnCh
tXylBPB4HJw1spKkDLtOpnCUSOniBdl9NcZjnBt6sP/OSnEfLznXFra+9fCHzsu0
9uhDn3Wn1iHWXQ2ZglUwVS0ja6pNgEip8wNZBysv8+XbO1CEEW0m7zQA6tunzIwp
yiPZDUJrKtpKAK0+v19EccT2VjYAa+Vo+p3/E0piaTYNbsTqtFRy63tdjDkf+mo+
l/PaPhrMqdnbxv3/sd/63VCNdvPH3f0+OuydcC7mXyysmvap99EC+QKnpsrm7RAP
uf51WIBywxztet6vi+jYJK1jFoY4iA==
=Lnrt
-----END PGP SIGNATURE-----`
commit := &git.Commit{
ID: commitSHA,
Committer: &git.Signature{
Email: "user2@example.com",
},
Signature: &git.ObjectSignature{
Payload: commitPayload,
Signature: commitGPGSig,
},
}
result, err := ToAnnotatedTag(t.Context(), nil, headRepo, tag, commit)
require.NoError(t, err)
require.NotNil(t, result)
// Should fall back to commit verification (tag has no signature)
assert.Equal(t, commitGPGSig, result.Verification.Signature, "should use the commit GPG signature")
assert.Equal(t, commitPayload, result.Verification.Payload, "should use the commit payload")
assert.True(t, result.Verification.Verified, "commit signature should be verified")
assert.Equal(t, "v2.0.0", result.Tag)
assert.Equal(t, tagSHA.String(), result.SHA)
assert.Equal(t, util.URLJoin(headRepo.APIURL(), "git/tags", tagSHA.String()), result.URL)
})
t.Run("Unsigned tag, unsigned commit", func(t *testing.T) {
tag := &git.Tag{
Name: "v3.0.0",
ID: tagSHA,
Object: commitSHA,
Type: "commit",
Tagger: tagger,
Message: "No signature\n",
}
commit := &git.Commit{
ID: commitSHA,
Committer: &git.Signature{Email: "user2@example.com"},
// No Signature
}
result, err := ToAnnotatedTag(t.Context(), nil, headRepo, tag, commit)
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.Verification.Verified, "should not be verified")
assert.Empty(t, result.Verification.Signature, "should have no signature")
assert.Equal(t, "v3.0.0", result.Tag)
})
}
func TestToActionRunner(t *testing.T) {
testCases := []struct {
name string