Skip to content

Commit 7e205c5

Browse files
sguiheuxMathieu Fenniak
authored andcommitted
fix: get tag must return the tag signature instead of commit signature (#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). Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12351 Reviewed-by: limiting-factor <limiting-factor@noreply.codeberg.org> Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
1 parent ee8ad65 commit 7e205c5

3 files changed

Lines changed: 122 additions & 3 deletions

File tree

routers/api/v1/repo/tag.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func GetAnnotatedTag(ctx *context.APIContext) {
122122
ctx.Error(http.StatusBadRequest, "GetAnnotatedTag", err)
123123
}
124124

125-
convertedAnnotatedTag, err := convert.ToAnnotatedTag(ctx, ctx.Repo.Repository, tag, commit)
125+
convertedAnnotatedTag, err := convert.ToAnnotatedTag(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, tag, commit)
126126
if err != nil {
127127
ctx.Error(http.StatusInternalServerError, "ToAnnotatedTag", err)
128128
return

services/convert/convert.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,20 +397,40 @@ func ToTeams(ctx context.Context, teams []*organization.Team, loadOrgs bool) ([]
397397
}
398398

399399
// ToAnnotatedTag convert git.Tag to api.AnnotatedTag
400-
func ToAnnotatedTag(ctx context.Context, repo *repo_model.Repository, t *git.Tag, c *git.Commit) (*api.AnnotatedTag, error) {
400+
func ToAnnotatedTag(ctx context.Context, gitRepo *git.Repository, repo *repo_model.Repository, t *git.Tag, c *git.Commit) (*api.AnnotatedTag, error) {
401401
archiveDownloadCount, err := repo_model.GetArchiveDownloadCountForTagName(ctx, repo.ID, t.Name)
402402
if err != nil {
403403
return nil, err
404404
}
405405

406+
// Use the tag's own signature if the tag is signed, otherwise fall back to commit signature.
407+
var verification *api.PayloadCommitVerification
408+
if t.Signature != nil {
409+
verif := asymkey_model.ParseTagWithSignature(ctx, gitRepo, t)
410+
verification = &api.PayloadCommitVerification{
411+
Verified: verif.Verified,
412+
Reason: verif.Reason,
413+
Signature: t.Signature.Signature,
414+
Payload: t.Signature.Payload,
415+
}
416+
if verif.SigningUser != nil {
417+
verification.Signer = &api.PayloadUser{
418+
Name: verif.SigningUser.Name,
419+
Email: verif.SigningEmail,
420+
}
421+
}
422+
} else {
423+
verification = ToVerification(ctx, c)
424+
}
425+
406426
return &api.AnnotatedTag{
407427
Tag: t.Name,
408428
SHA: t.ID.String(),
409429
Object: ToAnnotatedTagObject(repo, c),
410430
Message: t.Message,
411431
URL: util.URLJoin(repo.APIURL(), "git/tags", t.ID.String()),
412432
Tagger: ToCommitUser(t.Tagger),
413-
Verification: ToVerification(ctx, c),
433+
Verification: verification,
414434
ArchiveDownloadCount: archiveDownloadCount,
415435
}, nil
416436
}

services/convert/convert_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ package convert
55

66
import (
77
"testing"
8+
"time"
89

910
actions_model "forgejo.org/models/actions"
1011
"forgejo.org/models/db"
12+
repo_model "forgejo.org/models/repo"
1113
"forgejo.org/models/unittest"
1214
user_model "forgejo.org/models/user"
1315
"forgejo.org/modules/git"
1416
api "forgejo.org/modules/structs"
1517
"forgejo.org/modules/timeutil"
18+
"forgejo.org/modules/util"
1619

1720
"github.com/stretchr/testify/assert"
1821
"github.com/stretchr/testify/require"
@@ -106,6 +109,102 @@ uf51WIBywxztet6vi+jYJK1jFoY4iA==
106109
})
107110
}
108111

112+
func TestToAnnotatedTag(t *testing.T) {
113+
defer unittest.OverrideFixtures("models/fixtures/TestParseCommitWithSSHSignature")()
114+
require.NoError(t, unittest.PrepareTestDatabase())
115+
116+
// Align user email for predictable test results (same as TestToVerification).
117+
userModel := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
118+
userModel.Email = "secret-email@example.com"
119+
db.GetEngine(t.Context()).ID(userModel.ID).Cols("email").Update(userModel)
120+
121+
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
122+
sha1 := git.Sha1ObjectFormat
123+
124+
tagSHA := sha1.EmptyObjectID()
125+
commitSHA := git.MustIDFromString("e20aa0bcd2878f65a93de68a3eed9045d6efdd74")
126+
tagger := &git.Signature{Name: "user2", Email: "user2@example.com", When: time.Unix(1699707877, 0)}
127+
128+
t.Run("Unsigned tag falls back to commit signature (GPG)", func(t *testing.T) {
129+
tag := &git.Tag{
130+
Name: "v2.0.0",
131+
ID: tagSHA,
132+
Object: commitSHA,
133+
Type: "commit",
134+
Tagger: tagger,
135+
Message: "Lightweight tag\n",
136+
// No Signature → unsigned tag
137+
}
138+
139+
commitPayload := `tree e20aa0bcd2878f65a93de68a3eed9045d6efdd74
140+
parent 5cd9b9847563eb730d63d23c1f1b84868e52ae7d
141+
author user2 <user2+committer@example.com> 1759956520 -0600
142+
committer user2 <user2+committer@example.com> 1759956520 -0600
143+
144+
Add content
145+
`
146+
commitGPGSig := `-----BEGIN PGP SIGNATURE-----
147+
148+
iQEzBAABCgAdFiEEdlqhn25IEoMmvK5vmDaXTfEZWRMFAmjmzigACgkQmDaXTfEZ
149+
WROC4ggAs8mD8csA6FV5e2v/4HcxuaZKCN+D8Gvku2JUigODQCA+NOX0FF2jDnCh
150+
tXylBPB4HJw1spKkDLtOpnCUSOniBdl9NcZjnBt6sP/OSnEfLznXFra+9fCHzsu0
151+
9uhDn3Wn1iHWXQ2ZglUwVS0ja6pNgEip8wNZBysv8+XbO1CEEW0m7zQA6tunzIwp
152+
yiPZDUJrKtpKAK0+v19EccT2VjYAa+Vo+p3/E0piaTYNbsTqtFRy63tdjDkf+mo+
153+
l/PaPhrMqdnbxv3/sd/63VCNdvPH3f0+OuydcC7mXyysmvap99EC+QKnpsrm7RAP
154+
uf51WIBywxztet6vi+jYJK1jFoY4iA==
155+
=Lnrt
156+
-----END PGP SIGNATURE-----`
157+
158+
commit := &git.Commit{
159+
ID: commitSHA,
160+
Committer: &git.Signature{
161+
Email: "user2@example.com",
162+
},
163+
Signature: &git.ObjectSignature{
164+
Payload: commitPayload,
165+
Signature: commitGPGSig,
166+
},
167+
}
168+
169+
result, err := ToAnnotatedTag(t.Context(), nil, headRepo, tag, commit)
170+
require.NoError(t, err)
171+
require.NotNil(t, result)
172+
173+
// Should fall back to commit verification (tag has no signature)
174+
assert.Equal(t, commitGPGSig, result.Verification.Signature, "should use the commit GPG signature")
175+
assert.Equal(t, commitPayload, result.Verification.Payload, "should use the commit payload")
176+
assert.True(t, result.Verification.Verified, "commit signature should be verified")
177+
assert.Equal(t, "v2.0.0", result.Tag)
178+
assert.Equal(t, tagSHA.String(), result.SHA)
179+
assert.Equal(t, util.URLJoin(headRepo.APIURL(), "git/tags", tagSHA.String()), result.URL)
180+
})
181+
182+
t.Run("Unsigned tag, unsigned commit", func(t *testing.T) {
183+
tag := &git.Tag{
184+
Name: "v3.0.0",
185+
ID: tagSHA,
186+
Object: commitSHA,
187+
Type: "commit",
188+
Tagger: tagger,
189+
Message: "No signature\n",
190+
}
191+
192+
commit := &git.Commit{
193+
ID: commitSHA,
194+
Committer: &git.Signature{Email: "user2@example.com"},
195+
// No Signature
196+
}
197+
198+
result, err := ToAnnotatedTag(t.Context(), nil, headRepo, tag, commit)
199+
require.NoError(t, err)
200+
require.NotNil(t, result)
201+
202+
assert.False(t, result.Verification.Verified, "should not be verified")
203+
assert.Empty(t, result.Verification.Signature, "should have no signature")
204+
assert.Equal(t, "v3.0.0", result.Tag)
205+
})
206+
}
207+
109208
func TestToActionRunner(t *testing.T) {
110209
testCases := []struct {
111210
name string

0 commit comments

Comments
 (0)