diff --git a/.entire/settings.json b/.entire/settings.json new file mode 100644 index 0000000..61cc6b3 --- /dev/null +++ b/.entire/settings.json @@ -0,0 +1,10 @@ +{ + "enabled": true, + "strategy": "manual-commit", + "strategy_options": { + "checkpoint_remote": { + "provider": "github", + "repo": "go-git/entire" + } + } +} diff --git a/.golangci.yaml b/.golangci.yaml index e04f161..92f6c9c 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -5,6 +5,8 @@ linters: - depguard - wsl settings: + gomoddirectives: + replace-local: true govet: enable-all: true revive: diff --git a/plugin/objectsigner/auto/auto.go b/plugin/objectsigner/auto/auto.go new file mode 100644 index 0000000..810e7b9 --- /dev/null +++ b/plugin/objectsigner/auto/auto.go @@ -0,0 +1,358 @@ +// Package auto constructs a [Signer] from the git signing configuration +// fields gpg.format and user.signingKey. It supports OpenPGP and SSH signing. +// +// For SSH signing the resolution logic closely mirrors the git CLI: file +// paths, key:: literals, and .pub paths matched via an SSH agent are all +// supported. For OpenPGP signing the behaviour differs from git: the git +// CLI expects a key ID or fingerprint and shells out to gpg(1), whereas +// this package expects a file path to an armored private-key ring and +// signs natively in Go. +// +// The underlying signing process takes place via Go native libraries, as +// opposed to shelling out to binaries. +// +// Passphrase-protected keys are not supported directly. Expose such keys +// through an SSH agent instead, or use the lower-level gpg and ssh sibling +// packages when full control over key loading is required. +package auto + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + billy "github.com/go-git/go-billy/v6" + "github.com/go-git/go-billy/v6/osfs" + "github.com/go-git/go-billy/v6/util" + gpgpkg "github.com/go-git/x/plugin/objectsigner/gpg" + sshpkg "github.com/go-git/x/plugin/objectsigner/ssh" + gossh "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +var ( + // ErrPassphraseUnsupported is returned when the SSH private key on disk + // is protected by a passphrase. + ErrPassphraseUnsupported = errors.New("passphrase-protected SSH keys are not supported") + // ErrEncryptedKeyUnsupported is returned when every private key in an + // OpenPGP key ring is encrypted and no unencrypted alternative exists. + ErrEncryptedKeyUnsupported = errors.New("encrypted GPG private keys are not supported") + // ErrNoPrivateKey is returned when no usable private key can be found: + // the key ring contains no private-key material, or no SigningKey or + // agent was provided. + ErrNoPrivateKey = errors.New("no private key found") + // ErrNoPrivateKeyInAgent is returned when the SSH agent holds no keys. + ErrNoPrivateKeyInAgent = errors.New("no private key found in SSH agent") + // ErrUnsupportedFormat is returned for unrecognized signing formats. + ErrUnsupportedFormat = errors.New("unsupported signing format") +) + +// Format represents the signing format as configured by gpg.format. +type Format string + +const ( + // FormatOpenPGP selects OpenPGP (GPG) signing. This is the default + // when no format is configured. + FormatOpenPGP Format = "openpgp" + // FormatSSH selects SSH signing. + FormatSSH Format = "ssh" +) + +// Config holds the git signing configuration values needed to construct +// the appropriate signer. +type Config struct { + // FS is the filesystem used to read key files. When nil, it defaults + // to the OS root filesystem. + FS billy.Basic + + // SSHAgent is an optional SSH agent for SSH signing. It is consulted when + // SigningKey is a key:: literal, a .pub file path, or empty. For any other + // path, the private key is read from FS directly and the agent is ignored. + SSHAgent agent.Agent + + // SigningKey is the value of user.signingKey. + // + // For SSH format: + // - Path to a private key file (e.g. ~/.ssh/id_ed25519). + // - Path to a public key file ending in .pub (e.g. ~/.ssh/id_ed25519.pub) + // when SSHAgent is set; the agent is queried for the matching signer. + // - A key:: literal (e.g. "key::ssh-ed25519 AAAA...") when SSHAgent is + // set; the agent is queried for the matching signer. + // - Empty string when SSHAgent is set; the first agent signer is used. + // + // For OpenPGP format: path to an armored private-key file. + // + // A leading ~/ is expanded to the current user's home directory. + // ~username/ prefixes are not expanded. + SigningKey string + + // Format is the value of gpg.format. + // Supported: FormatSSH, FormatOpenPGP. Defaults to FormatOpenPGP when empty. + Format Format +} + +// Signer signs a message read from an io.Reader and returns the raw signature +// bytes. +type Signer interface { + Sign(message io.Reader) ([]byte, error) +} + +// FromConfig returns a [Signer] configured according to the provided Config. +// It reads the signing key from disk and selects the appropriate signer +// implementation based on the format. +// +//nolint:ireturn // Signer is the package's own exported interface; callers always use it as Signer. +func FromConfig(cfg Config) (Signer, error) { + if cfg.FS == nil { + cfg.FS = osfs.Default + } + + signingKey, err := expandHome(cfg.SigningKey) + if err != nil { + return nil, err + } + + cfg.SigningKey = signingKey + + switch cfg.Format { + case FormatSSH: + return newSSHSigner(cfg.FS, cfg.SigningKey, cfg.SSHAgent) + case "", FormatOpenPGP: + return newGPGSigner(cfg.FS, cfg.SigningKey) + default: + return nil, fmt.Errorf("%w: %q", ErrUnsupportedFormat, cfg.Format) + } +} + +// expandHome replaces a leading ~/ with the current user's home directory. +// Paths that do not start with ~/ are returned unchanged. ~username/ prefixes +// are not expanded. +func expandHome(path string) (string, error) { + if !strings.HasPrefix(path, "~/") { + return path, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("expanding ~/ in signing key path: %w", err) + } + + return filepath.Join(home, path[2:]), nil +} + +// newSSHSigner resolves keyInfoOrPath and returns an SSH signer. +// Resolution order: +// +// 1. key:: prefix – keyInfoOrPath holds a literal public key; sshAgent must +// be non-nil and is queried for the matching signer. +// 2. .pub suffix + sshAgent – the public key is read from fsys and the +// matching agent signer is returned. +// 3. Any other non-empty path – the file is read from fsys as a private key; +// sshAgent is not consulted. +// 4. Empty keyInfoOrPath + sshAgent – the first available agent signer is used. +// 5. Empty keyInfoOrPath, no sshAgent – error. +// +//nolint:ireturn // Signer is the package's own exported interface. +func newSSHSigner(fsys billy.Basic, keyInfoOrPath string, sshAgent agent.Agent) (Signer, error) { + if len(keyInfoOrPath) > 0 { + return sshSignerFromKey(fsys, keyInfoOrPath, sshAgent) + } + + // No SigningKey: use the first available key from the agent. + if sshAgent != nil { + return sshFromAgent(sshAgent, nil) + } + + return nil, fmt.Errorf("%w: %s", ErrNoPrivateKey, "missing signingKey or active ssh-agent") +} + +// sshSignerFromKey resolves a non-empty keyInfoOrPath to an SSH signer. +// +//nolint:ireturn // Signer is the package's own exported interface. +func sshSignerFromKey(fsys billy.Basic, keyInfoOrPath string, sshAgent agent.Agent) (Signer, error) { + if key, found := strings.CutPrefix(keyInfoOrPath, "key::"); found { + return sshSignerFromLiteral(sshAgent, key) + } + + if strings.HasSuffix(keyInfoOrPath, ".pub") && sshAgent != nil { + return sshSignerFromPubFile(fsys, keyInfoOrPath, sshAgent) + } + + // Not a key:: literal or agent-matched .pub path: read as a private key. + return sshSignerFromPrivateKeyFile(fsys, keyInfoOrPath) +} + +// sshSignerFromLiteral resolves a key:: literal against the SSH agent. +// +//nolint:ireturn // Signer is the package's own exported interface. +func sshSignerFromLiteral(sshAgent agent.Agent, key string) (Signer, error) { + if sshAgent == nil { + return nil, fmt.Errorf("%w: signingKey must be a file path when ssh-agent is not being used", ErrNoPrivateKey) + } + + pubKey, err := parseAuthorizedKey([]byte(sshTrimIdentifier(key))) + if err != nil { + return nil, fmt.Errorf("parsing SSH public key from signingKey literal: %w", err) + } + + return sshFromAgent(sshAgent, pubKey.Marshal()) +} + +// sshSignerFromPubFile reads a .pub file from fsys and matches it against the SSH agent. +// +//nolint:ireturn // Signer is the package's own exported interface. +func sshSignerFromPubFile(fsys billy.Basic, keyInfoOrPath string, sshAgent agent.Agent) (Signer, error) { + pubData, err := util.ReadFile(fsys, keyInfoOrPath) + if err != nil { + return nil, fmt.Errorf("reading SSH public key: %w", err) + } + + pubKey, err := parseAuthorizedKey(pubData) + if err != nil { + return nil, fmt.Errorf("parsing SSH public key: %w", err) + } + + return sshFromAgent(sshAgent, pubKey.Marshal()) +} + +// sshSignerFromPrivateKeyFile reads a private key from fsys and returns a signer. +// +//nolint:ireturn // Signer is the package's own exported interface. +func sshSignerFromPrivateKeyFile(fsys billy.Basic, keyInfoOrPath string) (Signer, error) { + data, err := util.ReadFile(fsys, keyInfoOrPath) + if err != nil { + return nil, fmt.Errorf("reading SSH private key: %w", err) + } + + signer, err := gossh.ParsePrivateKey(data) + if err != nil { + var passErr *gossh.PassphraseMissingError + if errors.As(err, &passErr) { + return nil, ErrPassphraseUnsupported + } + + return nil, fmt.Errorf("parsing SSH private key: %w", err) + } + + result, fErr := sshpkg.FromKey(signer, sshpkg.WithHashAlgorithm(sshpkg.SHA512)) + if fErr != nil { + return nil, fmt.Errorf("creating SSH signer: %w", fErr) + } + + return result, nil +} + +// parseAuthorizedKey parses a single entry from an authorized_keys file, +// returning only the public key. It wraps gossh.ParseAuthorizedKey, +// discarding the unused return values. +// +//nolint:ireturn // gossh.PublicKey is an external interface; no concrete type is accessible. +func parseAuthorizedKey(data []byte) (gossh.PublicKey, error) { + //nolint:dogsled // API returns 5 values; only key+err are needed. + pubKey, _, _, _, err := gossh.ParseAuthorizedKey(data) + if err != nil { + return nil, fmt.Errorf("parsing authorized key: %w", err) + } + + return pubKey, nil +} + +// sshAuthorizedKeyFields is the minimum number of space-separated fields in +// an authorized-key string (key-type + base64-encoded key). +const sshAuthorizedKeyFields = 2 + +// sshTrimIdentifier strips any trailing comment from an authorized-key string, +// returning only the key type and base64-encoded key (e.g. +// "ssh-ed25519 AAAA... comment" → "ssh-ed25519 AAAA..."). +func sshTrimIdentifier(key string) string { + fields := strings.Fields(key) + if len(fields) < sshAuthorizedKeyFields { + return key + } + + return strings.Join(fields[:sshAuthorizedKeyFields], " ") +} + +// sshFromAgent returns a Signer backed by the agent signer whose public-key +// wire encoding matches pubKeyBytes. When pubKeyBytes is nil or empty, the +// first available signer is returned without filtering. +// +//nolint:ireturn // Signer is the package's own exported interface. +func sshFromAgent(sshAgent agent.Agent, pubKeyBytes []byte) (Signer, error) { + signers, err := sshAgent.Signers() + if err != nil { + return nil, fmt.Errorf("listing agent signers: %w", err) + } + + for _, s := range signers { + if len(pubKeyBytes) == 0 || bytes.Equal(s.PublicKey().Marshal(), pubKeyBytes) { + signer, sErr := sshpkg.FromKey(s, sshpkg.WithHashAlgorithm(sshpkg.SHA512)) + if sErr != nil { + return nil, fmt.Errorf("creating SSH signer from agent key: %w", sErr) + } + + return signer, nil + } + } + + if len(pubKeyBytes) != 0 { + return nil, fmt.Errorf("%w: no keys found matching signingKey", ErrNoPrivateKey) + } + + return nil, ErrNoPrivateKeyInAgent +} + +// newGPGSigner reads an armored OpenPGP private-key ring from keyPath and +// returns a signer backed by the first unencrypted private key found. +// Entities without private-key material are skipped. Encrypted private keys +// are also skipped; ErrEncryptedKeyUnsupported is returned only when no +// unencrypted key exists in the ring. +// +//nolint:ireturn // Signer is the package's own exported interface. +func newGPGSigner(fsys billy.Basic, keyPath string) (Signer, error) { + if keyPath == "" { + return nil, fmt.Errorf("%w: %s", ErrNoPrivateKey, "missing signingKey") + } + + data, err := util.ReadFile(fsys, keyPath) + if err != nil { + return nil, fmt.Errorf("reading GPG private key: %w", err) + } + + entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("parsing GPG key ring: %w", err) + } + + var hasEncrypted bool + + for _, entity := range entities { + if entity.PrivateKey == nil { + continue + } + + if entity.PrivateKey.Encrypted { + hasEncrypted = true + + continue + } + + signer, sErr := gpgpkg.FromKey(entity) + if sErr != nil { + return nil, fmt.Errorf("creating GPG signer: %w", sErr) + } + + return signer, nil + } + + if hasEncrypted { + return nil, ErrEncryptedKeyUnsupported + } + + return nil, ErrNoPrivateKey +} diff --git a/plugin/objectsigner/auto/auto_test.go b/plugin/objectsigner/auto/auto_test.go new file mode 100644 index 0000000..ad5b0c8 --- /dev/null +++ b/plugin/objectsigner/auto/auto_test.go @@ -0,0 +1,771 @@ +package auto_test + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + billy "github.com/go-git/go-billy/v6" + "github.com/go-git/go-billy/v6/memfs" + "github.com/go-git/go-billy/v6/osfs" + "github.com/hiddeco/sshsig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gossh "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + + "github.com/go-git/x/plugin/objectsigner/auto" +) + +func TestFromConfigSSH(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeSSHKey(t, filepath.Join(dir, "id_ed25519")) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "id_ed25519", + Format: auto.FormatSSH, + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN SSH SIGNATURE-----") + assert.Contains(t, string(sig), "-----END SSH SIGNATURE-----") +} + +// When no SSH agent is configured, the .pub suffix carries no special meaning: +// the implementation reads the file at the given path directly as a private key. +func TestFromConfigSSHPubSuffixNoAgent(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Write a private key to a file whose name ends in .pub. + writeSSHKey(t, filepath.Join(dir, "id_ed25519.pub")) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "id_ed25519.pub", + Format: auto.FormatSSH, + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN SSH SIGNATURE-----") +} + +func TestFromConfigSSHPassphraseProtected(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + _, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + block, err := gossh.MarshalPrivateKeyWithPassphrase(priv, "", []byte("secret")) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "id_ed25519"), pem.EncodeToMemory(block), 0o600)) + + _, err = auto.FromConfig(auto.Config{ + SigningKey: "id_ed25519", + Format: auto.FormatSSH, + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.Error(t, err) + assert.ErrorIs(t, err, auto.ErrPassphraseUnsupported) +} + +// key:: prefix requires an SSH agent; the literal public key is used to +// select the matching signer from the agent. +func TestFromConfigSSHKeyLiteralAgent(t *testing.T) { + t.Parallel() + + keyring := agent.NewKeyring() + + // Decoy key — must not be selected. + addAgentKey(t, keyring, "") + + // Target key. + targetPub := addAgentKey(t, keyring, "") + + // key:: literal, type + base64 only (no trailing comment or newline). + literal := "key::" + strings.TrimSpace(string(gossh.MarshalAuthorizedKey(targetPub))) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: literal, + Format: auto.FormatSSH, + FS: nil, + SSHAgent: keyring, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN SSH SIGNATURE-----") +} + +func TestFromConfigSSHKeyLiteralNoAgent(t *testing.T) { + t.Parallel() + + _, err := auto.FromConfig(auto.Config{ + SigningKey: "key::ssh-ed25519 AAAA...", + Format: auto.FormatSSH, + FS: nil, + SSHAgent: nil, + }) + require.Error(t, err) + require.ErrorIs(t, err, auto.ErrNoPrivateKey) + assert.Contains(t, err.Error(), "signingKey must be a file path") +} + +// The .pub path is read from disk, the matching agent signer is returned. +func TestFromConfigSSHAgentPubKeyPath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + keyring := agent.NewKeyring() + addAgentKey(t, keyring, filepath.Join(dir, "id_ed25519.pub")) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "id_ed25519.pub", + Format: auto.FormatSSH, + FS: osfs.New(dir), + SSHAgent: keyring, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN SSH SIGNATURE-----") +} + +// When the agent holds multiple keys, the .pub path is used to select only +// the matching signer. +func TestFromConfigSSHAgentMultipleKeys(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + keyring := agent.NewKeyring() + + // Add two decoy keys before the target. + addAgentKey(t, keyring, "") + addAgentKey(t, keyring, "") + + // Target key — its public key is written to disk. + targetPub := addAgentKey(t, keyring, filepath.Join(dir, "id_ed25519.pub")) + + // Add another decoy after. + addAgentKey(t, keyring, "") + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "id_ed25519.pub", + Format: auto.FormatSSH, + FS: osfs.New(dir), + SSHAgent: keyring, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN SSH SIGNATURE-----") + + parsed, err := sshsig.Unarmor(sig) + require.NoError(t, err) + assert.True(t, bytes.Equal(parsed.PublicKey.Marshal(), targetPub.Marshal()), + "signature must be produced by the target key, not a decoy") +} + +// When the .pub path is given with an agent but the agent does not hold the +// matching key, the call returns an error (no private-key fallback for .pub paths). +func TestFromConfigSSHAgentPubKeyNotInAgent(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + keyring := agent.NewKeyring() + + // Write a public key for a key that is NOT in the agent. + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + sshPub, err := gossh.NewPublicKey(pub) + require.NoError(t, err) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "id_ed25519.pub"), + gossh.MarshalAuthorizedKey(sshPub), + 0o600, + )) + + // Add an unrelated key to the agent. + addAgentKey(t, keyring, "") + + _, err = auto.FromConfig(auto.Config{ + SigningKey: "id_ed25519.pub", + Format: auto.FormatSSH, + FS: osfs.New(dir), + SSHAgent: keyring, + }) + require.Error(t, err) + require.ErrorIs(t, err, auto.ErrNoPrivateKey) + assert.Contains(t, err.Error(), "no keys found matching signingKey") +} + +// When SigningKey is a non-.pub path and an agent is configured, the +// implementation reads the private key from disk directly (the agent is not +// consulted for non-.pub paths). +func TestFromConfigSSHAgentPrivateKeyPath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + keyring := agent.NewKeyring() + + // Generate the key pair, write the private key to disk, and also add + // the raw private key to the agent to prove the agent is not used when + // a non-.pub path is given. + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + block, err := gossh.MarshalPrivateKey(priv, "") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "id_ed25519"), pem.EncodeToMemory(block), 0o600)) + + sshPub, err := gossh.NewPublicKey(pub) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "id_ed25519.pub"), gossh.MarshalAuthorizedKey(sshPub), 0o600)) + + require.NoError(t, keyring.Add(agent.AddedKey{ //nolint:exhaustruct // only PrivateKey is required + PrivateKey: priv, + })) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "id_ed25519", + Format: auto.FormatSSH, + FS: osfs.New(dir), + SSHAgent: keyring, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN SSH SIGNATURE-----") +} + +// A non-.pub SigningKey with an agent, where the private key is absent from +// disk, results in a "reading SSH private key" error — there is no implicit +// fallback to the agent. +func TestFromConfigSSHAgentNoPrivateKeyFallback(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + keyring := agent.NewKeyring() + + // Add a key to the agent but do NOT write the private key to disk. + addAgentKey(t, keyring, filepath.Join(dir, "id_ed25519.pub")) + + _, err := auto.FromConfig(auto.Config{ + SigningKey: "id_ed25519", // private key file does not exist + Format: auto.FormatSSH, + FS: osfs.New(dir), + SSHAgent: keyring, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "reading SSH private key") +} + +// With no SigningKey and an agent, the first key from the agent is used. +func TestFromConfigSSHAgentFirstKey(t *testing.T) { + t.Parallel() + + keyring := agent.NewKeyring() + addAgentKey(t, keyring, "") + addAgentKey(t, keyring, "") + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "", + Format: auto.FormatSSH, + FS: nil, + SSHAgent: keyring, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN SSH SIGNATURE-----") +} + +// With no SigningKey and an empty agent, ErrNoPrivateKeyInAgent is returned. +func TestFromConfigSSHAgentEmptyAgent(t *testing.T) { + t.Parallel() + + keyring := agent.NewKeyring() + + _, err := auto.FromConfig(auto.Config{ + SigningKey: "", + Format: auto.FormatSSH, + FS: nil, + SSHAgent: keyring, + }) + require.Error(t, err) + assert.ErrorIs(t, err, auto.ErrNoPrivateKeyInAgent) +} + +// With no SigningKey and no agent, a descriptive error is returned. +func TestFromConfigSSHNoKeyNoAgent(t *testing.T) { + t.Parallel() + + _, err := auto.FromConfig(auto.Config{ + SigningKey: "", + Format: auto.FormatSSH, + FS: nil, + SSHAgent: nil, + }) + require.Error(t, err) + assert.ErrorIs(t, err, auto.ErrNoPrivateKey) +} + +func TestFromConfigGPG(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeGPGKeyRing(t, filepath.Join(dir, "key.asc"), newGPGEntity(t)) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "key.asc", + Format: auto.FormatOpenPGP, + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----") + assert.Contains(t, string(sig), "-----END PGP SIGNATURE-----") +} + +// Empty format string defaults to FormatOpenPGP. +func TestFromConfigGPGDefaultFormat(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeGPGKeyRing(t, filepath.Join(dir, "key.asc"), newGPGEntity(t)) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "key.asc", + Format: "", + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----") +} + +// An empty SigningKey for GPG returns ErrNoPrivateKey immediately, without +// attempting any file read. +func TestFromConfigGPGEmptyKeyPath(t *testing.T) { + t.Parallel() + + _, err := auto.FromConfig(auto.Config{ + SigningKey: "", + Format: auto.FormatOpenPGP, + FS: nil, + SSHAgent: nil, + }) + require.Error(t, err) + assert.ErrorIs(t, err, auto.ErrNoPrivateKey) +} + +// A key ring whose only entity has an encrypted private key returns +// ErrEncryptedKeyUnsupported. +func TestFromConfigGPGEncryptedKey(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeEncryptedGPGKey(t, filepath.Join(dir, "key.asc")) + + _, err := auto.FromConfig(auto.Config{ + SigningKey: "key.asc", + Format: auto.FormatOpenPGP, + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.Error(t, err) + assert.ErrorIs(t, err, auto.ErrEncryptedKeyUnsupported) +} + +// When the key ring contains multiple entities and the first has an encrypted +// private key, the implementation skips it and uses the first unencrypted one. +func TestFromConfigGPGEncryptedThenUnencrypted(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + encrypted := newGPGEntity(t) + require.NoError(t, encrypted.PrivateKey.Encrypt([]byte("test-passphrase"))) + + unencrypted := newGPGEntity(t) + + var buf bytes.Buffer + + armorWriter, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) + require.NoError(t, err) + // Encrypt clears the in-memory signer, so re-signing is impossible; + // use the no-resign variant for the encrypted entity. + require.NoError(t, encrypted.SerializePrivateWithoutSigning(armorWriter, nil)) + require.NoError(t, unencrypted.SerializePrivate(armorWriter, nil)) + require.NoError(t, armorWriter.Close()) + require.NoError(t, os.WriteFile(filepath.Join(dir, "key.asc"), buf.Bytes(), 0o600)) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "key.asc", + Format: auto.FormatOpenPGP, + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----") +} + +// A key ring with multiple unencrypted entities uses the first one. +func TestFromConfigGPGMultipleKeys(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeGPGKeyRing(t, filepath.Join(dir, "key.asc"), newGPGEntity(t), newGPGEntity(t)) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "key.asc", + Format: auto.FormatOpenPGP, + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----") +} + +// A key ring that contains only public-key material (no private key) returns +// ErrNoPrivateKey. +func TestFromConfigGPGNoPrivateKey(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeGPGPublicKeyOnly(t, filepath.Join(dir, "key.asc"), newGPGEntity(t)) + + _, err := auto.FromConfig(auto.Config{ + SigningKey: "key.asc", + Format: auto.FormatOpenPGP, + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.Error(t, err) + assert.ErrorIs(t, err, auto.ErrNoPrivateKey) +} + +// FromConfig expands a leading ~/ to the user's home directory before opening +// key files. The tests override $HOME so the real home directory is never +// touched and a temp dir is used as the fake home. + +func TestFromConfigSSHHomeTilde(t *testing.T) { + t.Parallel() + + home, err := os.UserHomeDir() + require.NoError(t, err) + + mfs := memfs.New() + + // Generate an SSH key and write the private key into the memfs at the + // absolute path that expandHome("~/.ssh/id_ed25519") will produce. + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + _ = pub + + block, err := gossh.MarshalPrivateKey(priv, "") + require.NoError(t, err) + + writeFileFS(t, mfs, filepath.Join(home, ".ssh", "id_ed25519"), pem.EncodeToMemory(block)) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "~/.ssh/id_ed25519", + Format: auto.FormatSSH, + FS: mfs, + SSHAgent: nil, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN SSH SIGNATURE-----") +} + +func TestFromConfigSSHAgentHomeTildePubKey(t *testing.T) { + t.Parallel() + + home, err := os.UserHomeDir() + require.NoError(t, err) + + mfs := memfs.New() + + // Generate a key, add the private half to the agent, and write the + // public key into the memfs where expandHome will resolve the path. + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + keyring := agent.NewKeyring() + require.NoError(t, keyring.Add(agent.AddedKey{ //nolint:exhaustruct // only PrivateKey is required + PrivateKey: priv, + })) + + sshPub, err := gossh.NewPublicKey(pub) + require.NoError(t, err) + + writeFileFS(t, mfs, filepath.Join(home, ".ssh", "id_ed25519.pub"), gossh.MarshalAuthorizedKey(sshPub)) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "~/.ssh/id_ed25519.pub", + Format: auto.FormatSSH, + FS: mfs, + SSHAgent: keyring, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN SSH SIGNATURE-----") +} + +func TestFromConfigGPGHomeTilde(t *testing.T) { + t.Parallel() + + home, err := os.UserHomeDir() + require.NoError(t, err) + + mfs := memfs.New() + + // Serialize a GPG key ring and write it into the memfs at the absolute + // path that expandHome("~/.gnupg/key.asc") will produce. + entity := newGPGEntity(t) + + var buf bytes.Buffer + + armorWriter, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) + require.NoError(t, err) + require.NoError(t, entity.SerializePrivate(armorWriter, nil)) + require.NoError(t, armorWriter.Close()) + + writeFileFS(t, mfs, filepath.Join(home, ".gnupg", "key.asc"), buf.Bytes()) + + signer, err := auto.FromConfig(auto.Config{ + SigningKey: "~/.gnupg/key.asc", + Format: auto.FormatOpenPGP, + FS: mfs, + SSHAgent: nil, + }) + require.NoError(t, err) + + sig, err := signer.Sign(strings.NewReader("hello\n")) + require.NoError(t, err) + assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----") +} + +// A ~username/ prefix is not expanded; the path is passed through as-is +// (which will fail to open, confirming no silent misinterpretation occurs). +func TestFromConfigHomeTildeUsernameNotExpanded(t *testing.T) { + t.Parallel() + + _, err := auto.FromConfig(auto.Config{ + SigningKey: "~otheruser/.ssh/id_ed25519", + Format: auto.FormatSSH, + FS: nil, + SSHAgent: nil, + }) + require.Error(t, err) + // The path was not silently mapped to the current user's home; an I/O + // error is expected because ~otheruser/ is not expanded. + assert.Contains(t, err.Error(), "reading SSH private key") +} + +func TestFromConfigUnknownFormat(t *testing.T) { + t.Parallel() + + _, err := auto.FromConfig(auto.Config{ + SigningKey: "some/key", + Format: "x509", + FS: nil, + SSHAgent: nil, + }) + require.Error(t, err) + assert.ErrorIs(t, err, auto.ErrUnsupportedFormat) +} + +func TestFromConfigMissingKeyFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + _, err := auto.FromConfig(auto.Config{ + SigningKey: "nonexistent", + Format: auto.FormatSSH, + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.Error(t, err) + + _, err = auto.FromConfig(auto.Config{ + SigningKey: "nonexistent", + Format: auto.FormatOpenPGP, + FS: osfs.New(dir), + SSHAgent: nil, + }) + require.Error(t, err) +} + +// writeSSHKey generates an ed25519 private key, writes it as a PEM file to +// path, and returns the corresponding ssh.PublicKey. +// +//nolint:ireturn // gossh.NewPublicKey returns gossh.PublicKey (interface); no concrete type is accessible. +func writeSSHKey(t *testing.T, path string) gossh.PublicKey { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + block, err := gossh.MarshalPrivateKey(priv, "") + require.NoError(t, err) + + require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(block), 0o600)) + + sshPub, err := gossh.NewPublicKey(pub) + require.NoError(t, err) + + return sshPub +} + +// addAgentKey generates an ed25519 key, adds the private key to keyring, and +// writes the public key in authorized_keys format to pubPath. It returns the +// ssh.PublicKey so callers can build key:: literals or compare signers. +// +//nolint:ireturn // gossh.NewPublicKey returns gossh.PublicKey (interface); no concrete type is accessible. +func addAgentKey(t *testing.T, keyring agent.Agent, pubPath string) gossh.PublicKey { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + require.NoError(t, keyring.Add(agent.AddedKey{ //nolint:exhaustruct // only PrivateKey is required + PrivateKey: priv, + })) + + sshPub, err := gossh.NewPublicKey(pub) + require.NoError(t, err) + + if pubPath != "" { + require.NoError(t, os.WriteFile(pubPath, gossh.MarshalAuthorizedKey(sshPub), 0o600)) + } + + return sshPub +} + +// newGPGEntity creates a fresh OpenPGP entity with an unencrypted private key. +func newGPGEntity(t *testing.T) *openpgp.Entity { + t.Helper() + + entity, err := openpgp.NewEntity("Test User", "", "test@example.com", nil) + require.NoError(t, err) + + return entity +} + +// writeGPGKeyRing serializes one or more OpenPGP entities (private key +// material included) into a single armored key-ring file at path. +func writeGPGKeyRing(t *testing.T, path string, entities ...*openpgp.Entity) { + t.Helper() + + var buf bytes.Buffer + + armorWriter, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) + require.NoError(t, err) + + for _, entity := range entities { + require.NoError(t, entity.SerializePrivate(armorWriter, nil)) + } + + require.NoError(t, armorWriter.Close()) + require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o600)) +} + +// writeEncryptedGPGKey creates an OpenPGP entity, encrypts its primary +// private key with a passphrase, and writes the armored result to path. The +// resulting file's entity will have PrivateKey.Encrypted == true when parsed. +// +// SerializePrivateWithoutSigning is used because Encrypt clears the in-memory +// crypto.Signer (setting PrivateKey.PrivateKey = nil), which makes the normal +// SerializePrivate fail when it tries to re-sign identity self-certifications. +func writeEncryptedGPGKey(t *testing.T, path string) { + t.Helper() + + entity := newGPGEntity(t) + require.NoError(t, entity.PrivateKey.Encrypt([]byte("test-passphrase"))) + + var buf bytes.Buffer + + armorWriter, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) + require.NoError(t, err) + + require.NoError(t, entity.SerializePrivateWithoutSigning(armorWriter, nil)) + require.NoError(t, armorWriter.Close()) + + require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o600)) +} + +// writeGPGPublicKeyOnly serializes only the public-key material of entity to +// path. When parsed back the resulting entity has PrivateKey == nil. +func writeGPGPublicKeyOnly(t *testing.T, path string, entity *openpgp.Entity) { + t.Helper() + + var buf bytes.Buffer + + armorWriter, err := armor.Encode(&buf, openpgp.PublicKeyType, nil) + require.NoError(t, err) + + require.NoError(t, entity.Serialize(armorWriter)) + require.NoError(t, armorWriter.Close()) + + require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o600)) +} + +// writeFileFS creates a file in a billy filesystem, creating parent +// directories as needed. +func writeFileFS(t *testing.T, fsys billy.Filesystem, path string, data []byte) { + t.Helper() + require.NoError(t, fsys.MkdirAll(filepath.Dir(path), 0o700)) + f, err := fsys.Create(path) + require.NoError(t, err) + _, err = f.Write(data) + require.NoError(t, err) + require.NoError(t, f.Close()) +} diff --git a/plugin/objectsigner/auto/go.mod b/plugin/objectsigner/auto/go.mod new file mode 100644 index 0000000..36611e0 --- /dev/null +++ b/plugin/objectsigner/auto/go.mod @@ -0,0 +1,22 @@ +module github.com/go-git/x/plugin/objectsigner/auto + +go 1.25.0 + +require ( + github.com/ProtonMail/go-crypto v1.3.0 + github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d + github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 + github.com/go-git/x/plugin/objectsigner/ssh v0.1.0 + github.com/hiddeco/sshsig v0.2.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.48.0 +) + +require ( + github.com/cloudflare/circl v1.6.0 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugin/objectsigner/auto/go.sum b/plugin/objectsigner/auto/go.sum new file mode 100644 index 0000000..cf41441 --- /dev/null +++ b/plugin/objectsigner/auto/go.sum @@ -0,0 +1,30 @@ +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d h1:bLMI9z4mKkfQO383+O3fkP4xdWQcMdnn5fFBMwaBC1M= +github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d/go.mod h1:LLeMBFApkgIKwMzirxpU9XB7NvO2HdTw5FXmeP1M6c8= +github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 h1:NEGVSOD+LPnus6j4iNkAZaHVTc4DNY223y1/I2Jq2yI= +github.com/go-git/x/plugin/objectsigner/gpg v0.1.0/go.mod h1:1iosWq3OOqZxtNrwDHtcjicswuaOT45J5GMFyCk80wc= +github.com/go-git/x/plugin/objectsigner/ssh v0.1.0 h1:lAeeDgc1oxsMMvVUed6ssrqJnD97UR1K/dXIDdeg1Yc= +github.com/go-git/x/plugin/objectsigner/ssh v0.1.0/go.mod h1:6BvpZj9Yry1ZFNw4N5OZDc+7M1T8oyrZilLNFg2aTsM= +github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= +github.com/hiddeco/sshsig v0.2.0/go.mod h1:nJc98aGgiH6Yql2doqH4CTBVHexQA40Q+hMMLHP4EqE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=