1
0
mirror of synced 2026-06-20 20:10:49 +00:00

Merge pull request #5920 from fluxcd/feat/ssh-commit-signing

Allow signing commits using SSH key
This commit is contained in:
Matheus Pimenta
2026-06-19 18:52:13 +01:00
committed by GitHub
24 changed files with 854 additions and 25 deletions
+90
View File
@@ -20,9 +20,11 @@ import (
"context"
"crypto/elliptic"
"fmt"
"os"
"strings"
"github.com/fluxcd/pkg/git"
"github.com/fluxcd/pkg/git/signature"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/api/errors"
@@ -30,6 +32,7 @@ import (
"github.com/fluxcd/flux2/v2/internal/flags"
"github.com/fluxcd/flux2/v2/internal/utils"
"github.com/fluxcd/flux2/v2/pkg/bootstrap"
"github.com/fluxcd/flux2/v2/pkg/manifestgen"
"github.com/fluxcd/flux2/v2/pkg/manifestgen/sourcesecret"
)
@@ -80,6 +83,11 @@ type bootstrapFlags struct {
gpgPassphrase string
gpgKeyID string
sshSigningKeyFile string
sshSigningPassword string
sshSigningPassphrase string
sshSigningReusePrivateKey bool
force bool
commitMessageAppendix string
@@ -142,6 +150,12 @@ func init() {
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.gpgPassphrase, "gpg-passphrase", "", "passphrase for decrypting GPG private key")
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.gpgKeyID, "gpg-key-id", "", "key id for selecting a particular key")
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.sshSigningKeyFile, "ssh-signing-key-file", "", "path to an SSH private key file used for signing commits")
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.sshSigningPassword, "ssh-signing-password", "", "passphrase for decrypting SSH signing key")
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.sshSigningPassphrase, "ssh-signing-passphrase", "", "alias for --ssh-signing-password")
bootstrapCmd.PersistentFlags().MarkHidden("ssh-signing-passphrase")
bootstrapCmd.PersistentFlags().BoolVar(&bootstrapArgs.sshSigningReusePrivateKey, "ssh-signing-reuse-private-key", false, "use the SSH transport key (--private-key-file) to sign commits")
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.commitMessageAppendix, "commit-message-appendix", "", "string to add to the commit messages, e.g. '[ci skip]'")
bootstrapCmd.PersistentFlags().BoolVar(&bootstrapArgs.force, "force", false, "override existing Flux installation if it's managed by a different tool such as Helm")
@@ -198,6 +212,31 @@ func bootstrapValidate() error {
return fmt.Errorf("invalid --registry-creds format, expected 'user:password'")
}
sshSigningSet := bootstrapArgs.sshSigningKeyFile != "" || bootstrapArgs.sshSigningReusePrivateKey
if bootstrapArgs.gpgKeyRingPath != "" && sshSigningSet {
return fmt.Errorf("--gpg-* and --ssh-signing-* are mutually exclusive; pick one signing format")
}
if bootstrapArgs.sshSigningKeyFile != "" && bootstrapArgs.sshSigningReusePrivateKey {
return fmt.Errorf("--ssh-signing-key-file and --ssh-signing-reuse-private-key are mutually exclusive")
}
if bootstrapArgs.sshSigningReusePrivateKey && bootstrapArgs.privateKeyFile == "" {
return fmt.Errorf("--ssh-signing-reuse-private-key requires --private-key-file")
}
sshSigningPwd, err := effectiveSshSigningPassword()
if err != nil {
return err
}
if sshSigningPwd != "" && bootstrapArgs.sshSigningKeyFile == "" {
return fmt.Errorf("--ssh-signing-password requires --ssh-signing-key-file")
}
if err := preflightSigningKey(); err != nil {
return err
}
if len(bootstrapArgs.sshHostKeyAlgorithms) > 0 {
git.HostKeyAlgos = bootstrapArgs.sshHostKeyAlgorithms
}
@@ -217,6 +256,57 @@ func mapTeamSlice(s []string, defaultPermission string) map[string]string {
return m
}
// preflightSigningKey reads and parses the configured signing key so
// malformed PEM, wrong passphrases, and unsupported SSH algorithms
// surface before any clone runs.
func preflightSigningKey() error {
switch {
case bootstrapArgs.gpgKeyRingPath != "":
ring, err := bootstrap.LoadEntityListFromPath(bootstrapArgs.gpgKeyRingPath)
if err != nil {
return fmt.Errorf("invalid GPG signing key: %w", err)
}
if _, err := bootstrap.SelectOpenPGPSigningEntity(ring, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID); err != nil {
return fmt.Errorf("invalid GPG signing key: %w", err)
}
case bootstrapArgs.sshSigningKeyFile != "":
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
if err != nil {
return fmt.Errorf("failed to read SSH signing key file: %w", err)
}
pwd, err := effectiveSshSigningPassword()
if err != nil {
return err
}
if _, err := signature.NewSSHSigner(pemBytes, []byte(pwd)); err != nil {
return fmt.Errorf("invalid SSH signing key: %w", err)
}
}
return nil
}
// effectiveSshSigningPassword resolves the SSH signing-key passphrase
// from --ssh-signing-password and its hidden alias
// --ssh-signing-passphrase. When both are set with the same value, the
// value is returned. When both are set with different non-empty values,
// an error is returned. When neither is set, an empty string is
// returned with no error.
func effectiveSshSigningPassword() (string, error) {
pw := bootstrapArgs.sshSigningPassword
alias := bootstrapArgs.sshSigningPassphrase
switch {
case pw != "" && alias != "":
if pw != alias {
return "", fmt.Errorf("--ssh-signing-password and --ssh-signing-passphrase are aliases; do not pass both")
}
return pw, nil
case pw == "" && alias != "":
return alias, nil
default:
return pw, nil
}
}
// confirmBootstrap gets a confirmation for running bootstrap over an existing Flux installation.
// It returns a nil error if Flux is not installed or the user confirms overriding an existing installation
func confirmBootstrap(ctx context.Context, kubeClient client.Client) error {
+26
View File
@@ -24,6 +24,7 @@ import (
"github.com/fluxcd/pkg/git"
"github.com/fluxcd/pkg/git/gogit"
"github.com/fluxcd/pkg/git/signature"
"github.com/spf13/cobra"
"github.com/fluxcd/flux2/v2/internal/flags"
@@ -288,6 +289,31 @@ func bootstrapBServerCmdRun(cmd *cobra.Command, args []string) error {
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
}
if bootstrapArgs.sshSigningKeyFile != "" {
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
if err != nil {
return fmt.Errorf("failed to read SSH signing key file: %w", err)
}
pwd, err := effectiveSshSigningPassword()
if err != nil {
return err
}
bootstrapOpts = append(bootstrapOpts,
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
}
if bootstrapArgs.sshSigningReusePrivateKey {
pemBytes, err := os.ReadFile(bootstrapArgs.privateKeyFile)
if err != nil {
return fmt.Errorf("failed to read transport private key for signing: %w", err)
}
if _, err := signature.NewSSHSigner(pemBytes, []byte(gitArgs.password)); err != nil {
return fmt.Errorf("invalid signing key (reused from --private-key-file): %w", err)
}
bootstrapOpts = append(bootstrapOpts,
bootstrap.WithSSHCommitSigning(pemBytes, []byte(gitArgs.password)))
}
// Setup bootstrapper with constructed configs
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
if err != nil {
+28
View File
@@ -33,6 +33,7 @@ import (
authutils "github.com/fluxcd/pkg/auth/utils"
"github.com/fluxcd/pkg/git"
"github.com/fluxcd/pkg/git/gogit"
"github.com/fluxcd/pkg/git/signature"
"github.com/fluxcd/flux2/v2/internal/flags"
"github.com/fluxcd/flux2/v2/internal/utils"
@@ -334,6 +335,33 @@ func bootstrapGitCmdRun(cmd *cobra.Command, args []string) error {
bootstrap.WithGitCommitSigning(entityList, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID),
}
if bootstrapArgs.sshSigningKeyFile != "" {
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
if err != nil {
return fmt.Errorf("failed to read SSH signing key file: %w", err)
}
pwd, err := effectiveSshSigningPassword()
if err != nil {
return err
}
bootstrapOpts = append(bootstrapOpts,
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
}
if bootstrapArgs.sshSigningReusePrivateKey {
pemBytes, err := os.ReadFile(bootstrapArgs.privateKeyFile)
if err != nil {
return fmt.Errorf("failed to read transport private key for signing: %w", err)
}
// Reuse-path pre-flight: bootstrapValidate cannot run this check
// because the SSH transport password is subcommand-local.
if _, err := signature.NewSSHSigner(pemBytes, []byte(gitArgs.password)); err != nil {
return fmt.Errorf("invalid signing key (reused from --private-key-file): %w", err)
}
bootstrapOpts = append(bootstrapOpts,
bootstrap.WithSSHCommitSigning(pemBytes, []byte(gitArgs.password)))
}
// Setup bootstrapper with constructed configs
b, err := bootstrap.NewPlainGitProvider(gitClient, kubeClient, bootstrapOpts...)
if err != nil {
+19
View File
@@ -107,6 +107,11 @@ func init() {
}
func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error {
if bootstrapArgs.sshSigningReusePrivateKey {
return fmt.Errorf("--ssh-signing-reuse-private-key is not supported by 'bootstrap gitea'; " +
"that subcommand generates the SSH transport key in-process and has no operator-supplied key to reuse")
}
gtToken := os.Getenv(gtTokenEnvVar)
if gtToken == "" {
var err error
@@ -253,6 +258,7 @@ func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error {
bootstrap.WithLogger(logger),
bootstrap.WithGitCommitSigning(entityList, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID),
}
if bootstrapArgs.sshHostname != "" {
bootstrapOpts = append(bootstrapOpts, bootstrap.WithSSHHostname(bootstrapArgs.sshHostname))
}
@@ -266,6 +272,19 @@ func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error {
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
}
if bootstrapArgs.sshSigningKeyFile != "" {
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
if err != nil {
return fmt.Errorf("failed to read SSH signing key file: %w", err)
}
pwd, err := effectiveSshSigningPassword()
if err != nil {
return err
}
bootstrapOpts = append(bootstrapOpts,
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
}
// Setup bootstrapper with constructed configs
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
if err != nil {
+19
View File
@@ -107,6 +107,11 @@ func init() {
}
func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error {
if bootstrapArgs.sshSigningReusePrivateKey {
return fmt.Errorf("--ssh-signing-reuse-private-key is not supported by 'bootstrap github'; " +
"that subcommand generates the SSH transport key in-process and has no operator-supplied key to reuse")
}
ghToken := os.Getenv(ghTokenEnvVar)
if ghToken == "" {
var err error
@@ -260,6 +265,7 @@ func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error {
bootstrap.WithLogger(logger),
bootstrap.WithGitCommitSigning(entityList, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID),
}
if bootstrapArgs.sshHostname != "" {
bootstrapOpts = append(bootstrapOpts, bootstrap.WithSSHHostname(bootstrapArgs.sshHostname))
}
@@ -273,6 +279,19 @@ func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error {
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
}
if bootstrapArgs.sshSigningKeyFile != "" {
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
if err != nil {
return fmt.Errorf("failed to read SSH signing key file: %w", err)
}
pwd, err := effectiveSshSigningPassword()
if err != nil {
return err
}
bootstrapOpts = append(bootstrapOpts,
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
}
// Setup bootstrapper with constructed configs
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
if err != nil {
+26
View File
@@ -27,6 +27,7 @@ import (
"github.com/fluxcd/go-git-providers/gitprovider"
"github.com/fluxcd/pkg/git"
"github.com/fluxcd/pkg/git/gogit"
"github.com/fluxcd/pkg/git/signature"
"github.com/spf13/cobra"
"github.com/fluxcd/flux2/v2/internal/flags"
@@ -322,6 +323,31 @@ func bootstrapGitLabCmdRun(cmd *cobra.Command, args []string) error {
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
}
if bootstrapArgs.sshSigningKeyFile != "" {
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
if err != nil {
return fmt.Errorf("failed to read SSH signing key file: %w", err)
}
pwd, err := effectiveSshSigningPassword()
if err != nil {
return err
}
bootstrapOpts = append(bootstrapOpts,
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
}
if bootstrapArgs.sshSigningReusePrivateKey {
pemBytes, err := os.ReadFile(bootstrapArgs.privateKeyFile)
if err != nil {
return fmt.Errorf("failed to read transport private key for signing: %w", err)
}
if _, err := signature.NewSSHSigner(pemBytes, []byte(gitArgs.password)); err != nil {
return fmt.Errorf("invalid signing key (reused from --private-key-file): %w", err)
}
bootstrapOpts = append(bootstrapOpts,
bootstrap.WithSSHCommitSigning(pemBytes, []byte(gitArgs.password)))
}
// Setup bootstrapper with constructed configs
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
if err != nil {
+208
View File
@@ -0,0 +1,208 @@
/*
Copyright 2026 The Flux 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 main
import (
"strings"
"testing"
)
func TestBootstrapValidate_signingFlags(t *testing.T) {
tests := []struct {
name string
gpgRing string
gpgPass string
sshKey string
sshPass string
sshPassp string
privateKey string
reuse bool
wantErr string
}{
{name: "no signing flags is valid"},
{name: "GPG only is valid", gpgRing: "./testdata/bootstrap/gpg.pgp"},
{name: "SSH only is valid", sshKey: "./testdata/bootstrap/ed25519.private"},
{
name: "Reuse-private-key with private-key-file is valid",
privateKey: "./testdata/bootstrap/ed25519.private",
reuse: true,
},
{
name: "GPG + SSH errors",
gpgRing: "./testdata/bootstrap/gpg.pgp",
sshKey: "./testdata/bootstrap/ed25519.private",
wantErr: "--gpg-* and --ssh-signing-* are mutually exclusive",
},
{
name: "GPG + reuse errors",
gpgRing: "./testdata/bootstrap/gpg.pgp",
privateKey: "./testdata/bootstrap/ed25519.private",
reuse: true,
wantErr: "--gpg-* and --ssh-signing-* are mutually exclusive",
},
{
name: "SSH key-file + reuse errors",
sshKey: "./testdata/bootstrap/ed25519.private",
privateKey: "./testdata/bootstrap/ed25519.private",
reuse: true,
wantErr: "--ssh-signing-key-file and --ssh-signing-reuse-private-key are mutually exclusive",
},
{
name: "Reuse without private-key-file errors",
reuse: true,
wantErr: "--ssh-signing-reuse-private-key requires --private-key-file",
},
{
name: "SSH password without key errors",
sshPass: "secret",
wantErr: "--ssh-signing-password requires --ssh-signing-key-file",
},
{
name: "SSH passphrase alias alone applies",
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
sshPassp: "abcde12345",
},
{
name: "SSH password and passphrase with same value passes",
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
sshPass: "abcde12345",
sshPassp: "abcde12345",
},
{
name: "SSH password and passphrase with different values errors",
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
sshPass: "right",
sshPassp: "wrong",
wantErr: "are aliases; do not pass both",
},
{
name: "SSH malformed key fails pre-flight",
sshKey: "./testdata/bootstrap/malformed.private",
wantErr: "invalid SSH signing key",
},
{
name: "SSH encrypted key without password fails pre-flight",
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
wantErr: "passphrase required",
},
// The GPG fixture used here is encrypted (passphrase: "right") so that
// passing the wrong passphrase exercises the Decrypt error path.
// An unencrypted key would make Decrypt a no-op regardless of the
// passphrase supplied.
{
name: "GPG with wrong passphrase fails pre-flight",
gpgRing: "./testdata/bootstrap/gpg-encrypted.pgp",
gpgPass: "wrong",
wantErr: "invalid GPG signing key",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
savedDefaultComponents := bootstrapArgs.defaultComponents
savedGpgRing := bootstrapArgs.gpgKeyRingPath
savedGpgPass := bootstrapArgs.gpgPassphrase
savedSshKey := bootstrapArgs.sshSigningKeyFile
savedSshPass := bootstrapArgs.sshSigningPassword
savedSshPassp := bootstrapArgs.sshSigningPassphrase
savedPrivKey := bootstrapArgs.privateKeyFile
savedReuse := bootstrapArgs.sshSigningReusePrivateKey
defer func() {
bootstrapArgs.defaultComponents = savedDefaultComponents
bootstrapArgs.gpgKeyRingPath = savedGpgRing
bootstrapArgs.gpgPassphrase = savedGpgPass
bootstrapArgs.sshSigningKeyFile = savedSshKey
bootstrapArgs.sshSigningPassword = savedSshPass
bootstrapArgs.sshSigningPassphrase = savedSshPassp
bootstrapArgs.privateKeyFile = savedPrivKey
bootstrapArgs.sshSigningReusePrivateKey = savedReuse
}()
// The e2e TestMain calls resetCmdArgs which clears the
// cobra-populated default components, so seed them here to
// satisfy the requiredComponents pre-check in bootstrapValidate.
bootstrapArgs.defaultComponents = bootstrapArgs.requiredComponents
bootstrapArgs.gpgKeyRingPath = tt.gpgRing
bootstrapArgs.gpgPassphrase = tt.gpgPass
bootstrapArgs.sshSigningKeyFile = tt.sshKey
bootstrapArgs.sshSigningPassword = tt.sshPass
bootstrapArgs.sshSigningPassphrase = tt.sshPassp
bootstrapArgs.privateKeyFile = tt.privateKey
bootstrapArgs.sshSigningReusePrivateKey = tt.reuse
err := bootstrapValidate()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err)
}
})
}
}
// Providers that generate the SSH transport key in-process (github, gitea)
// must reject --ssh-signing-reuse-private-key with their own, provider-
// specific error before bootstrapValidate runs — otherwise the generic
// "--ssh-signing-reuse-private-key requires --private-key-file" error
// shadows the fact that the flag is fundamentally unsupported there.
func TestBootstrapProviderRejectsReuseBeforeValidate(t *testing.T) {
tests := []struct {
name string
runE func() error
wantErr string
}{
{
name: "github rejects reuse with provider-specific error",
runE: func() error { return bootstrapGitHubCmdRun(nil, nil) },
wantErr: "not supported by 'bootstrap github'",
},
{
name: "gitea rejects reuse with provider-specific error",
runE: func() error { return bootstrapGiteaCmdRun(nil, nil) },
wantErr: "not supported by 'bootstrap gitea'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
savedReuse := bootstrapArgs.sshSigningReusePrivateKey
savedPrivKey := bootstrapArgs.privateKeyFile
defer func() {
bootstrapArgs.sshSigningReusePrivateKey = savedReuse
bootstrapArgs.privateKeyFile = savedPrivKey
}()
// Reuse flag set, no --private-key-file: bootstrapValidate
// would otherwise return "requires --private-key-file".
bootstrapArgs.sshSigningReusePrivateKey = true
bootstrapArgs.privateKeyFile = ""
err := tt.runE()
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err)
}
})
}
}
+21
View File
@@ -23,6 +23,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
autov1 "github.com/fluxcd/image-automation-controller/api/v1"
"github.com/fluxcd/pkg/apis/meta"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
)
@@ -75,6 +76,8 @@ type imageUpdateFlags struct {
commitTemplate string
authorName string
authorEmail string
signingKeySecret string
signingKeyType string
}
var imageUpdateArgs = imageUpdateFlags{}
@@ -89,6 +92,8 @@ func init() {
flags.StringVar(&imageUpdateArgs.commitTemplate, "commit-template", "", "a template for commit messages")
flags.StringVar(&imageUpdateArgs.authorName, "author-name", "", "the name to use for commit author")
flags.StringVar(&imageUpdateArgs.authorEmail, "author-email", "", "the email to use for commit author")
flags.StringVar(&imageUpdateArgs.signingKeySecret, "signing-key-secret", "", "name of the Secret containing the signing key referenced in spec.git.commit.signingKey")
flags.StringVar(&imageUpdateArgs.signingKeyType, "signing-key-type", "", "signing-key format: gpg or ssh (defaults to gpg when --signing-key-secret is set)")
createImageCmd.AddCommand(createImageUpdateCmd)
}
@@ -112,6 +117,15 @@ func createImageUpdateRun(cmd *cobra.Command, args []string) error {
return fmt.Errorf("the author email is required (--author-email)")
}
if imageUpdateArgs.signingKeyType != "" && imageUpdateArgs.signingKeySecret == "" {
return fmt.Errorf("--signing-key-type requires --signing-key-secret")
}
if imageUpdateArgs.signingKeyType != "" &&
imageUpdateArgs.signingKeyType != string(autov1.SigningKeyTypeGPG) &&
imageUpdateArgs.signingKeyType != string(autov1.SigningKeyTypeSSH) {
return fmt.Errorf("--signing-key-type must be one of: gpg, ssh")
}
labels, err := parseLabels()
if err != nil {
return err
@@ -163,6 +177,13 @@ func createImageUpdateRun(cmd *cobra.Command, args []string) error {
}
}
if imageUpdateArgs.signingKeySecret != "" {
update.Spec.GitSpec.Commit.SigningKey = &autov1.SigningKey{
SecretRef: meta.LocalObjectReference{Name: imageUpdateArgs.signingKeySecret},
Type: autov1.SigningKeyType(imageUpdateArgs.signingKeyType),
}
}
if createArgs.export {
return printExport(exportImageUpdate(&update))
}
+62
View File
@@ -0,0 +1,62 @@
/*
Copyright 2026 The Flux 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 main
import "testing"
func TestCreateImageUpdate(t *testing.T) {
tests := []struct {
name string
args string
assert assertFunc
}{
{
name: "no signing key",
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --interval=1m0s --namespace=flux-system --export",
assert: assertGoldenFile("./testdata/create_image_update/no-signing.yaml"),
},
{
name: "signing secret without explicit type defaults to gpg",
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-secret=my-key --interval=1m0s --namespace=flux-system --export",
assert: assertGoldenFile("./testdata/create_image_update/signing-default-gpg.yaml"),
},
{
name: "ssh signing key",
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-secret=my-deploy-key --signing-key-type=ssh --interval=1m0s --namespace=flux-system --export",
assert: assertGoldenFile("./testdata/create_image_update/signing-ssh.yaml"),
},
{
name: "signing-key-type without secret errors",
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-type=ssh --namespace=flux-system --export",
assert: assertError("--signing-key-type requires --signing-key-secret"),
},
{
name: "invalid signing-key-type errors",
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-secret=k --signing-key-type=pgp --namespace=flux-system --export",
assert: assertError("--signing-key-type must be one of: gpg, ssh"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := cmdTestCase{
args: tt.args,
assert: tt.assert,
}
cmd.runTestCmd(t)
})
}
}
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDuUiEMA0
eUvKlmOsur2w9FAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIDF/w86ZQb5qmZtv
m1GvyLojiJdhmPtI9hJ9XPcP7HBoAAAAkG2cIOuSVdWInSC0P81ExiorUpiAGOjxxpgvKW
VYERfU1zU72Z/c9n1+z/IH5cJOhZ1vlqBO0rubl4s0KQFvY/LKcsc4N0x0uzpqrvcJP4tO
9VW8LrMnrPp7b6KVJPsbeSW1SBcUM24aCMzF4/wV03mN/Uqz30s+YgS9SU4Lz8AOkX58xX
yAV0gkmndIzZl+Og==
-----END OPENSSH PRIVATE KEY-----
+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCAAAAIjjSDmx40g5
sQAAAAtzc2gtZWQyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCA
AAAEAGpzSFuLkCNDD49+tysxSFFwdOsRnDj67vDT9bfwoSDhYOV20IV1IxchXW8vBe9HCT
h4SZKgMKnE2Rxs2mHd0IAAAABHRlc3QB
-----END OPENSSH PRIVATE KEY-----
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
not a real ssh key
+19
View File
@@ -0,0 +1,19 @@
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: flux-system
namespace: flux-system
spec:
git:
checkout:
ref:
branch: main
commit:
author:
email: flux@example.com
name: flux
interval: 1m0s
sourceRef:
kind: GitRepository
name: flux-system
@@ -0,0 +1,22 @@
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: flux-system
namespace: flux-system
spec:
git:
checkout:
ref:
branch: main
commit:
author:
email: flux@example.com
name: flux
signingKey:
secretRef:
name: my-key
interval: 1m0s
sourceRef:
kind: GitRepository
name: flux-system
+23
View File
@@ -0,0 +1,23 @@
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: flux-system
namespace: flux-system
spec:
git:
checkout:
ref:
branch: main
commit:
author:
email: flux@example.com
name: flux
signingKey:
secretRef:
name: my-deploy-key
type: ssh
interval: 1m0s
sourceRef:
kind: GitRepository
name: flux-system
+4
View File
@@ -10,6 +10,10 @@ spec:
author:
email: fluxcdbot@users.noreply.github.com
name: fluxcdbot
signingKey:
secretRef:
name: my-signing-key
type: ssh
interval: 1m0s
sourceRef:
kind: GitRepository
+4
View File
@@ -67,6 +67,10 @@ spec:
email: fluxcdbot@users.noreply.github.com
name: fluxcdbot
messageTemplate: '{{range .Updated.Images}}{{println .}}{{end}}'
signingKey:
secretRef:
name: my-signing-key
type: ssh
update:
path: ./clusters/my-cluster
strategy: Setters
+2 -2
View File
@@ -23,7 +23,7 @@ require (
github.com/fluxcd/pkg/auth v0.51.0
github.com/fluxcd/pkg/chartutil v1.26.0
github.com/fluxcd/pkg/envsubst v1.7.0
github.com/fluxcd/pkg/git v0.50.0
github.com/fluxcd/pkg/git v0.52.0
github.com/fluxcd/pkg/kustomize v1.35.0
github.com/fluxcd/pkg/oci v0.66.0
github.com/fluxcd/pkg/runtime v0.108.0
@@ -31,7 +31,7 @@ require (
github.com/fluxcd/pkg/ssa v0.75.0
github.com/fluxcd/pkg/ssh v0.25.0
github.com/fluxcd/pkg/tar v1.2.0
github.com/fluxcd/pkg/version v0.15.0
github.com/fluxcd/pkg/version v0.16.0
github.com/fluxcd/source-controller/api v1.9.0
github.com/fluxcd/source-watcher/api/v2 v2.1.1
github.com/go-git/go-git/v5 v5.19.1
+4 -4
View File
@@ -202,8 +202,8 @@ github.com/fluxcd/pkg/chartutil v1.26.0 h1:bgXwDKl2uGITSDxOXe4N5HTXAL0ilk4YVbPLU
github.com/fluxcd/pkg/chartutil v1.26.0/go.mod h1:sWDcF//xpIwZ/MQupwTmuM/SgGCkfsTd9BVOQJ3cTjM=
github.com/fluxcd/pkg/envsubst v1.7.0 h1:PL9Nj/V2fgaMR9KYZR7mEEw+vlYgP80nFZjOQQKAfJs=
github.com/fluxcd/pkg/envsubst v1.7.0/go.mod h1:aoWeSIOamhqBZ3bHVj1GDwpdA10DXrI8yYbyjPiFly0=
github.com/fluxcd/pkg/git v0.50.0 h1:gbGmSTjQ6CxqFmT9ZkLfYh8kG7CHqc7gHoPqcygixK8=
github.com/fluxcd/pkg/git v0.50.0/go.mod h1:OgaHoS0iR0GuLl+f778X7NrGy1pDH7xcpF/nsCRgJ9g=
github.com/fluxcd/pkg/git v0.52.0 h1:dgsliHdaLADUcDO4pI0pc11N4dZ21NfDdhNcgRNuAkM=
github.com/fluxcd/pkg/git v0.52.0/go.mod h1:mOvFDxoiuz+Mm4Ux1wKeTTckvBgZFvbTK8lNxmVHzKs=
github.com/fluxcd/pkg/gittestserver v0.29.0 h1:2j03zKVL6iVn6oiUuecG/O/3Q1pULWM9JrF/HSjkpnc=
github.com/fluxcd/pkg/gittestserver v0.29.0/go.mod h1:O8151jV0ppBZTb9IUXMjxh6hZpkiuLq8JQHDBPOkZFw=
github.com/fluxcd/pkg/kustomize v1.35.0 h1:Hl3Y6WPPo1btDyUTrT+CS3b4GbAqCuE+bZebpIG2cxg=
@@ -220,8 +220,8 @@ github.com/fluxcd/pkg/ssh v0.25.0 h1:4Y9WmuNqyKvH759UznU5DGHRcOuoJ/dQM6sbsaDZYYM
github.com/fluxcd/pkg/ssh v0.25.0/go.mod h1:Fli2Ogu4uaIVGbCy+r0vvZlMO0RfuInyNY1q2FVIx0o=
github.com/fluxcd/pkg/tar v1.2.0 h1:T6WFB5M0YRHktlrgdKNskqpdp76TVDdWTOeuWz33CFs=
github.com/fluxcd/pkg/tar v1.2.0/go.mod h1:Wlalp5vIVe+BbckkKkqExKcoHAeeWJPAzwK7ONeFcS0=
github.com/fluxcd/pkg/version v0.15.0 h1:E2Ju4i0vj8ZXLHKz/F4a8JTmDh7Jcg8okB0hK5rEoTM=
github.com/fluxcd/pkg/version v0.15.0/go.mod h1:LEHnvLMgbTk4kelF+JHHzaG77kY9uTWodMtadPRMEW8=
github.com/fluxcd/pkg/version v0.16.0 h1:VR9+143LAwbyUSAaMhiJHbfsiU+fTjA9L/3dr1ucfrI=
github.com/fluxcd/pkg/version v0.16.0/go.mod h1:2M/l90CmbDaD21JTh77hjwaUbd/YM96+Fo8x4fMdxLI=
github.com/fluxcd/source-controller/api v1.9.0 h1:kO5elmtCk227YKAfpTY+8Q6rgozOYmeyRRskGZBEbbo=
github.com/fluxcd/source-controller/api v1.9.0/go.mod h1:NsJJuEPUYYUJ0u8wMnAqS2T+bXyxOmGrnwdzvZRTiZw=
github.com/fluxcd/source-watcher/api/v2 v2.1.1 h1:1LfT50ty+78MKKbschAZl28QbVqIyjaNq17KmW5wPJI=
+59 -19
View File
@@ -43,6 +43,7 @@ import (
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/git"
"github.com/fluxcd/pkg/git/repository"
"github.com/fluxcd/pkg/git/signature"
"github.com/fluxcd/pkg/kustomize/filesys"
runclient "github.com/fluxcd/pkg/runtime/client"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
@@ -67,6 +68,9 @@ type PlainGitBootstrapper struct {
gpgPassphrase string
gpgKeyID string
sshSigningKey []byte
sshSigningPassword []byte
restClientGetter genericclioptions.RESTClientGetter
restClientOptions *runclient.Options
@@ -155,24 +159,27 @@ func (b *PlainGitBootstrapper) ReconcileComponents(ctx context.Context, manifest
b.logger.Successf("generated component manifests")
// Write generated files and make a commit
var signer *openpgp.Entity
if b.gpgKeyRing != nil {
signer, err = getOpenPgpEntity(b.gpgKeyRing, b.gpgPassphrase, b.gpgKeyID)
if err != nil {
return fmt.Errorf("failed to generate OpenPGP entity: %w", err)
}
signer, err := b.resolveSigner()
if err != nil {
return fmt.Errorf("failed to construct commit signer: %w", err)
}
commitMsg := fmt.Sprintf("Add Flux %s component manifests", options.Version)
if b.commitMessageAppendix != "" {
commitMsg = commitMsg + "\n\n" + b.commitMessageAppendix
}
commitOpts := []repository.CommitOption{
repository.WithFiles(map[string]io.Reader{
manifests.Path: strings.NewReader(manifests.Content),
}),
}
if signer != nil {
commitOpts = append(commitOpts, repository.WithSigner(signer))
}
commit, err := b.gitClient.Commit(git.Commit{
Author: b.signature,
Message: commitMsg,
}, repository.WithFiles(map[string]io.Reader{
manifests.Path: strings.NewReader(manifests.Content),
}), repository.WithSigner(signer))
}, commitOpts...)
if err != nil && err != git.ErrNoStagedFiles {
return fmt.Errorf("failed to commit component manifests: %w", err)
}
@@ -330,24 +337,27 @@ func (b *PlainGitBootstrapper) ReconcileSyncConfig(ctx context.Context, options
b.logger.Successf("generated sync manifests")
// Write generated files and make a commit
var signer *openpgp.Entity
if b.gpgKeyRing != nil {
signer, err = getOpenPgpEntity(b.gpgKeyRing, b.gpgPassphrase, b.gpgKeyID)
if err != nil {
return fmt.Errorf("failed to generate OpenPGP entity: %w", err)
}
signer, err := b.resolveSigner()
if err != nil {
return fmt.Errorf("failed to construct commit signer: %w", err)
}
commitMsg := "Add Flux sync manifests"
if b.commitMessageAppendix != "" {
commitMsg = commitMsg + "\n\n" + b.commitMessageAppendix
}
commitOpts := []repository.CommitOption{
repository.WithFiles(map[string]io.Reader{
kusManifests.Path: strings.NewReader(kusManifests.Content),
}),
}
if signer != nil {
commitOpts = append(commitOpts, repository.WithSigner(signer))
}
commit, err := b.gitClient.Commit(git.Commit{
Author: b.signature,
Message: commitMsg,
}, repository.WithFiles(map[string]io.Reader{
kusManifests.Path: strings.NewReader(kusManifests.Content),
}), repository.WithSigner(signer))
}, commitOpts...)
if err != nil && err != git.ErrNoStagedFiles {
return fmt.Errorf("failed to commit sync manifests: %w", err)
}
@@ -511,7 +521,33 @@ func (b *PlainGitBootstrapper) cleanGitRepoDir() error {
return errors.Join(errs...)
}
func getOpenPgpEntity(keyRing openpgp.EntityList, passphrase, keyID string) (*openpgp.Entity, error) {
// resolveSigner returns a signature.Signer derived from the configured
// commit-signing options, or (nil, nil) when no signing has been
// configured. GPG and SSH signing are mutually exclusive; if both have
// been configured the GPG path wins (the caller is responsible for
// rejecting the combination at flag-validation time).
func (b *PlainGitBootstrapper) resolveSigner() (signature.Signer, error) {
switch {
case b.gpgKeyRing != nil:
entity, err := SelectOpenPGPSigningEntity(b.gpgKeyRing, b.gpgPassphrase, b.gpgKeyID)
if err != nil {
return nil, fmt.Errorf("failed to load OpenPGP signing entity: %w", err)
}
return signature.NewOpenPGPSigner(entity)
case len(b.sshSigningKey) > 0:
return signature.NewSSHSigner(b.sshSigningKey, b.sshSigningPassword)
default:
return nil, nil
}
}
// SelectOpenPGPSigningEntity selects a single OpenPGP entity from the
// given keyring and decrypts its private key with the provided
// passphrase. When keyID is empty the keyring must contain exactly one
// entity; otherwise the entity with the matching 16-character key ID
// is selected. Returns an error if no matching entity is found, the
// matching entity has no private key, or decryption fails.
func SelectOpenPGPSigningEntity(keyRing openpgp.EntityList, passphrase, keyID string) (*openpgp.Entity, error) {
if len(keyRing) == 0 {
return nil, fmt.Errorf("empty GPG key ring")
}
@@ -538,6 +574,10 @@ func getOpenPgpEntity(keyRing openpgp.EntityList, passphrase, keyID string) (*op
}
} else {
entity = keyRing[0]
if entity.PrivateKey == nil {
return nil, fmt.Errorf("keyring does not contain a private key; " +
"export the secret key with 'gpg --export-secret-keys' or specify --gpg-key-id")
}
}
err := entity.PrivateKey.Decrypt([]byte(passphrase))
+174
View File
@@ -18,10 +18,23 @@ package bootstrap
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"io"
"strings"
"testing"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/git"
gogit "github.com/fluxcd/pkg/git/gogit"
"github.com/fluxcd/pkg/git/repository"
"github.com/fluxcd/pkg/git/signature"
extgogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
. "github.com/onsi/gomega"
gossh "golang.org/x/crypto/ssh"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -468,3 +481,164 @@ func Test_objectReconciled(t *testing.T) {
})
}
}
func TestPlainGitBootstrapper_resolveSigner(t *testing.T) {
t.Run("no signing configured returns nil signer", func(t *testing.T) {
g := NewWithT(t)
b := &PlainGitBootstrapper{}
signer, err := b.resolveSigner()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(signer).To(BeNil())
})
t.Run("GPG key ring returns an OpenPGP signer", func(t *testing.T) {
g := NewWithT(t)
entity, err := openpgp.NewEntity("Alice", "test", "alice@example.com", nil)
g.Expect(err).ToNot(HaveOccurred())
b := &PlainGitBootstrapper{gpgKeyRing: openpgp.EntityList{entity}}
signer, err := b.resolveSigner()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(signer).ToNot(BeNil())
})
t.Run("SSH key returns an SSH signer", func(t *testing.T) {
g := NewWithT(t)
_, priv, err := ed25519.GenerateKey(rand.Reader)
g.Expect(err).ToNot(HaveOccurred())
block, err := gossh.MarshalPrivateKey(priv, "test ed25519 key")
g.Expect(err).ToNot(HaveOccurred())
pemBytes := pem.EncodeToMemory(block)
b := &PlainGitBootstrapper{sshSigningKey: pemBytes}
signer, err := b.resolveSigner()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(signer).ToNot(BeNil())
})
t.Run("encrypted SSH key without password errors", func(t *testing.T) {
g := NewWithT(t)
_, priv, err := ed25519.GenerateKey(rand.Reader)
g.Expect(err).ToNot(HaveOccurred())
block, err := gossh.MarshalPrivateKeyWithPassphrase(priv, "test ed25519 key", []byte("pw"))
g.Expect(err).ToNot(HaveOccurred())
pemBytes := pem.EncodeToMemory(block)
b := &PlainGitBootstrapper{sshSigningKey: pemBytes}
_, err = b.resolveSigner()
g.Expect(err).To(HaveOccurred())
})
t.Run("GPG path takes precedence over SSH path", func(t *testing.T) {
g := NewWithT(t)
entity, err := openpgp.NewEntity("Alice", "test", "alice@example.com", nil)
g.Expect(err).ToNot(HaveOccurred())
b := &PlainGitBootstrapper{
gpgKeyRing: openpgp.EntityList{entity},
sshSigningKey: []byte("ignored"),
}
signer, err := b.resolveSigner()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(signer).ToNot(BeNil())
})
}
func TestSelectOpenPGPSigningEntity(t *testing.T) {
t.Run("empty key ring errors", func(t *testing.T) {
g := NewWithT(t)
_, err := SelectOpenPGPSigningEntity(openpgp.EntityList{}, "", "")
g.Expect(err).To(MatchError(ContainSubstring("empty GPG key ring")))
})
t.Run("public-only key ring without key id errors instead of panicking", func(t *testing.T) {
g := NewWithT(t)
entity, err := openpgp.NewEntity("Alice", "test", "alice@example.com", nil)
g.Expect(err).ToNot(HaveOccurred())
entity.PrivateKey = nil
_, err = SelectOpenPGPSigningEntity(openpgp.EntityList{entity}, "", "")
g.Expect(err).To(MatchError(ContainSubstring("keyring does not contain a private key")))
})
t.Run("public-only key ring with matching key id errors with key id context", func(t *testing.T) {
g := NewWithT(t)
entity, err := openpgp.NewEntity("Alice", "test", "alice@example.com", nil)
g.Expect(err).ToNot(HaveOccurred())
keyID := entity.PrimaryKey.KeyIdString()
entity.PrivateKey = nil
_, err = SelectOpenPGPSigningEntity(openpgp.EntityList{entity}, "", keyID)
g.Expect(err).To(MatchError(ContainSubstring("keyring does not contain private key for key id")))
})
}
// TestPlainGitBootstrapper_sshSignerProducesVerifiableCommit is an
// end-to-end wiring test. resolveSigner already has unit tests for
// dispatch behaviour, but nothing in pkg/bootstrap exercises the full
// path from sshSigningKey → resolveSigner → repository.WithSigner →
// gogit.Client.Commit → gpgsig header on the resulting commit object.
// This test drives that path and then verifies the signature via
// signature.VerifySSHSignature, catching regressions that the existing
// dispatcher unit tests would miss.
func TestPlainGitBootstrapper_sshSignerProducesVerifiableCommit(t *testing.T) {
g := NewWithT(t)
// Generate an ed25519 keypair and marshal the private key to PEM.
pub, priv, err := ed25519.GenerateKey(rand.Reader)
g.Expect(err).ToNot(HaveOccurred())
pemBlock, err := gossh.MarshalPrivateKey(priv, "test ed25519 key")
g.Expect(err).ToNot(HaveOccurred())
pemBytes := pem.EncodeToMemory(pemBlock)
// Resolve a Signer via the same path the bootstrap commit code uses.
b := &PlainGitBootstrapper{sshSigningKey: pemBytes}
signer, err := b.resolveSigner()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(signer).ToNot(BeNil())
// Initialise a gogit.Client against a fresh on-disk repo. Init sets
// the internal repository pointer so that Commit can operate.
tmp := t.TempDir()
gogitClient, err := gogit.NewClient(tmp, nil)
g.Expect(err).ToNot(HaveOccurred())
// Use a file:// URL; Init only records the remote URL, it does not
// actually connect, so any syntactically valid URL works here.
g.Expect(gogitClient.Init(context.Background(), "file:///dev/null", git.DefaultBranch)).To(Succeed())
// Drive a commit through the same gogit pipeline bootstrap uses.
hash, err := gogitClient.Commit(
git.Commit{
Author: git.Signature{Name: "Test", Email: "test@example.com"},
Message: "ssh-signed test commit",
},
repository.WithFiles(map[string]io.Reader{
"signed-file": strings.NewReader("hello sshsig"),
}),
repository.WithSigner(signer),
)
g.Expect(err).ToNot(HaveOccurred())
// Read the commit object back via a plain go-git open of the same path.
repo, err := extgogit.PlainOpen(tmp)
g.Expect(err).ToNot(HaveOccurred())
commit, err := repo.CommitObject(plumbing.NewHash(hash))
g.Expect(err).ToNot(HaveOccurred())
// The commit must carry an SSH signature header.
g.Expect(commit.PGPSignature).To(HavePrefix("-----BEGIN SSH SIGNATURE-----"))
// Reconstruct the canonical payload (commit without signature) and
// run the full cryptographic verification against the known public key.
encoded := &plumbing.MemoryObject{}
g.Expect(commit.EncodeWithoutSignature(encoded)).To(Succeed())
payloadReader, err := encoded.Reader()
g.Expect(err).ToNot(HaveOccurred())
payload, err := io.ReadAll(payloadReader)
g.Expect(err).ToNot(HaveOccurred())
gosshPub, err := gossh.NewPublicKey(pub)
g.Expect(err).ToNot(HaveOccurred())
authorizedKey := gossh.MarshalAuthorizedKey(gosshPub)
_, err = signature.VerifySSHSignature(commit.PGPSignature, payload, string(authorizedKey))
g.Expect(err).ToNot(HaveOccurred())
}
+28
View File
@@ -145,6 +145,34 @@ func (o gitCommitSigningOption) applyGitProvider(b *GitProviderBootstrapper) {
o.applyGit(b.PlainGitBootstrapper)
}
// WithSSHCommitSigning configures the bootstrapper to sign commits with
// an SSH private key. pem is the PEM-encoded private key (typically the
// OpenSSH "-----BEGIN OPENSSH PRIVATE KEY-----" format produced by
// ssh-keygen). password is the optional passphrase for the key; pass
// nil for an unencrypted key.
//
// WithSSHCommitSigning and WithGitCommitSigning are mutually exclusive;
// calling both is undefined behavior. The caller is responsible for
// rejecting that combination before constructing the bootstrapper (the
// flux CLI does this in bootstrapValidate).
func WithSSHCommitSigning(pem, password []byte) Option {
return sshCommitSigningOption{pem: pem, password: password}
}
type sshCommitSigningOption struct {
pem []byte
password []byte
}
func (o sshCommitSigningOption) applyGit(b *PlainGitBootstrapper) {
b.sshSigningKey = o.pem
b.sshSigningPassword = o.password
}
func (o sshCommitSigningOption) applyGitProvider(b *GitProviderBootstrapper) {
o.applyGit(b.PlainGitBootstrapper)
}
func LoadEntityListFromPath(path string) (openpgp.EntityList, error) {
if path == "" {
return nil, nil