Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions internal/commands/verify-tag/verify_tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/sigstore/gitsign/internal/commands/verify"
"github.com/sigstore/gitsign/internal/config"
"github.com/sigstore/gitsign/internal/gitsign"
"github.com/sigstore/gitsign/pkg/git"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -59,40 +60,48 @@ func (o *options) Run(_ io.Writer, args []string) error {
return fmt.Errorf("error resolving tag reference: %w", err)
}

// Get the tag object
tagObj, err := repo.TagObject(ref.Hash())
// Read the raw tag object bytes directly from the object store.
// Verifying against the raw bytes (rather than bytes re-encoded through
// go-git) is what git-core does and avoids trust-confusion attacks where
// go-git's loose parser resolves a malformed tag differently than git-core.
obj, err := repo.Storer.EncodedObject(plumbing.TagObject, ref.Hash())
if err != nil {
return fmt.Errorf("error reading tag object: %w", err)
}

// Extract the signature
sig := []byte(tagObj.PGPSignature)
p, _ := pem.Decode(sig)
if p == nil || p.Type != "SIGNED MESSAGE" {
return fmt.Errorf("unsupported signature type")
}

// Get the tag data without the signature
tagData := new(plumbing.MemoryObject)
if err := tagObj.EncodeWithoutSignature(tagData); err != nil {
return err
}
r, err := tagData.Reader()
r, err := obj.Reader()
if err != nil {
return err
}
defer r.Close() // nolint:errcheck
data, err := io.ReadAll(r)

t, err := git.SplitTag(r)
if err != nil {
return err
return fmt.Errorf("error extracting tag signature: %w", err)
}

// Per the SHA-256 transition spec, the in-body PEM block is the
// signature in the tag's current hash algorithm; gpgsig /
// gpgsig-sha256 headers carry signatures over the alternate-algorithm
// form. We verify the local-form signature.
sig := t.InBody
if sig == nil {
return fmt.Errorf("tag has no in-body signature")
}

p, _ := pem.Decode(sig)
if p == nil {
return fmt.Errorf("%w: not a PEM block", git.ErrUnsupportedSignatureType)
}
if p.Type != "SIGNED MESSAGE" {
return fmt.Errorf("%w: %q", git.ErrUnsupportedSignatureType, p.Type)
}

// Verify the signature
v, err := gitsign.NewVerifierWithCosignOpts(ctx, o.Config, &o.CertVerifyOptions)
if err != nil {
return err
}
summary, err := v.Verify(ctx, data, sig, true)
summary, err := v.Verify(ctx, t.Payload, sig, true)
if err != nil {
return err
}
Expand Down
44 changes: 28 additions & 16 deletions internal/commands/verify/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,36 +60,48 @@ func (o *options) Run(_ io.Writer, args []string) error {
if err != nil {
return fmt.Errorf("error resolving commit object: %w", err)
}
c, err := repo.CommitObject(*h)

obj, err := repo.Storer.EncodedObject(plumbing.CommitObject, *h)
if err != nil {
return fmt.Errorf("error reading commit object: %w", err)
}

sig := []byte(c.PGPSignature)
p, _ := pem.Decode(sig)
if p == nil || p.Type != "SIGNED MESSAGE" {
return fmt.Errorf("unsupported signature type")
}

c2 := new(plumbing.MemoryObject)
if err := c.EncodeWithoutSignature(c2); err != nil {
return err
}
r, err := c2.Reader()
r, err := obj.Reader()
if err != nil {
return err
}
defer r.Close() // nolint:errcheck
data, err := io.ReadAll(r)

c, err := git.SplitCommit(r)
if err != nil {
return err
return fmt.Errorf("error extracting commit signature: %w", err)
}

// Per the SHA-256 transition spec a commit can carry gpgsig (SHA-1
// form), gpgsig-sha256 (SHA-256 form), or both. Prefer gpgsig — every
// repo go-git can read today is SHA-1 form, so its gpgsig matches the
// stripped Payload. gpgsig-sha256 is the fallback for SHA-256-only
// signed commits.
sig := c.Gpgsig
if sig == nil {
sig = c.GpgsigSha256
}
if sig == nil {
return fmt.Errorf("commit has no gpgsig or gpgsig-sha256 signature")
}

p, _ := pem.Decode(sig)
if p == nil {
return fmt.Errorf("%w: not a PEM block", git.ErrUnsupportedSignatureType)
}
if p.Type != "SIGNED MESSAGE" {
return fmt.Errorf("%w: %q", git.ErrUnsupportedSignatureType, p.Type)
}

v, err := gitsign.NewVerifierWithCosignOpts(ctx, o.Config, &o.CertVerifyOptions)
if err != nil {
return err
}
summary, err := v.Verify(ctx, data, sig, true)
summary, err := v.Verify(ctx, c.Payload, sig, true)
if err != nil {
return err
}
Expand Down
73 changes: 73 additions & 0 deletions internal/commands/verify/verify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2026 The Sigstore Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package verify

import (
"errors"
"io"
"testing"

gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/sigstore/gitsign/pkg/git"
)

// TestRun_RejectsUnsupportedSignatureType confirms the sentinel is wrapped
// so callers can errors.Is on it. End-to-end coverage of the GHSA
// trust-confusion attack lives in
// internal/gitsign/invalid_object_test.go::TestDuplicateTreeTrustConfusion.
func TestRun_RejectsUnsupportedSignatureType(t *testing.T) {
tmpDir := t.TempDir()
repo, err := gogit.PlainInit(tmpDir, false)
if err != nil {
t.Fatalf("PlainInit: %v", err)
}

// Well-formed commit but with a PGP SIGNATURE (not SIGNED MESSAGE) in gpgsig.
raw := []byte(`tree b333504b8cf3d9c314fed2cc242c5c38e89534a5
author Alice <alice@example.com> 1700000000 +0000
committer Alice <alice@example.com> 1700000000 +0000
gpgsig -----BEGIN PGP SIGNATURE-----
ZmFrZQ==
-----END PGP SIGNATURE-----

hi
`)

obj := repo.Storer.NewEncodedObject()
obj.SetType(plumbing.CommitObject)
w, err := obj.Writer()
if err != nil {
t.Fatalf("obj.Writer: %v", err)
}
if _, err := w.Write(raw); err != nil {
t.Fatalf("write: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close: %v", err)
}
h, err := repo.Storer.SetEncodedObject(obj)
if err != nil {
t.Fatalf("SetEncodedObject: %v", err)
}

t.Chdir(tmpDir)

opts := &options{}
err = opts.Run(io.Discard, []string{h.String()})
if !errors.Is(err, git.ErrUnsupportedSignatureType) {
t.Fatalf("want error wrapping ErrUnsupportedSignatureType, got %v", err)
}
}
6 changes: 5 additions & 1 deletion internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ func LegacySHASign(ctx context.Context, rekor rekor.Writer, ident *fulcio.Identi
// using the same key, this is probably okay? e.g. even if you could cause a SHA1 collision,
// you would still need the underlying commit to be valid and using the same key which seems hard.

commit, err := git.ObjectHash(data, resp.Signature)
raw, err := git.JoinCommit(&git.CommitSig{Payload: data, Gpgsig: resp.Signature})
if err != nil {
return nil, fmt.Errorf("error reassembling commit: %w", err)
}
commit, err := git.ObjectHash(raw)
if err != nil {
return nil, fmt.Errorf("error generating commit hash: %w", err)
}
Expand Down
Loading
Loading