diff --git a/cmd/flux/bootstrap.go b/cmd/flux/bootstrap.go index b2359958..2abfc9ff 100644 --- a/cmd/flux/bootstrap.go +++ b/cmd/flux/bootstrap.go @@ -68,6 +68,10 @@ type bootstrapFlags struct { authorName string authorEmail string + gpgKeyPath string + gpgPassphrase string + gpgKeyID string + commitMessageAppendix string } @@ -119,6 +123,10 @@ func init() { bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.authorName, "author-name", "Flux", "author name for Git commits") bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.authorEmail, "author-email", "", "author email for Git commits") + bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.gpgKeyPath, "gpg-key", "", "path to secret gpg key for signing commits") + bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.gpgPassphrase, "gpg-passphrase", "", "passphrase for decrypting secret gpg key") + bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.gpgKeyID, "gpg-key-id", "", "key id for selecting a particular key") + bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.commitMessageAppendix, "commit-message-appendix", "", "string to add to the commit messages, e.g. '[ci skip]'") bootstrapCmd.PersistentFlags().Var(&bootstrapArgs.arch, "arch", bootstrapArgs.arch.Description()) diff --git a/cmd/flux/bootstrap_git.go b/cmd/flux/bootstrap_git.go index f01d925c..894742c4 100644 --- a/cmd/flux/bootstrap_git.go +++ b/cmd/flux/bootstrap_git.go @@ -224,6 +224,7 @@ func bootstrapGitCmdRun(cmd *cobra.Command, args []string) error { bootstrap.WithPostGenerateSecretFunc(promptPublicKey), bootstrap.WithLogger(logger), bootstrap.WithCABundle(caBundle), + bootstrap.WithGitCommitSigning(bootstrapArgs.gpgKeyPath, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID), } // Setup bootstrapper with constructed configs diff --git a/go.mod b/go.mod index f03e0b04..2c03bc94 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.16 require ( github.com/Masterminds/semver/v3 v3.1.0 + github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 github.com/cyphar/filepath-securejoin v0.2.2 github.com/fluxcd/go-git-providers v0.1.1 github.com/fluxcd/helm-controller/api v0.11.2 diff --git a/internal/bootstrap/bootstrap_plain_git.go b/internal/bootstrap/bootstrap_plain_git.go index 6475c40f..898f976d 100644 --- a/internal/bootstrap/bootstrap_plain_git.go +++ b/internal/bootstrap/bootstrap_plain_git.go @@ -53,6 +53,10 @@ type PlainGitBootstrapper struct { author git.Author commitMessageAppendix string + gpgKeyPath string + gpgPassphrase string + gpgKeyID string + kubeconfig string kubecontext string @@ -142,6 +146,7 @@ func (b *PlainGitBootstrapper) ReconcileComponents(ctx context.Context, manifest } // Git commit generated + gpgOpts := git.WithGpgSigningOption(b.gpgKeyPath, b.gpgPassphrase, b.gpgKeyID) commitMsg := fmt.Sprintf("Add Flux %s component manifests", options.Version) if b.commitMessageAppendix != "" { commitMsg = commitMsg + "\n\n" + b.commitMessageAppendix @@ -149,7 +154,7 @@ func (b *PlainGitBootstrapper) ReconcileComponents(ctx context.Context, manifest commit, err := b.git.Commit(git.Commit{ Author: b.author, Message: commitMsg, - }) + }, gpgOpts) if err != nil && err != git.ErrNoStagedFiles { return fmt.Errorf("failed to commit sync manifests: %w", err) } @@ -306,6 +311,7 @@ func (b *PlainGitBootstrapper) ReconcileSyncConfig(ctx context.Context, options b.logger.Successf("generated sync manifests") // Git commit generated + gpgOpts := git.WithGpgSigningOption(b.gpgKeyPath, b.gpgPassphrase, b.gpgKeyID) commitMsg := fmt.Sprintf("Add Flux sync manifests") if b.commitMessageAppendix != "" { commitMsg = commitMsg + "\n\n" + b.commitMessageAppendix @@ -313,7 +319,8 @@ func (b *PlainGitBootstrapper) ReconcileSyncConfig(ctx context.Context, options commit, err := b.git.Commit(git.Commit{ Author: b.author, Message: commitMsg, - }) + }, gpgOpts) + if err != nil && err != git.ErrNoStagedFiles { return fmt.Errorf("failed to commit sync manifests: %w", err) } diff --git a/internal/bootstrap/git/commit_options.go b/internal/bootstrap/git/commit_options.go new file mode 100644 index 00000000..e1a9712c --- /dev/null +++ b/internal/bootstrap/git/commit_options.go @@ -0,0 +1,37 @@ +package git + +// Option is a some configuration that modifies options for a commit. +type Option interface { + // ApplyToCommit applies this configuration to a given commit option. + ApplyToCommit(*CommitOptions) +} + +// CommitOptions contains options for making a commit. +type CommitOptions struct { + *GPGSigningInfo +} + +// GPGSigningInfo contains information for signing a commit. +type GPGSigningInfo struct { + PrivateKeyPath string + Passphrase string + KeyID string +} + +type GpgSigningOption struct { + *GPGSigningInfo +} + +func (w GpgSigningOption) ApplyToCommit(in *CommitOptions) { + in.GPGSigningInfo = w.GPGSigningInfo +} + +func WithGpgSigningOption(path, passphrase, keyID string) Option { + return GpgSigningOption{ + GPGSigningInfo: &GPGSigningInfo{ + PrivateKeyPath: path, + Passphrase: passphrase, + KeyID: keyID, + }, + } +} diff --git a/internal/bootstrap/git/git.go b/internal/bootstrap/git/git.go index 103ea49b..1a0f6d86 100644 --- a/internal/bootstrap/git/git.go +++ b/internal/bootstrap/git/git.go @@ -44,7 +44,7 @@ type Git interface { Init(url, branch string) (bool, error) Clone(ctx context.Context, url, branch string, caBundle []byte) (bool, error) Write(path string, reader io.Reader) error - Commit(message Commit) (string, error) + Commit(message Commit, options ...Option) (string, error) Push(ctx context.Context, caBundle []byte) error Status() (bool, error) Head() (string, error) diff --git a/internal/bootstrap/git/gogit/gogit.go b/internal/bootstrap/git/gogit/gogit.go index 07248791..333661d5 100644 --- a/internal/bootstrap/git/gogit/gogit.go +++ b/internal/bootstrap/git/gogit/gogit.go @@ -25,6 +25,7 @@ import ( "strings" "time" + "github.com/ProtonMail/go-crypto/openpgp" gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" @@ -40,6 +41,12 @@ type GoGit struct { repository *gogit.Repository } +type CommitOptions struct { + GpgKeyPath string + GpgKeyPassphrase string + KeyID string +} + func New(path string, auth transport.AuthMethod) *GoGit { return &GoGit{ path: path, @@ -127,7 +134,7 @@ func (g *GoGit) Write(path string, reader io.Reader) error { return err } -func (g *GoGit) Commit(message git.Commit) (string, error) { +func (g *GoGit) Commit(message git.Commit, opts ...git.Option) (string, error) { if g.repository == nil { return "", git.ErrNoGitRepository } @@ -142,6 +149,12 @@ func (g *GoGit) Commit(message git.Commit) (string, error) { return "", err } + // apply the options + options := &git.CommitOptions{} + for _, opt := range opts { + opt.ApplyToCommit(options) + } + // go-git has [a bug](https://github.com/go-git/go-git/issues/253) // whereby it thinks broken symlinks to absolute paths are // modified. There's no circumstance in which we want to commit a @@ -173,13 +186,24 @@ func (g *GoGit) Commit(message git.Commit) (string, error) { return head.Hash().String(), git.ErrNoStagedFiles } - commit, err := wt.Commit(message.Message, &gogit.CommitOptions{ + commitOpts := &gogit.CommitOptions{ Author: &object.Signature{ Name: message.Name, Email: message.Email, When: time.Now(), }, - }) + } + + if options.GPGSigningInfo != nil { + entity, err := getOpenPgpEntity(*options.GPGSigningInfo) + if err != nil { + return "", err + } + + commitOpts.SignKey = entity + } + + commit, err := wt.Commit(message.Message, commitOpts) if err != nil { return "", err } @@ -232,3 +256,41 @@ func (g *GoGit) Path() string { func isRemoteBranchNotFoundErr(err error, ref string) bool { return strings.Contains(err.Error(), fmt.Sprintf("couldn't find remote ref %q", ref)) } + +func getOpenPgpEntity(info git.GPGSigningInfo) (*openpgp.Entity, error) { + r, err := os.Open(info.PrivateKeyPath) + if err != nil { + return nil, err + } + + entityList, err := openpgp.ReadKeyRing(r) + if err != nil { + return nil, err + } + + if len(entityList) == 0 { + return nil, fmt.Errorf("no entity formed") + } + + var entity *openpgp.Entity + if info.KeyID != "" { + for _, ent := range entityList { + if ent.PrimaryKey.KeyIdString() == info.KeyID { + entity = ent + } + } + + if entity == nil { + return nil, fmt.Errorf("no key matching the key id was found") + } + } else { + entity = entityList[0] + } + + err = entity.PrivateKey.Decrypt([]byte(info.Passphrase)) + if err != nil { + return nil, err + } + + return entity, nil +} diff --git a/internal/bootstrap/git/gogit/gogit_test.go b/internal/bootstrap/git/gogit/gogit_test.go new file mode 100644 index 00000000..1c8bb311 --- /dev/null +++ b/internal/bootstrap/git/gogit/gogit_test.go @@ -0,0 +1,66 @@ +// +build unit + +package gogit + +import ( + "testing" + + "github.com/fluxcd/flux2/internal/bootstrap/git" +) + +func TestGetOpenPgpEntity(t *testing.T) { + tests := []struct { + name string + keyPath string + passphrase string + id string + expectErr bool + }{ + { + name: "no default key id given", + keyPath: "testdata/private.key", + passphrase: "flux", + id: "", + expectErr: false, + }, + { + name: "key id given", + keyPath: "testdata/private.key", + passphrase: "flux", + id: "0619327DBD777415", + expectErr: false, + }, + { + name: "wrong key id", + keyPath: "testdata/private.key", + passphrase: "flux", + id: "0619327DBD777416", + expectErr: true, + }, + { + name: "wrong password", + keyPath: "testdata/private.key", + passphrase: "fluxe", + id: "0619327DBD777415", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gpgInfo := git.GPGSigningInfo{ + PrivateKeyPath: tt.keyPath, + Passphrase: tt.passphrase, + KeyID: tt.id, + } + + _, err := getOpenPgpEntity(gpgInfo) + if err != nil && !tt.expectErr { + t.Errorf("unexpected error: %s", err) + } + if err == nil && tt.expectErr { + t.Errorf("expected error when %s", tt.name) + } + }) + } +} diff --git a/internal/bootstrap/git/gogit/testdata/private.key b/internal/bootstrap/git/gogit/testdata/private.key new file mode 100644 index 00000000..9e56432c Binary files /dev/null and b/internal/bootstrap/git/gogit/testdata/private.key differ diff --git a/internal/bootstrap/options.go b/internal/bootstrap/options.go index 88972c1e..750f66e9 100644 --- a/internal/bootstrap/options.go +++ b/internal/bootstrap/options.go @@ -112,3 +112,27 @@ func (o loggerOption) applyGit(b *PlainGitBootstrapper) { func (o loggerOption) applyGitProvider(b *GitProviderBootstrapper) { b.logger = o.logger } + +func WithGitCommitSigning(path, passphrase, keyID string) Option { + return gitCommitSigningOption{ + gpgKeyPath: path, + gpgPassphrase: passphrase, + gpgKeyID: keyID, + } +} + +type gitCommitSigningOption struct { + gpgKeyPath string + gpgPassphrase string + gpgKeyID string +} + +func (o gitCommitSigningOption) applyGit(b *PlainGitBootstrapper) { + b.gpgPassphrase = o.gpgPassphrase + b.gpgKeyPath = o.gpgKeyPath + b.gpgKeyID = o.gpgKeyID +} + +func (o gitCommitSigningOption) applyGitProvider(b *GitProviderBootstrapper) { + o.applyGit(b.PlainGitBootstrapper) +}