Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion git/gogit/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ func (g *Client) Commit(info git.Commit, commitOpts ...repository.CommitOption)
}

if options.Signer != nil {
opts.SignKey = options.Signer
opts.Signer = options.Signer
}

commit, err := wt.Commit(info.Message, opts)
Expand Down
195 changes: 195 additions & 0 deletions git/gogit/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,32 @@ limitations under the License.
package gogit

import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"encoding/pem"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
extgogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
. "github.com/onsi/gomega"
gossh "golang.org/x/crypto/ssh"

"github.com/fluxcd/pkg/git"
"github.com/fluxcd/pkg/git/repository"
"github.com/fluxcd/pkg/git/signature"
"github.com/fluxcd/pkg/gittestserver"
)

Expand Down Expand Up @@ -173,6 +185,189 @@ func TestCommit(t *testing.T) {
g.Expect(cc).ToNot(Equal(hash))
}

func TestCommit_WithSigner(t *testing.T) {
// signerVerifier returns the [signature.Signer] to install on the
// commit and a verifyFn that checks the resulting commit's gpgsig
// header. verifyFn is nil for the unsigned-commit case.
type verifyFn func(t *testing.T, commit *object.Commit)
type signerSetup func(t *testing.T) (signature.Signer, verifyFn)

openpgpSetup := func(t *testing.T) (signature.Signer, verifyFn) {
t.Helper()
g := NewWithT(t)
entity, err := openpgp.NewEntity("Test", "openpgp test", "test@example.com", nil)
g.Expect(err).ToNot(HaveOccurred())
signer, err := signature.NewOpenPGPSigner(entity)
g.Expect(err).ToNot(HaveOccurred())
return signer, func(t *testing.T, commit *object.Commit) {
g := NewWithT(t)
g.Expect(commit.PGPSignature).To(HavePrefix("-----BEGIN PGP SIGNATURE-----"))
payload := commitPayload(t, commit)
var pubBuf bytes.Buffer
g.Expect(entity.Serialize(&pubBuf)).To(Succeed())
armored, err := armorPGPPublicKey(&pubBuf)
g.Expect(err).ToNot(HaveOccurred())
_, err = signature.VerifyPGPSignature(commit.PGPSignature, payload, armored)
g.Expect(err).ToNot(HaveOccurred())
}
}

sshSetup := func(keyFn func(t *testing.T) (crypto.PublicKey, []byte)) signerSetup {
return func(t *testing.T) (signature.Signer, verifyFn) {
t.Helper()
g := NewWithT(t)
pub, pemBytes := keyFn(t)
signer, err := signature.NewSSHSigner(pemBytes, nil)
g.Expect(err).ToNot(HaveOccurred())
return signer, func(t *testing.T, commit *object.Commit) {
g := NewWithT(t)
g.Expect(commit.PGPSignature).To(HavePrefix("-----BEGIN SSH SIGNATURE-----"))
payload := commitPayload(t, commit)
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())
}
}
}

ed25519Key := func(t *testing.T) (crypto.PublicKey, []byte) {
t.Helper()
g := NewWithT(t)
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())
return pub, pem.EncodeToMemory(pemBlock)
}

ecdsaP256Key := func(t *testing.T) (crypto.PublicKey, []byte) {
t.Helper()
g := NewWithT(t)
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
g.Expect(err).ToNot(HaveOccurred())
pemBlock, err := gossh.MarshalPrivateKey(priv, "test ecdsa p256 key")
g.Expect(err).ToNot(HaveOccurred())
return &priv.PublicKey, pem.EncodeToMemory(pemBlock)
}

tests := []struct {
name string
setup signerSetup // nil = pass nil to WithSigner
file string
message string
unsigned bool // true when WithSigner(nil) should yield an empty gpgsig
}{
{
name: "openpgp",
setup: openpgpSetup,
file: "signed-openpgp",
message: "signed by openpgp",
},
{
name: "ssh ed25519",
setup: sshSetup(ed25519Key),
file: "signed-ssh-ed25519",
message: "signed by ssh ed25519",
},
{
name: "ssh ecdsa-sha2-nistp256",
setup: sshSetup(ecdsaP256Key),
file: "signed-ssh-ecdsa-p256",
message: "signed by ssh ecdsa p256",
},
{
name: "nil signer yields unsigned commit",
file: "unsigned",
message: "unsigned despite WithSigner(nil)",
unsigned: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)

server, err := gittestserver.NewTempGitServer()
g.Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(server.Root())

g.Expect(server.InitRepo("../testdata/git/repo", git.DefaultBranch, "test.git")).To(Succeed())

tmp := t.TempDir()
repo, err := extgogit.PlainClone(tmp, false, &extgogit.CloneOptions{
URL: filepath.Join(server.Root(), "test.git"),
})
g.Expect(err).ToNot(HaveOccurred())

ggc, err := NewClient(tmp, nil)
g.Expect(err).ToNot(HaveOccurred())
ggc.repository = repo

var (
signer signature.Signer
verify verifyFn
)
if tt.setup != nil {
signer, verify = tt.setup(t)
}

hash, err := ggc.Commit(
git.Commit{
Author: git.Signature{Name: "Test User", Email: "test@example.com"},
Message: tt.message,
},
repository.WithFiles(map[string]io.Reader{
tt.file: strings.NewReader("payload for " + tt.name),
}),
repository.WithSigner(signer),
)
g.Expect(err).ToNot(HaveOccurred())

commit, err := repo.CommitObject(plumbing.NewHash(hash))
g.Expect(err).ToNot(HaveOccurred())

if tt.unsigned {
g.Expect(commit.PGPSignature).To(BeEmpty())
return
}
g.Expect(commit.PGPSignature).ToNot(BeEmpty())
verify(t, commit)
})
}
}

// commitPayload returns the canonical commit-without-signature payload that
// signature.Verify{PGP,SSH}Signature expect for a verification round-trip.
func commitPayload(t *testing.T, commit *object.Commit) []byte {
t.Helper()
g := NewWithT(t)
encoded := &plumbing.MemoryObject{}
g.Expect(commit.EncodeWithoutSignature(encoded)).To(Succeed())
r, err := encoded.Reader()
g.Expect(err).ToNot(HaveOccurred())
b, err := io.ReadAll(r)
g.Expect(err).ToNot(HaveOccurred())
return b
}

// armorPGPPublicKey ASCII-armors r as a "PGP PUBLIC KEY BLOCK".
func armorPGPPublicKey(r io.Reader) (string, error) {
var sb strings.Builder
w, err := armor.Encode(&sb, "PGP PUBLIC KEY BLOCK", nil)
if err != nil {
return "", err
}
if _, err := io.Copy(w, r); err != nil {
return "", err
}
if err := w.Close(); err != nil {
return "", err
}
return sb.String(), nil
}

func TestPush(t *testing.T) {
g := NewWithT(t)

Expand Down
16 changes: 10 additions & 6 deletions git/repository/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package repository
import (
"io"

"github.com/ProtonMail/go-crypto/openpgp"
"github.com/fluxcd/pkg/git/signature"
)

const (
Expand Down Expand Up @@ -98,8 +98,10 @@ type CheckoutStrategy struct {

// CommitOptions provides options to configure a Git commit operation.
type CommitOptions struct {
// Signer can be used to sign a commit using OpenPGP.
Signer *openpgp.Entity
// Signer signs the resulting commit. May be nil for unsigned commits.
// Use [signature.NewOpenPGPSigner] or [signature.NewSSHSigner] to
// construct one.
Signer signature.Signer
// Files contains file names mapped to the file's content.
// Its used to write files which are then included in the commit.
Files map[string]io.Reader
Expand All @@ -109,10 +111,12 @@ type CommitOptions struct {
type CommitOption func(*CommitOptions)

// WithSigner allows for the commit to be signed using the provided
// OpenPGP signer.
func WithSigner(signer *openpgp.Entity) CommitOption {
// [signature.Signer]. Passing a nil interface is a no-op; the commit will
// be unsigned. See [signature.NewOpenPGPSigner] and
// [signature.NewSSHSigner] for the supported constructors.
func WithSigner(s signature.Signer) CommitOption {
return func(co *CommitOptions) {
co.Signer = signer
co.Signer = s
}
}

Expand Down
5 changes: 5 additions & 0 deletions git/signature/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ var (
// at least one key ring or authorized_keys input and none of them could
// verify the signature against the payload.
ErrNoMatchingKey = errors.New("no matching key")

// ErrSSHPassphraseRequired is returned by NewSSHSigner when the
// provided private key is encrypted but no passphrase was supplied.
// Callers may branch on it via errors.Is.
ErrSSHPassphraseRequired = errors.New("SSH signing key is encrypted; passphrase required")
)
37 changes: 37 additions & 0 deletions git/signature/gpg_signature.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ package signature

import (
"bytes"
"errors"
"fmt"
"io"
"strings"

"github.com/ProtonMail/go-crypto/openpgp"
Expand Down Expand Up @@ -88,3 +90,38 @@ func VerifyPGPSignature(signature string, payload []byte, keyRings ...string) (s

return "", fmt.Errorf("unable to verify payload with any of the given key rings: %w", ErrNoMatchingKey)
}

// OpenPGPSigner adapts an [openpgp.Entity] to the [Signer] interface so it
// can be used as a generic Git commit signer. Callers may type-assert a
// [Signer] returned by [NewOpenPGPSigner] back to *OpenPGPSigner to inspect
// or distinguish it from other Signer implementations.
type OpenPGPSigner struct {
entity *openpgp.Entity
}

// Sign produces an ASCII-armored detached OpenPGP signature over the
// message read from r. The output matches what go-git's internal gpgSigner
// produces, so it is interchangeable with the previous typed-entity
// signing path.
func (s *OpenPGPSigner) Sign(r io.Reader) ([]byte, error) {
var buf bytes.Buffer
if err := openpgp.ArmoredDetachSign(&buf, s.entity, r, nil); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

// NewOpenPGPSigner returns a [Signer] that signs commits with the given
// OpenPGP entity. The entity's private key must be present and decrypted;
// callers that load keys from passphrase-protected key rings are
// responsible for decryption before constructing the signer.
//
// The constructor returns an error only when the entity is nil; today no
// other validation is performed, but the (Signer, error) shape leaves room
// to add validation later without an API break.
func NewOpenPGPSigner(e *openpgp.Entity) (Signer, error) {
if e == nil {
return nil, errors.New("nil openpgp entity")
}
return &OpenPGPSigner{entity: e}, nil
}
66 changes: 66 additions & 0 deletions git/signature/gpg_signer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
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 signature_test

import (
"bytes"
"strings"
"testing"

"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
. "github.com/onsi/gomega"

"github.com/fluxcd/pkg/git/signature"
)

func TestNewOpenPGPSigner(t *testing.T) {
t.Run("nil entity returns error", func(t *testing.T) {
g := NewWithT(t)

_, err := signature.NewOpenPGPSigner(nil)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("nil openpgp entity"))
})

t.Run("sign produces detached armored signature verifiable by VerifyPGPSignature", func(t *testing.T) {
g := NewWithT(t)

entity, err := openpgp.NewEntity("Test", "test signing key", "test@example.com", nil)
g.Expect(err).ToNot(HaveOccurred())

signer, err := signature.NewOpenPGPSigner(entity)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(signer).ToNot(BeNil())

payload := []byte("hello world")
sig, err := signer.Sign(bytes.NewReader(payload))
g.Expect(err).ToNot(HaveOccurred())
g.Expect(sig).ToNot(BeEmpty())
g.Expect(string(sig)).To(HavePrefix("-----BEGIN PGP SIGNATURE-----"))

// Round-trip: armor the public key and verify the produced signature.
var sb strings.Builder
armorWriter, err := armor.Encode(&sb, "PGP PUBLIC KEY BLOCK", nil)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(entity.Serialize(armorWriter)).To(Succeed())
g.Expect(armorWriter.Close()).To(Succeed())

_, err = signature.VerifyPGPSignature(string(sig), payload, sb.String())
g.Expect(err).ToNot(HaveOccurred())
})
}
Loading
Loading