From 6ac6774906e85c753c3903ceb98e8d3a691449ce Mon Sep 17 00:00:00 2001 From: Prucek Date: Fri, 26 Jun 2026 09:31:39 +0200 Subject: [PATCH] git: add SSH commit signing support to the git client factory Add --git-signing-key-path flag to GitHubOptions. When set, the git client factory configures gpg.format=ssh, user.signingkey, and commit.gpgsign=true on every cloned repo so that commits produced by any component are signed. --- pkg/flagutil/github.go | 3 + pkg/git/v2/client_factory.go | 48 ++++++++++-- pkg/git/v2/client_factory_test.go | 124 ++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 pkg/git/v2/client_factory_test.go diff --git a/pkg/flagutil/github.go b/pkg/flagutil/github.go index 5315948d26..9dd52ce7fb 100644 --- a/pkg/flagutil/github.go +++ b/pkg/flagutil/github.go @@ -48,6 +48,7 @@ type GitHubOptions struct { AllowDirectAccess bool AppID string AppPrivateKeyPath string + SigningKeyPath string ThrottleHourlyTokens int ThrottleAllowBurst int @@ -147,6 +148,7 @@ func (o *GitHubOptions) addFlags(fs *flag.FlagSet, paramFuncs ...FlagParameter) fs.IntVar(&o.max404Retries, "github-client.max-404-retries", github.DefaultMax404Retries, "Maximum number of retries that will be used for a 404-ing request to the GitHub API.") fs.DurationVar(&o.maxSleepTime, "github-client.backoff-timeout", github.DefaultMaxSleepTime, "Largest allowable Retry-After time for requests to the GitHub API.") fs.DurationVar(&o.initialDelay, "github-client.initial-delay", github.DefaultInitialDelay, "Initial delay before retries begin for requests to the GitHub API.") + fs.StringVar(&o.SigningKeyPath, "git-signing-key-path", "", "Path to an SSH private key for signing git commits. When set, all commits made by the git client are signed using SSH.") } func (o *GitHubOptions) parseOrgThrottlers() error { @@ -341,6 +343,7 @@ func (o *GitHubOptions) GitClientFactory(cookieFilePath string, cacheDir *string CookieFilePath: cookieFilePath, Host: o.Host, Persist: &persistCache, + SigningKeyPath: o.SigningKeyPath, } if cacheDir != nil && *cacheDir != "" { opts.CacheDirBase = cacheDir diff --git a/pkg/git/v2/client_factory.go b/pkg/git/v2/client_factory.go index e329cc0831..1f9a3d2169 100644 --- a/pkg/git/v2/client_factory.go +++ b/pkg/git/v2/client_factory.go @@ -125,6 +125,9 @@ type ClientFactoryOpts struct { CookieFilePath string // If set, cacheDir persist. Otherwise temp dir will be used for CacheDir Persist *bool + // SigningKeyPath is the path to an SSH private key for signing commits. + // When set, cloned repos are configured with gpg.format=ssh and commit.gpgsign=true. + SigningKeyPath string } // These options are scoped to the repo, not the ClientFactory level. The reason @@ -186,6 +189,9 @@ func (cfo *ClientFactoryOpts) Apply(target *ClientFactoryOpts) { if cfo.Persist != nil { target.Persist = cfo.Persist } + if cfo.SigningKeyPath != "" { + target.SigningKeyPath = cfo.SigningKeyPath + } } func defaultTempDir() *string { @@ -270,6 +276,13 @@ func WithPersist(persist bool) ClientFactoryOpt { } } +// WithSigningKeyPath sets the SigningKeyPath option. +func WithSigningKeyPath(path string) ClientFactoryOpt { + return func(o *ClientFactoryOpts) { + o.SigningKeyPath = path + } +} + func defaultClientFactoryOpts(cfo *ClientFactoryOpts) { if cfo.Host == "" { cfo.Host = "github.com" @@ -337,24 +350,30 @@ func NewClientFactory(opts ...ClientFactoryOpt) (ClientFactory, error) { repoLocks: map[string]*sync.Mutex{}, logger: logrus.WithField("client", "git"), cookieFilePath: o.CookieFilePath, + signingKeyPath: o.SigningKeyPath, }, nil } // NewLocalClientFactory allows for the creation of repository clients // based on a local filepath remote for testing -func NewLocalClientFactory(baseDir string, gitUser GitUserGetter, censor Censor) (ClientFactory, error) { +func NewLocalClientFactory(baseDir string, gitUser GitUserGetter, censor Censor, opts ...ClientFactoryOpt) (ClientFactory, error) { + o := ClientFactoryOpts{} + for _, opt := range opts { + opt(&o) + } cacheDir, err := os.MkdirTemp("", "gitcache") if err != nil { return nil, err } return &clientFactory{ - cacheDir: cacheDir, - remote: &pathResolverFactory{baseDir: baseDir}, - gitUser: gitUser, - censor: censor, - masterLock: &sync.Mutex{}, - repoLocks: map[string]*sync.Mutex{}, - logger: logrus.WithField("client", "git"), + cacheDir: cacheDir, + remote: &pathResolverFactory{baseDir: baseDir}, + gitUser: gitUser, + censor: censor, + signingKeyPath: o.SigningKeyPath, + masterLock: &sync.Mutex{}, + repoLocks: map[string]*sync.Mutex{}, + logger: logrus.WithField("client", "git"), }, nil } @@ -364,6 +383,7 @@ type clientFactory struct { censor Censor logger *logrus.Entry cookieFilePath string + signingKeyPath string // cacheDir is the root under which cached clones of repos are created cacheDir string @@ -474,6 +494,18 @@ func (c *clientFactory) ClientForWithRepoOpts(org, repo string, repoOpts RepoOpt } gitMetrics.secondaryCloneDuration.WithLabelValues(org, repo).Observe(time.Since(timeBeforeSecondaryClone).Seconds()) + if c.signingKeyPath != "" { + for _, args := range [][]string{ + {"gpg.format", "ssh"}, + {"user.signingkey", c.signingKeyPath}, + {"commit.gpgsign", "true"}, + } { + if err := repoClient.Config(args...); err != nil { + return nil, fmt.Errorf("failed to configure commit signing: %w", err) + } + } + } + return repoClient, nil } diff --git a/pkg/git/v2/client_factory_test.go b/pkg/git/v2/client_factory_test.go new file mode 100644 index 0000000000..f3496ef439 --- /dev/null +++ b/pkg/git/v2/client_factory_test.go @@ -0,0 +1,124 @@ +/* +Copyright 2025 The Kubernetes 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 git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func TestClientFactorySigningKey(t *testing.T) { + t.Parallel() + + // Generate an SSH signing key. + keyDir := t.TempDir() + keyPath := filepath.Join(keyDir, "id_ed25519") + if out, err := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "").CombinedOutput(); err != nil { + t.Fatalf("generating SSH key: %v\n%s", err, out) + } + + // Create a source repo with a commit. + repoBase := t.TempDir() + repoDir := filepath.Join(repoBase, "org", "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + runGit(t, repoDir, "init") + runGit(t, repoDir, "config", "user.email", "test@test.test") + runGit(t, repoDir, "config", "user.name", "test") + runGit(t, repoDir, "config", "commit.gpgsign", "false") + if err := os.WriteFile(filepath.Join(repoDir, "file.txt"), []byte("hello\n"), 0644); err != nil { + t.Fatal(err) + } + runGit(t, repoDir, "add", "file.txt") + runGit(t, repoDir, "commit", "-m", "initial") + + // Create a patch to apply. + patchFile := filepath.Join(keyDir, "test.patch") + patchContent := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Test User +Date: Thu, 01 Jan 2026 00:00:00 +0000 +Subject: [PATCH] update file + +--- + file.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/file.txt b/file.txt +index ce01362..94954ab 100644 +--- a/file.txt ++++ b/file.txt +@@ -1 +1 @@ +-hello ++world +-- +2.40.0 +` + if err := os.WriteFile(patchFile, []byte(patchContent), 0644); err != nil { + t.Fatal(err) + } + + // Create factory with signing key. + factory, err := NewLocalClientFactory(repoBase, + func() (string, string, error) { return "test", "test@test.test", nil }, + func(in []byte) []byte { return in }, + WithSigningKeyPath(keyPath), + ) + if err != nil { + t.Fatalf("creating factory: %v", err) + } + defer factory.Clean() + + client, err := factory.ClientFor("org", "repo") + if err != nil { + t.Fatalf("getting client: %v", err) + } + defer client.Clean() + + if err := client.Config("user.name", "test"); err != nil { + t.Fatalf("setting user.name: %v", err) + } + if err := client.Config("user.email", "test@test.test"); err != nil { + t.Fatalf("setting user.email: %v", err) + } + + // Apply patch — should produce a signed commit. + if err := client.Am(patchFile); err != nil { + t.Fatalf("git am: %v", err) + } + + // Verify the commit is signed. + out, err := exec.Command("git", "-C", client.Directory(), "cat-file", "-p", "HEAD").CombinedOutput() + if err != nil { + t.Fatalf("inspecting commit: %v\n%s", err, out) + } + if !strings.Contains(string(out), "BEGIN SSH SIGNATURE") { + t.Errorf("expected commit to contain SSH signature, got:\n%s", out) + } +}