diff --git a/changelog/fragments/1726040648-Add-support-for-passphrase-protected-mTLS-client-certificate-key.yaml b/changelog/fragments/1726040648-Add-support-for-passphrase-protected-mTLS-client-certificate-key.yaml new file mode 100644 index 00000000000..d9fcaad8db8 --- /dev/null +++ b/changelog/fragments/1726040648-Add-support-for-passphrase-protected-mTLS-client-certificate-key.yaml @@ -0,0 +1,35 @@ +# Kind can be one of: +# - breaking-change: a change to previously-documented behavior +# - deprecation: functionality that is being removed in a later release +# - bug-fix: fixes a problem in a previous version +# - enhancement: extends functionality but does not break or fix existing behavior +# - feature: new functionality +# - known-issue: problems that we are aware of in a given version +# - security: impacts on the security of a product or a user’s deployment. +# - upgrade: important information for someone upgrading from a prior version +# - other: does not fit into any of the other categories +kind: feature + +# Change summary; a 80ish characters long description of the change. +summary: Add support for passphrase protected mTLS client certificate key during install/enroll + +# Long description; in case the summary is not enough to describe the change +# this field accommodate a description without length limits. +# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment. +description: | + Adds `--elastic-agent-cert-key-passphrase` command line flag for the `install` + and `enroll` commands. The new flag accepts a absolute path for a file containing + a passphrase to be used to decrypt the mTLS client certificate key. + +# Affected component; a word indicating the component this changeset affects. +component: + +# PR URL; optional; the PR number that added the changeset. +# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added. +# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number. +# Please provide it if you are adding a fragment for a different PR. +#pr: https://github.com/owner/repo/1234 + +# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of). +# If not present is automatically filled by the tooling with the issue linked to the PR number. +#issue: https://github.com/owner/repo/1234 diff --git a/internal/pkg/agent/application/actions/handlers/handler_action_policy_change_test.go b/internal/pkg/agent/application/actions/handlers/handler_action_policy_change_test.go index d68483d3ab6..4895959c17f 100644 --- a/internal/pkg/agent/application/actions/handlers/handler_action_policy_change_test.go +++ b/internal/pkg/agent/application/actions/handlers/handler_action_policy_change_test.go @@ -741,6 +741,46 @@ func TestPolicyChangeHandler_handlePolicyChange_FleetClientSettings(t *testing.T "unexpected error when applying fleet.ssl.certificate and key") }, }, + { + name: "certificate and key without passphrase clear out previous passphrase", + originalCfg: &configuration.Configuration{ + Fleet: &configuration.FleetAgentConfig{ + Client: remote.Config{ + Host: fleetmTLSServer.URL, + Transport: httpcommon.HTTPTransportSettings{ + TLS: &tlscommon.Config{ + CAs: []string{string(fleetRootPair.Cert)}, + Certificate: tlscommon.CertificateConfig{ + Certificate: "some certificate", + Key: "some key", + Passphrase: "", + PassphrasePath: "/path/to/passphrase", + }, + }, + }, + }, + AccessAPIKey: "ignore", + }, + Settings: configuration.DefaultSettingsConfig(), + }, + newCfg: map[string]interface{}{ + "fleet.ssl.enabled": true, + "fleet.ssl.certificate": string(agentChildPair.Cert), + "fleet.ssl.key": string(agentChildPair.Key), + }, + setterCalledCount: 1, + wantCAs: []string{string(fleetRootPair.Cert)}, + wantCertificateConfig: tlscommon.CertificateConfig{ + Certificate: string(agentChildPair.Cert), + Key: string(agentChildPair.Key), + Passphrase: "", + PassphrasePath: "", + }, + assertErr: func(t *testing.T, err error) { + assert.NoError(t, err, + "unexpected error when applying fleet.ssl.certificate and key") + }, + }, { name: "certificate and key with passphrase_path is applied when present", originalCfg: &configuration.Configuration{ diff --git a/internal/pkg/agent/cmd/enroll.go b/internal/pkg/agent/cmd/enroll.go index 093bf9e7377..924ab373f8f 100644 --- a/internal/pkg/agent/cmd/enroll.go +++ b/internal/pkg/agent/cmd/enroll.go @@ -84,6 +84,7 @@ func addEnrollFlags(cmd *cobra.Command) { cmd.Flags().StringP("ca-sha256", "p", "", "Comma-separated list of certificate authority hash pins for server verification used by Elastic Agent and Fleet Server") cmd.Flags().StringP("elastic-agent-cert", "", "", "Elastic Agent client certificate to use with Fleet Server during mTLS authentication") cmd.Flags().StringP("elastic-agent-cert-key", "", "", "Elastic Agent client private key to use with Fleet Server during mTLS authentication") + cmd.Flags().StringP("elastic-agent-cert-key-passphrase", "", "", "Path for private key passphrase file used to decrypt Elastic Agent client certificate key") cmd.Flags().BoolP("insecure", "i", false, "Allow insecure connection made by the Elastic Agent. It's also required to use a Fleet Server on a HTTP endpoint") cmd.Flags().StringP("staging", "", "", "Configures Elastic Agent to download artifacts from a staging build") cmd.Flags().StringP("proxy-url", "", "", "Configures the proxy URL: when bootstrapping Fleet Server, it's the proxy used by Fleet Server to connect to Elasticsearch; when enrolling the Elastic Agent to Fleet Server, it's the proxy used by the Elastic Agent to connect to Fleet Server") @@ -111,6 +112,16 @@ func validateEnrollFlags(cmd *cobra.Command) error { if key != "" && !filepath.IsAbs(key) { return errors.New("--elastic-agent-cert-key must be provided as an absolute path", errors.M("path", key), errors.TypeConfig) } + keyPassphrase, _ := cmd.Flags().GetString("elastic-agent-cert-key-passphrase") + if keyPassphrase != "" { + if !filepath.IsAbs(keyPassphrase) { + return errors.New("--elastic-agent-cert-key-passphrase must be provided as an absolute path", errors.M("path", keyPassphrase), errors.TypeConfig) + } + + if cert == "" || key == "" { + return errors.New("--elastic-agent-cert and --elastic-agent-cert-key must be provided when using --elastic-agent-cert-key-passphrase", errors.M("path", keyPassphrase), errors.TypeConfig) + } + } esCa, _ := cmd.Flags().GetString("fleet-server-es-ca") if esCa != "" && !filepath.IsAbs(esCa) { return errors.New("--fleet-server-es-ca must be provided as an absolute path", errors.M("path", esCa), errors.TypeConfig) @@ -180,6 +191,7 @@ func buildEnrollmentFlags(cmd *cobra.Command, url string, token string) []string ca, _ := cmd.Flags().GetString("certificate-authorities") cert, _ := cmd.Flags().GetString("elastic-agent-cert") key, _ := cmd.Flags().GetString("elastic-agent-cert-key") + keyPassphrase, _ := cmd.Flags().GetString("elastic-agent-cert-key-passphrase") sha256, _ := cmd.Flags().GetString("ca-sha256") insecure, _ := cmd.Flags().GetBool("insecure") staging, _ := cmd.Flags().GetString("staging") @@ -285,6 +297,10 @@ func buildEnrollmentFlags(cmd *cobra.Command, url string, token string) []string args = append(args, "--elastic-agent-cert-key") args = append(args, key) } + if keyPassphrase != "" { + args = append(args, "--elastic-agent-cert-key-passphrase") + args = append(args, keyPassphrase) + } if sha256 != "" { args = append(args, "--ca-sha256") args = append(args, sha256) @@ -422,6 +438,7 @@ func enroll(streams *cli.IOStreams, cmd *cobra.Command) error { caSHA256 := cli.StringToSlice(caSHA256str) cert, _ := cmd.Flags().GetString("elastic-agent-cert") key, _ := cmd.Flags().GetString("elastic-agent-cert-key") + keyPassphrase, _ := cmd.Flags().GetString("elastic-agent-cert-key-passphrase") ctx := handleSignal(context.Background()) @@ -449,6 +466,7 @@ func enroll(streams *cli.IOStreams, cmd *cobra.Command) error { CASha256: caSHA256, Certificate: cert, Key: key, + KeyPassphrasePath: keyPassphrase, Insecure: insecure, UserProvidedMetadata: make(map[string]interface{}), Staging: staging, diff --git a/internal/pkg/agent/cmd/enroll_cmd.go b/internal/pkg/agent/cmd/enroll_cmd.go index 68a1e018f3f..2bb07dde414 100644 --- a/internal/pkg/agent/cmd/enroll_cmd.go +++ b/internal/pkg/agent/cmd/enroll_cmd.go @@ -111,6 +111,7 @@ type enrollCmdOption struct { CASha256 []string `yaml:"ca_sha256,omitempty"` Certificate string `yaml:"certificate,omitempty"` Key string `yaml:"key,omitempty"` + KeyPassphrasePath string `yaml:"key_passphrase_path,omitempty"` Insecure bool `yaml:"insecure,omitempty"` EnrollAPIKey string `yaml:"enrollment_key,omitempty"` Staging string `yaml:"staging,omitempty"` @@ -149,8 +150,9 @@ func (e *enrollCmdOption) remoteConfig() (remote.Config, error) { } if e.Certificate != "" || e.Key != "" { tlsCfg.Certificate = tlscommon.CertificateConfig{ - Certificate: e.Certificate, - Key: e.Key, + Certificate: e.Certificate, + Key: e.Key, + PassphrasePath: e.KeyPassphrasePath, } } diff --git a/internal/pkg/agent/cmd/enroll_cmd_test.go b/internal/pkg/agent/cmd/enroll_cmd_test.go index d24794f6b0c..cc13b2ab252 100644 --- a/internal/pkg/agent/cmd/enroll_cmd_test.go +++ b/internal/pkg/agent/cmd/enroll_cmd_test.go @@ -7,12 +7,17 @@ package cmd import ( "bytes" "context" + "crypto/rand" "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" "io" "net" "net/http" "net/http/httptest" "os" + "path/filepath" "runtime" "strconv" "sync/atomic" @@ -22,6 +27,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/elastic/elastic-agent-libs/testing/certutil" "github.com/elastic/elastic-agent/internal/pkg/agent/configuration" "github.com/elastic/elastic-agent/internal/pkg/agent/errors" "github.com/elastic/elastic-agent/internal/pkg/cli" @@ -113,6 +119,95 @@ func TestEnroll(t *testing.T) { }, )) + t.Run("successfully enroll with mTLS and save fleet config in the store", func(t *testing.T) { + agentCertPassphrase := "a really secure passphrase" + passphrasePath := filepath.Join(t.TempDir(), "passphrase") + err := os.WriteFile( + passphrasePath, + []byte(agentCertPassphrase), + 0666) + require.NoError(t, err, + "could not write agent child certificate key passphrase to temp directory") + + tlsCfg, _, agentCertPathPair, fleetRootPathPair, _ := + mTLSServer(t, agentCertPassphrase) + + mockHandlerCalled := false + mockHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mockHandlerCalled = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(` +{ + "action": "created", + "item": { + "id": "a9328860-ec54-11e9-93c4-d72ab8a69391", + "active": true, + "policy_id": "69f3f5a0-ec52-11e9-93c4-d72ab8a69391", + "type": "PERMANENT", + "enrolled_at": "2019-10-11T18:26:37.158Z", + "user_provided_metadata": { + "custom": "customize" + }, + "local_metadata": { + "platform": "linux", + "version": "8.0.0" + }, + "actions": [], + "access_api_key": "my-access-api-key" + } +}`)) + }) + + s := httptest.NewUnstartedServer(mockHandler) + s.TLS = tlsCfg + s.StartTLS() + defer s.Close() + + store := &mockStore{} + enrollOptions := enrollCmdOption{ + CAs: []string{string(fleetRootPathPair.Cert)}, + Certificate: string(agentCertPathPair.Cert), + Key: string(agentCertPathPair.Key), + KeyPassphrasePath: passphrasePath, + + URL: s.URL, + EnrollAPIKey: "my-enrollment-api-key", + UserProvidedMetadata: map[string]interface{}{"custom": "customize"}, + SkipCreateSecret: skipCreateSecret, + SkipDaemonRestart: true, + } + cmd, err := newEnrollCmd( + log, + &enrollOptions, + "", + store, + ) + require.NoError(t, err, "could not create enroll command") + + streams, _, _, _ := cli.NewTestingIOStreams() + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + err = cmd.Execute(ctx, streams) + require.NoError(t, err, "enroll command returned and unexpected error") + + fleetCfg, err := readConfig(store.Content) + require.NoError(t, err, "could not read fleet config from store") + + assert.True(t, mockHandlerCalled, "mock handler should have been called") + fleetTLS := fleetCfg.Client.Transport.TLS + + require.NotNil(t, fleetTLS, `fleet client TLS config should have been set`) + assert.Equal(t, s.URL, fmt.Sprintf("%s://%s", + fleetCfg.Client.Protocol, fleetCfg.Client.Host)) + assert.Equal(t, enrollOptions.CAs, fleetTLS.CAs) + assert.Equal(t, + enrollOptions.Certificate, fleetTLS.Certificate.Certificate) + assert.Equal(t, enrollOptions.Key, fleetTLS.Certificate.Key) + assert.Equal(t, + enrollOptions.KeyPassphrasePath, fleetTLS.Certificate.PassphrasePath) + }) + t.Run("successfully enroll with TLS and save access api key in the store", withTLSServer( func(t *testing.T) *http.ServeMux { mux := http.NewServeMux() @@ -167,7 +262,7 @@ func TestEnroll(t *testing.T) { defer cancel() if err := cmd.Execute(ctx, streams); err != nil { - t.Fatalf("enrrol coms returned and unexpected error: %v", err) + t.Fatalf("enroll command returned and unexpected error: %v", err) } config, err := readConfig(store.Content) @@ -229,7 +324,7 @@ func TestEnroll(t *testing.T) { defer cancel() if err := cmd.Execute(ctx, streams); err != nil { - t.Fatalf("enrrol coms returned and unexpected error: %v", err) + t.Fatalf("enroll command returned and unexpected error: %v", err) } assert.True(t, store.Called) @@ -522,21 +617,55 @@ func TestValidateEnrollFlags(t *testing.T) { t.Run("no flags", func(t *testing.T) { cmd := newEnrollCommandWithArgs([]string{}, streams) err := validateEnrollFlags(cmd) - require.NoError(t, err) + + assert.NoError(t, err) }) t.Run("service_token and a service_token_path are mutually exclusive", func(t *testing.T) { + absPath, err := filepath.Abs("/path/to/token") + require.NoError(t, err, "could not get absolute absPath") + cmd := newEnrollCommandWithArgs([]string{}, streams) - err := cmd.Flags().Set("fleet-server-service-token-path", "/path/to/token") + err = cmd.Flags().Set("fleet-server-service-token-path", absPath) require.NoError(t, err) err = cmd.Flags().Set("fleet-server-service-token", "token-value") require.NoError(t, err) + err = validateEnrollFlags(cmd) - require.Error(t, err) + assert.Error(t, err) var agentErr errors.Error - require.ErrorAs(t, err, &agentErr) - require.Equal(t, errors.TypeConfig, agentErr.Type()) + assert.ErrorAs(t, err, &agentErr) + assert.Equal(t, errors.TypeConfig, agentErr.Type()) + }) + + t.Run("elastic-agent-cert-key does not require key-passphrase", func(t *testing.T) { + absPath, err := filepath.Abs("/path/to/elastic-agent-cert-key") + require.NoError(t, err, "could not get absolute absPath") + + cmd := newEnrollCommandWithArgs([]string{}, streams) + err = cmd.Flags().Set("elastic-agent-cert-key", absPath) + require.NoError(t, err, "could not set flag 'elastic-agent-cert-key'") + + err = validateEnrollFlags(cmd) + + assert.NoError(t, err, "validateEnrollFlags should have succeeded") + }) + + t.Run("elastic-agent-cert-key-passphrase requires certificate and key", func(t *testing.T) { + absPath, err := filepath.Abs("/path/to/elastic-agent-cert-key-passphrase") + require.NoError(t, err, "could not get absolute absPath") + + cmd := newEnrollCommandWithArgs([]string{}, streams) + err = cmd.Flags().Set("elastic-agent-cert-key-passphrase", absPath) + require.NoError(t, err, "could not set flag 'elastic-agent-cert-key-passphrase'") + + err = validateEnrollFlags(cmd) + + assert.Error(t, err, "validateEnrollFlags should not accept only --elastic-agent-cert-key-passphrase") + var agentErr errors.Error + assert.ErrorAs(t, err, &agentErr) + assert.Equal(t, errors.TypeConfig, agentErr.Type()) }) } @@ -645,6 +774,81 @@ func withTLSServer( } } +// mTLSServer generates the necessary certificates and tls.Config for a mTLS +// server. If agentPassphrase is given, it'll encrypt the agent's client +// certificate key. +// It returns the *tls.Config to be used with httptest.NewUnstartedServer, +// the agentRootPair, agentChildPair, fleetRootPathPair, fleetCertPathPair. +// Theirs Cert and Key values are the path to the respective certificate and +// certificate key in PEM format. +func mTLSServer(t *testing.T, agentPassphrase string) ( + *tls.Config, certutil.Pair, certutil.Pair, certutil.Pair, certutil.Pair) { + + dir := t.TempDir() + + // generate certificates + agentRootPair, agentCertPair, err := certutil.NewRootAndChildCerts() + require.NoError(t, err, "could not create agent's root CA and child certificate") + + // encrypt keys if needed + if agentPassphrase != "" { + agentChildDERKey, _ := pem.Decode(agentCertPair.Key) + require.NoError(t, err, "could not create tls.Certificates from child certificate") + + encPem, err := x509.EncryptPEMBlock( //nolint:staticcheck // we need to drop support for this, but while we don't, it needs to be tested. + rand.Reader, + "EC PRIVATE KEY", + agentChildDERKey.Bytes, + []byte(agentPassphrase), + x509.PEMCipherAES128) + require.NoError(t, err, "failed encrypting agent child certificate key block") + + agentCertPair.Key = pem.EncodeToMemory(encPem) + } + + agentRootPathPair := savePair(t, dir, "agent_ca", agentRootPair) + agentCertPathPair := savePair(t, dir, "agent_cert", agentCertPair) + + fleetRootPair, fleetChildPair, err := certutil.NewRootAndChildCerts() + require.NoError(t, err, "could not create fleet-server's root CA and child certificate") + fleetRootPathPair := savePair(t, dir, "fleet_ca", fleetRootPair) + fleetCertPathPair := savePair(t, dir, "fleet_cert", fleetChildPair) + + // configure server's TLS + fleetRootCertPool := x509.NewCertPool() + fleetRootCertPool.AppendCertsFromPEM(fleetRootPair.Cert) + cert, err := tls.X509KeyPair(fleetRootPair.Cert, fleetRootPair.Key) + require.NoError(t, err, "could not create tls.Certificates from child certificate") + + agentRootCertPool := x509.NewCertPool() + agentRootCertPool.AppendCertsFromPEM(agentRootPair.Cert) + + cfg := &tls.Config{ //nolint:gosec // it's just a test + RootCAs: fleetRootCertPool, + Certificates: []tls.Certificate{cert}, + ClientCAs: agentRootCertPool, + ClientAuth: tls.RequireAndVerifyClientCert, + } + + return cfg, agentRootPathPair, agentCertPathPair, fleetRootPathPair, fleetCertPathPair +} + +// savePair saves the key pair on {dest}/{name}.pem and {dest}/{name}_key.pem +func savePair(t *testing.T, dest string, name string, pair certutil.Pair) certutil.Pair { + certPath := filepath.Join(dest, name+".pem") + err := os.WriteFile(certPath, pair.Cert, 0o600) + require.NoErrorf(t, err, "could not save %s certificate", name) + + keyPath := filepath.Join(dest, name+"_key.pem") + err = os.WriteFile(keyPath, pair.Key, 0o600) + require.NoErrorf(t, err, "could not save %s certificate key", name) + + return certutil.Pair{ + Cert: []byte(certPath), + Key: []byte(keyPath), + } +} + func bytesToTMPFile(b []byte) (string, error) { f, err := os.CreateTemp("", "prefix") if err != nil {