diff --git a/cmd/flux/bootstrap.go b/cmd/flux/bootstrap.go index 49cd88d8..67a81ad2 100644 --- a/cmd/flux/bootstrap.go +++ b/cmd/flux/bootstrap.go @@ -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 { diff --git a/cmd/flux/bootstrap_bitbucket_server.go b/cmd/flux/bootstrap_bitbucket_server.go index 7cbfce73..105da32b 100644 --- a/cmd/flux/bootstrap_bitbucket_server.go +++ b/cmd/flux/bootstrap_bitbucket_server.go @@ -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 { diff --git a/cmd/flux/bootstrap_git.go b/cmd/flux/bootstrap_git.go index 52b67723..4a6958ef 100644 --- a/cmd/flux/bootstrap_git.go +++ b/cmd/flux/bootstrap_git.go @@ -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 { diff --git a/cmd/flux/bootstrap_gitea.go b/cmd/flux/bootstrap_gitea.go index 81a2d544..767f61a2 100644 --- a/cmd/flux/bootstrap_gitea.go +++ b/cmd/flux/bootstrap_gitea.go @@ -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 { diff --git a/cmd/flux/bootstrap_github.go b/cmd/flux/bootstrap_github.go index a0c5974e..2867cd40 100644 --- a/cmd/flux/bootstrap_github.go +++ b/cmd/flux/bootstrap_github.go @@ -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 { diff --git a/cmd/flux/bootstrap_gitlab.go b/cmd/flux/bootstrap_gitlab.go index c1a16902..58e3e127 100644 --- a/cmd/flux/bootstrap_gitlab.go +++ b/cmd/flux/bootstrap_gitlab.go @@ -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 { diff --git a/cmd/flux/bootstrap_test.go b/cmd/flux/bootstrap_test.go new file mode 100644 index 00000000..4ec9b84c --- /dev/null +++ b/cmd/flux/bootstrap_test.go @@ -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) + } + }) + } +} diff --git a/cmd/flux/create_image_update.go b/cmd/flux/create_image_update.go index f0ee11cf..f1d21ded 100644 --- a/cmd/flux/create_image_update.go +++ b/cmd/flux/create_image_update.go @@ -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)) } diff --git a/cmd/flux/create_image_update_test.go b/cmd/flux/create_image_update_test.go new file mode 100644 index 00000000..f901c15a --- /dev/null +++ b/cmd/flux/create_image_update_test.go @@ -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) + }) + } +} diff --git a/cmd/flux/testdata/bootstrap/ed25519-encrypted.private b/cmd/flux/testdata/bootstrap/ed25519-encrypted.private new file mode 100644 index 00000000..6a540db3 --- /dev/null +++ b/cmd/flux/testdata/bootstrap/ed25519-encrypted.private @@ -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----- diff --git a/cmd/flux/testdata/bootstrap/ed25519.private b/cmd/flux/testdata/bootstrap/ed25519.private new file mode 100644 index 00000000..2cc37a05 --- /dev/null +++ b/cmd/flux/testdata/bootstrap/ed25519.private @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCAAAAIjjSDmx40g5 +sQAAAAtzc2gtZWQyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCA +AAAEAGpzSFuLkCNDD49+tysxSFFwdOsRnDj67vDT9bfwoSDhYOV20IV1IxchXW8vBe9HCT +h4SZKgMKnE2Rxs2mHd0IAAAABHRlc3QB +-----END OPENSSH PRIVATE KEY----- diff --git a/cmd/flux/testdata/bootstrap/gpg-encrypted.pgp b/cmd/flux/testdata/bootstrap/gpg-encrypted.pgp new file mode 100644 index 00000000..aff2f539 Binary files /dev/null and b/cmd/flux/testdata/bootstrap/gpg-encrypted.pgp differ diff --git a/cmd/flux/testdata/bootstrap/gpg.pgp b/cmd/flux/testdata/bootstrap/gpg.pgp new file mode 100644 index 00000000..e435898c Binary files /dev/null and b/cmd/flux/testdata/bootstrap/gpg.pgp differ diff --git a/cmd/flux/testdata/bootstrap/malformed.private b/cmd/flux/testdata/bootstrap/malformed.private new file mode 100644 index 00000000..850bf46f --- /dev/null +++ b/cmd/flux/testdata/bootstrap/malformed.private @@ -0,0 +1 @@ +not a real ssh key diff --git a/cmd/flux/testdata/create_image_update/no-signing.yaml b/cmd/flux/testdata/create_image_update/no-signing.yaml new file mode 100644 index 00000000..91bd27ed --- /dev/null +++ b/cmd/flux/testdata/create_image_update/no-signing.yaml @@ -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 diff --git a/cmd/flux/testdata/create_image_update/signing-default-gpg.yaml b/cmd/flux/testdata/create_image_update/signing-default-gpg.yaml new file mode 100644 index 00000000..4d958b76 --- /dev/null +++ b/cmd/flux/testdata/create_image_update/signing-default-gpg.yaml @@ -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 diff --git a/cmd/flux/testdata/create_image_update/signing-ssh.yaml b/cmd/flux/testdata/create_image_update/signing-ssh.yaml new file mode 100644 index 00000000..84e81c8c --- /dev/null +++ b/cmd/flux/testdata/create_image_update/signing-ssh.yaml @@ -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 diff --git a/cmd/flux/testdata/export/image-update.yaml b/cmd/flux/testdata/export/image-update.yaml index 0d534482..0a3462c3 100644 --- a/cmd/flux/testdata/export/image-update.yaml +++ b/cmd/flux/testdata/export/image-update.yaml @@ -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 diff --git a/cmd/flux/testdata/export/objects.yaml b/cmd/flux/testdata/export/objects.yaml index 057470e5..7a905634 100644 --- a/cmd/flux/testdata/export/objects.yaml +++ b/cmd/flux/testdata/export/objects.yaml @@ -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 diff --git a/go.mod b/go.mod index 678b4d7f..0f8b95b0 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 408c07cc..cd778386 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/bootstrap/bootstrap_plain_git.go b/pkg/bootstrap/bootstrap_plain_git.go index f39b08b7..af1f8af3 100644 --- a/pkg/bootstrap/bootstrap_plain_git.go +++ b/pkg/bootstrap/bootstrap_plain_git.go @@ -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)) diff --git a/pkg/bootstrap/bootstrap_test.go b/pkg/bootstrap/bootstrap_test.go index 8f8e3db8..0bd9deab 100644 --- a/pkg/bootstrap/bootstrap_test.go +++ b/pkg/bootstrap/bootstrap_test.go @@ -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()) +} diff --git a/pkg/bootstrap/options.go b/pkg/bootstrap/options.go index 27830f4a..c3cf9226 100644 --- a/pkg/bootstrap/options.go +++ b/pkg/bootstrap/options.go @@ -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