Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
102 changes: 97 additions & 5 deletions cmd/argocd-commit-server/commands/argocd_commit_server.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package commands

import (
"context"
stderrors "errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"

Expand All @@ -22,17 +25,21 @@ import (
"github.com/argoproj/argo-cd/v3/util/cli"
"github.com/argoproj/argo-cd/v3/util/env"
"github.com/argoproj/argo-cd/v3/util/errors"
"github.com/argoproj/argo-cd/v3/util/gpgsign"
"github.com/argoproj/argo-cd/v3/util/healthz"
utilio "github.com/argoproj/argo-cd/v3/util/io"
"github.com/argoproj/argo-cd/v3/util/sourceintegrity"
)

// NewCommand returns a new instance of an argocd-commit-server command
func NewCommand() *cobra.Command {
var (
listenHost string
listenPort int
metricsPort int
metricsHost string
listenHost string
listenPort int
metricsPort int
metricsHost string
signingKeyPath string
signingKeyPassphraseFile string
)
command := &cobra.Command{
Use: common.CommandCommitServer,
Expand All @@ -57,7 +64,24 @@ func NewCommand() *cobra.Command {
askPassServer := askpass.NewServer(askpass.CommitServerSocketPath)
go func() { errors.CheckError(askPassServer.Run()) }()

server := commitserver.NewServer(askPassServer, metricsServer)
errors.CheckError(validateSigningFlags(signingKeyPath, signingKeyPassphraseFile))

// A configured signing key path is what enables signing: there is
// no separate on/off toggle. Inferring intent from the key path
// avoids contradictory states (enabled-but-no-key, key-but-disabled).
var signingConfig *gpgsign.Config
if signingKeyPath != "" {
cfg, err := setupSigningKey(signingKeyPath, signingKeyPassphraseFile)
errors.CheckError(err)
signingConfig = cfg
log.WithFields(log.Fields{
"keyID": cfg.KeyID,
"fingerprint": cfg.Fingerprint,
"gnupgHome": common.GetGnuPGHomePath(),
}).Info("Hydrated commit signing enabled")
}

server := commitserver.NewServer(askPassServer, metricsServer, signingConfig)
grpc := server.CreateGRPC()
ctx := cmd.Context()

Expand Down Expand Up @@ -112,6 +136,74 @@ func NewCommand() *cobra.Command {
command.Flags().IntVar(&listenPort, "port", common.DefaultPortCommitServer, "Listen on given port for incoming connections")
command.Flags().StringVar(&metricsHost, "metrics-address", env.StringFromEnv("ARGOCD_COMMIT_SERVER_METRICS_LISTEN_ADDRESS", common.DefaultAddressCommitServerMetrics), "Listen on given address for metrics")
command.Flags().IntVar(&metricsPort, "metrics-port", common.DefaultPortCommitServerMetrics, "Start metrics server on given port")
command.Flags().StringVar(&signingKeyPath, "signing-key-path", env.StringFromEnv("ARGOCD_COMMIT_SERVER_SIGNING_KEY_PATH", ""), "Path to the ASCII-armored GPG private key used to sign hydrated commits. Setting this enables signing; when set, the commit server fails at startup if the key cannot be loaded.")
command.Flags().StringVar(&signingKeyPassphraseFile, "signing-key-passphrase-file", env.StringFromEnv("ARGOCD_COMMIT_SERVER_SIGNING_KEY_PASSPHRASE_FILE", ""), "Optional path to a file containing the passphrase for the signing key.")

return command
}

// validateSigningFlags rejects contradictory signing configuration before
// startup. Signing is enabled solely by setting the key path, so a passphrase
// without a key is meaningless — reject it rather than silently ignoring it.
func validateSigningFlags(keyPath, passphraseFile string) error {
if keyPath == "" && passphraseFile != "" {
return stderrors.New("signing-key-passphrase-file is set but signing-key-path is empty; set signing-key-path to enable signing")
}
return nil
}

// setupSigningKey initializes the shared GNUPGHOME, imports the configured
// private signing key, and — for a passphrase-protected key — loads the
// passphrase into gpg-agent so later `git commit -S` calls sign
// non-interactively. Any error here must propagate up to abort startup —
// silently falling back to unsigned commits would defeat the feature's purpose.
func setupSigningKey(keyPath, passphraseFile string) (*gpgsign.Config, error) {
if err := sourceintegrity.InitializeGnuPG(); err != nil {
return nil, fmt.Errorf("failed to initialize GnuPG home: %w", err)
}

// Write gpg-agent.conf into the GNUPGHOME that InitializeGnuPG just created.
// The agent is typically already running by now (InitializeGnuPG generates a
// key, which starts it); PresetSigningPassphrase reloads the agent so
// allow-preset-passphrase takes effect before the passphrase is preset.
if err := gpgsign.WriteAgentConfig(common.GetGnuPGHomePath()); err != nil {
return nil, fmt.Errorf("failed to configure gpg-agent: %w", err)
}

keyData, err := os.ReadFile(keyPath)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think the code should empty keydata, for both security and memory issues?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keyData is gc-eligable so no memory issues that way. however, from security perspective, it can be zeroed as in-depth defense.

if err != nil {
return nil, fmt.Errorf("failed to read signing key from %s: %w", keyPath, err)
}
if strings.TrimSpace(string(keyData)) == "" {
return nil, fmt.Errorf("signing key file %s is empty", keyPath)
}

var passphrase string
if passphraseFile != "" {
pp, err := os.ReadFile(passphraseFile)
if err != nil {
return nil, fmt.Errorf("failed to read signing key passphrase from %s: %w", passphraseFile, err)
}
// Strip trailing newline that file-editors love to add. We deliberately
// don't TrimSpace — a passphrase legitimately can start/end with spaces.
passphrase = strings.TrimRight(string(pp), "\r\n")
}

cfg, err := gpgsign.ImportSigningKey(context.Background(), keyData, passphrase)
// keyData held a copy of the private signing key. Scrub it as soon as the
// import returns so the secret doesn't linger on the heap until GC reclaims
// it. Defense-in-depth only: the key still lives on disk and in GNUPGHOME,
// and the passphrase string can't be wiped the same way.
clear(keyData)
if err != nil {
return nil, fmt.Errorf("failed to import signing key: %w", err)
}

// Only a protected key needs a preset; an unprotected key signs without one.
if passphrase != "" {
if err := gpgsign.PresetSigningPassphrase(context.Background(), cfg.Fingerprint, passphrase); err != nil {
return nil, fmt.Errorf("failed to preset signing passphrase: %w", err)
}
}
return cfg, nil
}
165 changes: 165 additions & 0 deletions cmd/argocd-commit-server/commands/argocd_commit_server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package commands

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/argoproj/argo-cd/v3/common"
"github.com/argoproj/argo-cd/v3/util/gpgsign/gpgsigntest"
)

func TestValidateSigningFlags(t *testing.T) {
tests := []struct {
name string
keyPath string
passphraseFile string
wantErr bool
}{
{
name: "no key and no passphrase is valid (signing disabled)",
keyPath: "",
passphraseFile: "",
wantErr: false,
},
{
name: "key without passphrase is valid (unprotected key)",
keyPath: "/app/config/gpg/signing/signingKey",
passphraseFile: "",
wantErr: false,
},
{
name: "key with passphrase is valid (protected key)",
keyPath: "/app/config/gpg/signing/signingKey",
passphraseFile: "/app/config/gpg/signing/passphrase",
wantErr: false,
},
{
name: "passphrase without key is rejected",
keyPath: "",
passphraseFile: "/app/config/gpg/signing/passphrase",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateSigningFlags(tt.keyPath, tt.passphraseFile)
if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), "signing-key-path")
} else {
require.NoError(t, err)
}
})
}
}

// setSharedGnuPGHome points ARGOCD_GNUPGHOME at a throwaway dir so
// setupSigningKey can initialize a real keyring without touching the host's.
func setSharedGnuPGHome(t *testing.T) {
t.Helper()
t.Setenv(common.EnvGnuPGHome, gpgsigntest.ShortTempDir(t))
}

func TestSetupSigningKey(t *testing.T) {
t.Run("imports an unprotected key and returns its config", func(t *testing.T) {
keyData, wantFP := gpgsigntest.GenerateSigningKey(t, "")
setSharedGnuPGHome(t)

keyPath := filepath.Join(gpgsigntest.ShortTempDir(t), "signingKey")
require.NoError(t, os.WriteFile(keyPath, keyData, 0o600))

cfg, err := setupSigningKey(keyPath, "")
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, wantFP, cfg.Fingerprint)
assert.Equal(t, wantFP[len(wantFP)-16:], cfg.KeyID)
})

t.Run("imports a passphrase-protected key and presets the passphrase", func(t *testing.T) {
const passphrase = "s3cret"
keyData, wantFP := gpgsigntest.GenerateSigningKey(t, passphrase)
setSharedGnuPGHome(t)

dir := gpgsigntest.ShortTempDir(t)
keyPath := filepath.Join(dir, "signingKey")
require.NoError(t, os.WriteFile(keyPath, keyData, 0o600))
// File-editors love a trailing newline; setupSigningKey must strip it.
passphraseFile := filepath.Join(dir, "passphrase")
require.NoError(t, os.WriteFile(passphraseFile, []byte(passphrase+"\n"), 0o600))

cfg, err := setupSigningKey(keyPath, passphraseFile)
// gpg-preset-passphrase, and the gpgconf used to locate it, ship with the
// gpg-agent package but may be absent in minimal environments; skip there
// rather than fail.
if err != nil && (strings.Contains(err.Error(), "gpg-preset-passphrase not found") ||
strings.Contains(err.Error(), "failed to locate gnupg libexec dir")) {
t.Skipf("gpg-preset-passphrase unavailable: %v", err)
}
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, wantFP, cfg.Fingerprint)
})

t.Run("fails when the key file does not exist", func(t *testing.T) {
// gpg is still required because setupSigningKey initializes GnuPG before
// it ever reads the key file.
if _, err := exec.LookPath("gpg"); err != nil {
t.Skip("gpg not available")
}
setSharedGnuPGHome(t)

_, err := setupSigningKey(filepath.Join(gpgsigntest.ShortTempDir(t), "does-not-exist"), "")
require.Error(t, err)
assert.ErrorContains(t, err, "failed to read signing key")
})

t.Run("fails when the key file is empty", func(t *testing.T) {
if _, err := exec.LookPath("gpg"); err != nil {
t.Skip("gpg not available")
}
setSharedGnuPGHome(t)

keyPath := filepath.Join(gpgsigntest.ShortTempDir(t), "empty")
require.NoError(t, os.WriteFile(keyPath, []byte(" \n"), 0o600))

_, err := setupSigningKey(keyPath, "")
require.Error(t, err)
assert.ErrorContains(t, err, "is empty")
})

t.Run("fails when the passphrase file does not exist", func(t *testing.T) {
if _, err := exec.LookPath("gpg"); err != nil {
t.Skip("gpg not available")
}
setSharedGnuPGHome(t)

// The passphrase is read before the key is imported, so a non-empty
// placeholder key is enough to reach the passphrase-read error.
keyPath := filepath.Join(gpgsigntest.ShortTempDir(t), "signingKey")
require.NoError(t, os.WriteFile(keyPath, []byte("not-a-real-key"), 0o600))

_, err := setupSigningKey(keyPath, filepath.Join(gpgsigntest.ShortTempDir(t), "no-passphrase"))
require.Error(t, err)
assert.ErrorContains(t, err, "failed to read signing key passphrase")
})

t.Run("fails when the key data is not a valid GPG key", func(t *testing.T) {
if _, err := exec.LookPath("gpg"); err != nil {
t.Skip("gpg not available")
}
setSharedGnuPGHome(t)

keyPath := filepath.Join(gpgsigntest.ShortTempDir(t), "signingKey")
require.NoError(t, os.WriteFile(keyPath, []byte("not-a-real-key"), 0o600))

_, err := setupSigningKey(keyPath, "")
require.Error(t, err)
assert.ErrorContains(t, err, "failed to import signing key")
})
}
Loading
Loading