git: add signature verification to CacheKey and ResolveMetadata

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi
2025-10-21 22:55:40 -07:00
parent aade64489a
commit 81a79837c3
7 changed files with 348 additions and 46 deletions

View File

@@ -21,6 +21,15 @@ type GitIdentifier struct {
MountSSHSock string
KnownSSHHosts string
SkipSubmodules bool
VerifySignature *GitSignatureVerifyOptions
}
type GitSignatureVerifyOptions struct {
PubKey []byte
RejectExpiredKeys bool
RequireSignedTag bool // signed tag must be present
IgnoreSignedTag bool // even if signed tag is present, verify signature on commit object
}
func NewGitIdentifier(remoteURL string) (*GitIdentifier, error) {

View File

@@ -28,6 +28,8 @@ import (
srctypes "github.com/moby/buildkit/source/types"
"github.com/moby/buildkit/util/bklog"
"github.com/moby/buildkit/util/gitutil"
"github.com/moby/buildkit/util/gitutil/gitobject"
"github.com/moby/buildkit/util/gitutil/gitsign"
"github.com/moby/buildkit/util/progress/logs"
"github.com/moby/buildkit/util/urlutil"
"github.com/moby/locker"
@@ -255,58 +257,67 @@ func (gs *Source) ResolveMetadata(ctx context.Context, id *GitIdentifier, sm *se
return nil, err
}
if !opt.ReturnObject {
if !opt.ReturnObject && id.VerifySignature == nil {
return md, nil
}
gsh.cacheCommit = md.Checksum
gsh.sha256 = len(md.Checksum) == 64
repo, err := gsh.remoteFetch(ctx, nil)
if err != nil {
if err := gsh.addGitObjectsToMetadata(ctx, jobCtx, md); err != nil {
return nil, err
}
defer repo.Release()
// if ref was commit sha then we don't know the type of the object yet
buf, err := repo.Run(ctx, "cat-file", "-t", md.Checksum)
if err != nil {
return nil, err
}
objType := strings.TrimSpace(string(buf))
if objType != "commit" && objType != "tag" {
return nil, errors.Errorf("expected commit or tag object, got %s", objType)
}
if objType == "tag" && md.CommitChecksum == "" {
buf, err := repo.Run(ctx, "rev-parse", md.Checksum+"^{commit}")
if err != nil {
if id.VerifySignature != nil {
if err := verifyGitSignature(md, id.VerifySignature); err != nil {
return nil, err
}
md.CommitChecksum = strings.TrimSpace(string(buf))
} else if objType == "commit" {
md.CommitChecksum = ""
}
commitChecksum := md.Checksum
if md.CommitChecksum != "" {
buf, err := repo.Run(ctx, "cat-file", "tag", md.Checksum)
if err != nil {
return nil, err
}
md.TagObject = buf
commitChecksum = md.CommitChecksum
}
buf, err = repo.Run(ctx, "cat-file", "commit", commitChecksum)
if err != nil {
return nil, err
}
md.CommitObject = buf
return md, nil
}
func verifyGitSignature(md *Metadata, opts *GitSignatureVerifyOptions) error {
var tagVerifyError error
if !opts.IgnoreSignedTag {
if len(md.TagObject) > 0 {
tagObj, err := gitobject.Parse(md.TagObject)
if err != nil {
return errors.Wrap(err, "failed to parse git tag object")
}
if err := tagObj.VerifyChecksum(md.Checksum); err != nil {
return errors.Wrap(err, "tag object checksum verification failed")
}
tagVerifyError = gitsign.VerifySignature(tagObj, opts.PubKey, &gitsign.VerifyPolicy{
RejectExpiredKeys: opts.RejectExpiredKeys,
})
if tagVerifyError == nil {
return nil
}
}
}
if opts.RequireSignedTag {
if tagVerifyError != nil {
return tagVerifyError
}
return errors.New("signed tag required but no signed tag found")
}
commitObj, err := gitobject.Parse(md.CommitObject)
if err != nil {
return errors.Wrap(err, "failed to parse git commit object")
}
expected := md.Checksum
if md.CommitChecksum != "" {
expected = md.CommitChecksum
}
if err := commitObj.VerifyChecksum(expected); err != nil {
return errors.Wrap(err, "commit object checksum verification failed")
}
return gitsign.VerifySignature(commitObj, opts.PubKey, &gitsign.VerifyPolicy{
RejectExpiredKeys: opts.RejectExpiredKeys,
})
}
func (gs *Source) Resolve(ctx context.Context, id source.Identifier, sm *session.Manager, _ solver.Vertex) (source.SourceInstance, error) {
gitIdentifier, ok := id.(*GitIdentifier)
if !ok {
@@ -587,17 +598,74 @@ func (gs *gitSourceHandler) resolveMetadata(ctx context.Context, jobCtx solver.J
return md, nil
}
func (gs *gitSourceHandler) addGitObjectsToMetadata(ctx context.Context, jobCtx solver.JobContext, md *Metadata) error {
repo, err := gs.remoteFetch(ctx, jobCtx)
if err != nil {
return err
}
defer repo.Release()
// if ref was commit sha then we don't know the type of the object yet
buf, err := repo.Run(ctx, "cat-file", "-t", md.Checksum)
if err != nil {
return err
}
objType := strings.TrimSpace(string(buf))
if objType != "commit" && objType != "tag" {
return errors.Errorf("expected commit or tag object, got %s", objType)
}
if objType == "tag" && md.CommitChecksum == "" {
buf, err := repo.Run(ctx, "rev-parse", md.Checksum+"^{commit}")
if err != nil {
return err
}
md.CommitChecksum = strings.TrimSpace(string(buf))
} else if objType == "commit" {
md.CommitChecksum = ""
}
commitChecksum := md.Checksum
if md.CommitChecksum != "" {
buf, err := repo.Run(ctx, "cat-file", "tag", md.Checksum)
if err != nil {
return err
}
md.TagObject = buf
commitChecksum = md.CommitChecksum
}
buf, err = repo.Run(ctx, "cat-file", "commit", commitChecksum)
if err != nil {
return err
}
md.CommitObject = buf
return nil
}
func (gs *gitSourceHandler) CacheKey(ctx context.Context, jobCtx solver.JobContext, index int) (string, string, solver.CacheOpts, bool, error) {
md, err := gs.resolveMetadata(ctx, jobCtx)
if err != nil {
return "", "", nil, false, err
}
gs.sha256 = len(md.Checksum) == 64
if gs.src.VerifySignature != nil {
gs.cacheCommit = md.Checksum
if err := gs.addGitObjectsToMetadata(ctx, jobCtx, md); err != nil {
return "", "", nil, false, err
}
if err := verifyGitSignature(md, gs.src.VerifySignature); err != nil {
return "", "", nil, false, err
}
}
if gitutil.IsCommitSHA(md.Ref) {
cacheKey := gs.shaToCacheKey(md.Ref, md.Ref)
gs.cacheKey = cacheKey
gs.cacheCommit = md.Ref
gs.sha256 = len(md.Ref) == 64
// gs.src.Checksum is verified when checking out the commit
return cacheKey, md.Ref, nil, true, nil
}
@@ -609,12 +677,11 @@ func (gs *gitSourceHandler) CacheKey(ctx context.Context, jobCtx solver.JobConte
}
cacheKey := gs.shaToCacheKey(shaForCacheKey, md.Ref)
gs.cacheKey = cacheKey
gs.sha256 = len(md.Checksum) == 64
gs.cacheCommit = md.Checksum
return cacheKey, md.Checksum, nil, true, nil
}
func (gs *gitSourceHandler) remoteFetch(ctx context.Context, g session.Group) (_ *gitRepo, retErr error) {
func (gs *gitSourceHandler) remoteFetch(ctx context.Context, jobCtx solver.JobContext) (_ *gitRepo, retErr error) {
gs.locker.Lock(gs.src.Remote)
cleanup := func() error { return gs.locker.Unlock(gs.src.Remote) }
@@ -624,6 +691,11 @@ func (gs *gitSourceHandler) remoteFetch(ctx context.Context, g session.Group) (_
}
}()
var g session.Group
if jobCtx != nil {
g = jobCtx.Session()
}
repo, err := gs.tryRemoteFetch(ctx, g, false)
if err != nil {
var wce *wouldClobberExistingTagError
@@ -701,7 +773,7 @@ func (gs *gitSourceHandler) Snapshot(ctx context.Context, jobCtx solver.JobConte
return gs.cache.Get(ctx, sis[0].ID(), nil)
}
repo, err := gs.remoteFetch(ctx, g)
repo, err := gs.remoteFetch(ctx, jobCtx)
if err != nil {
return nil, err
}

View File

@@ -1454,7 +1454,7 @@ func testResolveMetadataObject(t *testing.T, keepGitDir bool, format string) {
require.NoError(t, err)
require.Equal(t, "v1.2.3", tag.Tag)
require.Equal(t, "this is an annotated tag\n", tag.Message)
require.Equal(t, "this is an annotated tag", tag.Message)
require.Equal(t, "test-user", tag.Tagger.Name)
require.Equal(t, "test-user@example.com", tag.Tagger.Email)
@@ -1474,7 +1474,7 @@ func testResolveMetadataObject(t *testing.T, keepGitDir bool, format string) {
commit, err := commitObject.ToCommit()
require.NoError(t, err)
require.Equal(t, "second\n", commit.Message)
require.Equal(t, "second", commit.Message)
require.Equal(t, "test-user", commit.Author.Name)
require.Equal(t, "test-user@example.com", commit.Author.Email)
require.Equal(t, "test-user", commit.Committer.Name)
@@ -1728,6 +1728,7 @@ func testCheckSignatures(t *testing.T, keepGitDir bool, format string) {
ob, err := gitobject.Parse(md.CommitObject)
require.NoError(t, err)
require.Greater(t, len(ob.Signature), 50)
fixturesBase := os.Getenv("BUILDKIT_TEST_SIGN_FIXTURES")
@@ -1753,6 +1754,7 @@ func testCheckSignatures(t *testing.T, keepGitDir bool, format string) {
ob, err = gitobject.Parse(md.TagObject)
require.NoError(t, err)
require.Greater(t, len(ob.Signature), 50)
sshkey1, err := os.ReadFile(fixturesBase + "/user1.ssh.pub")
require.NoError(t, err)
@@ -1778,6 +1780,219 @@ func testCheckSignatures(t *testing.T, keepGitDir bool, format string) {
require.ErrorContains(t, err, "public key does not match")
}
func TestVerifySignaturesSHA1(t *testing.T) {
testVerifySignatures(t, false, "sha1")
}
func TestVerifySignaturesKeepGitDirSHA1(t *testing.T) {
testVerifySignatures(t, true, "sha1")
}
func TestVerifySignaturesSHA256(t *testing.T) {
testVerifySignatures(t, false, "sha256")
}
func TestVerifySignaturesKeepGitDirSHA256(t *testing.T) {
testVerifySignatures(t, true, "sha256")
}
func testVerifySignatures(t *testing.T, keepGitDir bool, format string) {
if runtime.GOOS == "windows" {
t.Skip("Depends on unimplemented containerd bind-mount support on Windows")
}
t.Parallel()
requireSignFixtures(t)
ctx := namespaces.WithNamespace(context.Background(), "buildkit-test")
ctx = logProgressStreams(ctx, t)
gs := setupGitSource(t, t.TempDir())
repo := setupGitRepo(t, format)
fixturesBase := os.Getenv("BUILDKIT_TEST_SIGN_FIXTURES")
user1GPGPub, err := os.ReadFile(fixturesBase + "/user1.gpg.pub")
require.NoError(t, err)
user2GPGPub, err := os.ReadFile(fixturesBase + "/user2.gpg.pub")
require.NoError(t, err)
user1SSHPub, err := os.ReadFile(fixturesBase + "/user1.ssh.pub")
require.NoError(t, err)
user2SSHPub, err := os.ReadFile(fixturesBase + "/user2.ssh.pub")
require.NoError(t, err)
// a/v1.2.3 commit is signed by user1 gpg
// v1.2.3 commit is signed by user1 ssh
// v1.2.3 is a signed tag by user2 ssh
// v1.2.3-special is not signed
id := &GitIdentifier{
Remote: repo.mainURL,
KeepGitDir: keepGitDir,
Ref: "a/v1.2.3",
VerifySignature: &GitSignatureVerifyOptions{
PubKey: user2GPGPub, // wrong key
},
}
gsi, err := gs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, _, _, _, err = gsi.CacheKey(ctx, nil, 0)
require.ErrorContains(t, err, "signature made by unknown entity")
require.ErrorContains(t, err, "signature by")
id = &GitIdentifier{
Remote: repo.mainURL,
KeepGitDir: keepGitDir,
Ref: "v1.2.3-special",
VerifySignature: &GitSignatureVerifyOptions{
PubKey: user2GPGPub,
},
}
gsi, err = gs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, _, _, _, err = gsi.CacheKey(ctx, nil, 0)
require.ErrorContains(t, err, "git object is not signed")
id = &GitIdentifier{
Remote: repo.mainURL,
KeepGitDir: keepGitDir,
Ref: "a/v1.2.3",
VerifySignature: &GitSignatureVerifyOptions{
PubKey: user1GPGPub, // correct
},
}
gsi, err = gs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, pin, _, done, err := gsi.CacheKey(ctx, nil, 0)
require.NoError(t, err)
require.True(t, done)
expLen := 40
if format == "sha256" {
expLen = 64
}
require.Equal(t, expLen, len(pin))
id = &GitIdentifier{
Remote: repo.mainURL,
KeepGitDir: keepGitDir,
Ref: "a/v1.2.3", // not signed tag
VerifySignature: &GitSignatureVerifyOptions{
PubKey: user1GPGPub,
RequireSignedTag: true,
},
}
gsi, err = gs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, _, _, _, err = gsi.CacheKey(ctx, nil, 0)
require.ErrorContains(t, err, "signed tag required but no signed tag found")
// signed tag can be validated via commit signature
id = &GitIdentifier{
Remote: repo.mainURL,
KeepGitDir: keepGitDir,
Ref: "v1.2.3",
VerifySignature: &GitSignatureVerifyOptions{
PubKey: user1SSHPub,
RequireSignedTag: false,
},
}
gsi, err = gs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, pin, _, done, err = gsi.CacheKey(ctx, nil, 0)
require.NoError(t, err)
require.True(t, done)
require.Equal(t, expLen, len(pin))
// but not when signed tag is required
id = &GitIdentifier{
Remote: repo.mainURL,
KeepGitDir: keepGitDir,
Ref: "v1.2.3",
VerifySignature: &GitSignatureVerifyOptions{
PubKey: user1SSHPub,
RequireSignedTag: true,
},
}
gsi, err = gs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, _, _, _, err = gsi.CacheKey(ctx, nil, 0)
require.ErrorContains(t, err, "failed to verify ssh signature")
// correct key for signed tag
id = &GitIdentifier{
Remote: repo.mainURL,
KeepGitDir: keepGitDir,
Ref: "v1.2.3",
VerifySignature: &GitSignatureVerifyOptions{
PubKey: user2SSHPub,
RequireSignedTag: true,
},
}
gsi, err = gs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, pin, _, done, err = gsi.CacheKey(ctx, nil, 0)
require.NoError(t, err)
require.True(t, done)
require.Equal(t, expLen, len(pin))
// repeat three last checks via ResolveSourceMetadata
id = &GitIdentifier{
Remote: repo.mainURL,
KeepGitDir: keepGitDir,
Ref: "v1.2.3",
VerifySignature: &GitSignatureVerifyOptions{
PubKey: user1SSHPub,
RequireSignedTag: false,
},
}
_, err = gs.ResolveMetadata(ctx, id, nil, nil, MetadataOpts{})
require.NoError(t, err)
id = &GitIdentifier{
Remote: repo.mainURL,
KeepGitDir: keepGitDir,
Ref: "v1.2.3",
VerifySignature: &GitSignatureVerifyOptions{
PubKey: user1SSHPub,
RequireSignedTag: true,
},
}
_, err = gs.ResolveMetadata(ctx, id, nil, nil, MetadataOpts{})
require.ErrorContains(t, err, "failed to verify ssh signature")
id = &GitIdentifier{
Remote: repo.mainURL,
KeepGitDir: keepGitDir,
Ref: "v1.2.3",
VerifySignature: &GitSignatureVerifyOptions{
PubKey: user2SSHPub,
RequireSignedTag: true,
},
}
_, err = gs.ResolveMetadata(ctx, id, nil, nil, MetadataOpts{})
require.NoError(t, err)
}
func TestSubdir(t *testing.T) {
testSubdir(t, false)
}