From b031afc31bd67a72f04d9a9bbab62c84c2126d11 Mon Sep 17 00:00:00 2001 From: Anderson Queiroz Date: Tue, 15 Oct 2024 19:38:57 +0200 Subject: [PATCH] Decrypt client TLS certificate key for Elastic Defend (#5542) * decrypt client mTLS certificate key for Elastic Defend It adds EndpointTLSComponentModifier which will check if the client certificate key is encrypted, if so, it'll decrypt the key and pass it decrypted to endpoint (Elastic Defend) * add cache and tests * fix cache miss test * fix linter * update elastic-agent-libs * better comments * fix comment * mage notice * update elastic-agent-libs * debug test * fix test * Revert "debug test" This reverts commit b04b42c3b7ddde2f6760cd481ad947dc9558eed0. * make cache key from all paths (cherry picked from commit 1c041a29bfde297be5c29e89c7da5df3bd3cc8b2) --- ...ected-client-certificate-key-for-mTLS.yaml | 32 + internal/pkg/agent/application/application.go | 4 +- .../endpoint_component_modifier.go | 265 ++++++++ .../endpoint_component_modifier_test.go | 612 ++++++++++++++++++ .../endpoint_signed_component_modifier.go | 54 -- ...endpoint_signed_component_modifier_test.go | 137 ---- .../integration/metrics_monitoring_test.go | 35 +- 7 files changed, 940 insertions(+), 199 deletions(-) create mode 100644 changelog/fragments/1726735182-Elastic-Defend-accepts-passphrase-protected-client-certificate-key-for-mTLS.yaml create mode 100644 internal/pkg/agent/application/endpoint_component_modifier.go create mode 100644 internal/pkg/agent/application/endpoint_component_modifier_test.go delete mode 100644 internal/pkg/agent/application/endpoint_signed_component_modifier.go delete mode 100644 internal/pkg/agent/application/endpoint_signed_component_modifier_test.go diff --git a/changelog/fragments/1726735182-Elastic-Defend-accepts-passphrase-protected-client-certificate-key-for-mTLS.yaml b/changelog/fragments/1726735182-Elastic-Defend-accepts-passphrase-protected-client-certificate-key-for-mTLS.yaml new file mode 100644 index 00000000000..36b7579892c --- /dev/null +++ b/changelog/fragments/1726735182-Elastic-Defend-accepts-passphrase-protected-client-certificate-key-for-mTLS.yaml @@ -0,0 +1,32 @@ +# 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: Elastic Defend accepts passphrase protected client certificate key for mTLS + +# 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: + +# 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/elastic/elastic-agent/issues/5490 diff --git a/internal/pkg/agent/application/application.go b/internal/pkg/agent/application/application.go index c9d50e4dd3a..eb7fbe1ab3f 100644 --- a/internal/pkg/agent/application/application.go +++ b/internal/pkg/agent/application/application.go @@ -172,9 +172,11 @@ func New( log.Info("Parsed configuration and determined agent is managed by Fleet") composableManaged = true - compModifiers = append(compModifiers, FleetServerComponentModifier(cfg.Fleet.Server), + compModifiers = append(compModifiers, + FleetServerComponentModifier(cfg.Fleet.Server), InjectFleetConfigComponentModifier(cfg.Fleet, agentInfo), EndpointSignedComponentModifier(), + EndpointTLSComponentModifier(log), InjectProxyEndpointModifier(), ) diff --git a/internal/pkg/agent/application/endpoint_component_modifier.go b/internal/pkg/agent/application/endpoint_component_modifier.go new file mode 100644 index 00000000000..bc9b4b1db07 --- /dev/null +++ b/internal/pkg/agent/application/endpoint_component_modifier.go @@ -0,0 +1,265 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package application + +import ( + "encoding/pem" + "errors" + "fmt" + "os" + "sync" + + "github.com/elastic/elastic-agent-client/v7/pkg/client" + "github.com/elastic/elastic-agent-libs/transport/tlscommon" + "github.com/elastic/elastic-agent/internal/pkg/agent/application/coordinator" + "github.com/elastic/elastic-agent/pkg/component" + "github.com/elastic/elastic-agent/pkg/core/logger" +) + +// tlsCache is used to cache the decrypted client certificate and key. +// In environments with a high rate of config changes, such as in Kubernetes +// using autodiscover, loading files and decrypting them might have a +// non-negligible overhead. +type tlsCache struct { + mu *sync.Mutex + + CacheKey string + + Certificate string + Key string +} + +func (tlsCache) MakeKey(keyPassPath, certPath, keyPath string) string { + return keyPassPath + certPath + keyPath +} + +// EndpointSignedComponentModifier copies "signed" properties to the top level "signed" for the endpoint input. +// Enpoint team want to be able to validate the signature and parse the signed configuration (not trust the agent). +// Endpoint uses uninstall_token_hash in order to verify uninstall command token +// and signing_key in order validate the action signature. +// Example: +// +// { +// .... +// "signed": { +// "data": "eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", +// "signature": "MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E=" +// }, +// "revision": 1, +// "type": "endpoint" +// } +func EndpointSignedComponentModifier() coordinator.ComponentsModifier { + return func(comps []component.Component, cfg map[string]interface{}) ([]component.Component, error) { + const signedKey = "signed" + + compIdx, unitIdx, ok := findEndpointUnit(comps, client.UnitTypeInput) + if !ok { + return comps, nil + } + + unit := comps[compIdx].Units[unitIdx] + unitCfgMap := unit.Config.Source.AsMap() + if signed, ok := cfg[signedKey]; ok { + unitCfgMap[signedKey] = signed + } + + unitCfg, err := component.ExpectedConfig(unitCfgMap) + if err != nil { + return nil, err + } + + unit.Config = unitCfg + comps[compIdx].Units[unitIdx] = unit + + return comps, nil + } +} + +// EndpointTLSComponentModifier decrypts the client TLS certificate key if it's +// passphrase-protected. It replaces the content of 'fleet.ssl.key' +// and 'certificate' with theirs decrypted version and removes +// 'key_passphrase_path'. +// It does so, ONLY for the client TLS configuration for mTLS used with +// fleet-server. +func EndpointTLSComponentModifier(log *logger.Logger) coordinator.ComponentsModifier { + return newEndpointTLSComponentModifier(log, &tlsCache{mu: &sync.Mutex{}}) +} + +func newEndpointTLSComponentModifier(log *logger.Logger, cache *tlsCache) func(comps []component.Component, cfg map[string]interface{}) ([]component.Component, error) { + return func(comps []component.Component, cfg map[string]interface{}) ([]component.Component, error) { + compIdx, unitIdx, ok := findEndpointUnit(comps, client.UnitTypeInput) + if !ok { + // endpoint not present, nothing to do + return comps, nil + } + + unit := comps[compIdx].Units[unitIdx] + unitCfgMap := unit.Config.Source.AsMap() + + // ensure the following config exists: + // fleet.ssl: + // key_passphrase_path + // certificate + // key + fleetNode, ok := unitCfgMap["fleet"] + if !ok { + // if 'fleet' isn't, present nothing to do + return comps, nil + } + fleetMap, ok := fleetNode.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("EndpointTLSComponentModifier: 'fleet' node isn't a map, it is: %T", fleetNode) + } + + sslNode, ok := fleetMap["ssl"] + if !ok { + // 'ssl' node not present isn't an issue + return comps, nil + } + sslMap, ok := sslNode.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("EndpointTLSComponentModifier: 'ssl' node isn't a map, it is: %T", sslNode) + } + + keyPassPathI, ok := sslMap["key_passphrase_path"] + if !ok { + // if no key_passphrase_path, nothing to decrypt + return comps, nil + } + keyPassPath, ok := keyPassPathI.(string) + if !ok { + return nil, errors.New("EndpointTLSComponentModifier: 'key_passphrase_path' isn't a string") + } + if keyPassPath == "" { + // key_passphrase_path shouldn't be empty, but if it's, nothing to decrypt + return comps, nil + } + + keyI, ok := sslMap["key"] + if !ok { + // if there is a key_passphrase_path, the key must be present + return nil, errors.New("EndpointTLSComponentModifier: 'key_passphrase_path' present, but 'key' isn't present") + } + keyPath, ok := keyI.(string) + if !ok { + return nil, fmt.Errorf("EndpointTLSComponentModifier: 'key' isn't a string, it's %T", keyI) + } + + certI, ok := sslMap["certificate"] + if !ok { + // if there is a key_passphrase_path, the certificate must be present + return nil, errors.New("EndpointTLSComponentModifier: 'key_passphrase_path' present, but 'certificate' isn't present") + } + certPath, ok := certI.(string) + if !ok { + return nil, errors.New("EndpointTLSComponentModifier: 'certificate' isn't a string") + } + + // all TLS config exists and the certificate key is passphrase protected, + // now load and decrypt the key. + cert, key, err := loadCertificatesWithCache(log, cache, keyPassPath, certPath, keyPath) + if err != nil { + return nil, err + } + + // remove 'key_passphrase_path' as the certificate key isn't encrypted + // anymore. + delete(sslMap, "key_passphrase_path") + + // update the certificate and its key with their decrypted version. + sslMap["certificate"] = cert + sslMap["key"] = key + + unitCfg, err := component.ExpectedConfig(unitCfgMap) + if err != nil { + return nil, fmt.Errorf("EndpointTLSComponentModifier: could not covert modified config to expected config: %w", err) + } + + unit.Config = unitCfg + comps[compIdx].Units[unitIdx] = unit + + return comps, nil + } +} + +func loadCertificatesWithCache(log *logger.Logger, cache *tlsCache, keyPassPath string, certPath string, keyPath string) (string, string, error) { + cache.mu.Lock() + defer cache.mu.Unlock() + + cacheKey := cache.MakeKey(keyPassPath, certPath, keyPath) + + // cache hit + if cache.CacheKey == cacheKey { + return cache.Certificate, cache.Key, nil + } + + cert, key, err := loadCertificates(log, keyPassPath, certPath, keyPath) + if err != nil { + return "", "", err + } + + cache.CacheKey = cacheKey + cache.Certificate = cert + cache.Key = key + + return cert, key, nil +} + +func loadCertificates(log *logger.Logger, keyPassPathStr string, certStr string, keyStr string) (string, string, error) { + pass, err := os.ReadFile(keyPassPathStr) + if err != nil { + return "", "", fmt.Errorf("EndpointTLSComponentModifier: failed to read client certificate passphrase file: %w", err) + } + + // we don't really support encrypted certificates, but it's how + // tlscommon.LoadCertificate does. Thus, let's keep the same behaviour. + // Also, tlscommon.LoadCertificate 'loses' the type of the private key. + // It stores they private key as an interface and there is no way to + // retrieve the type os the private key without a type assertion. + // Therefore, instead of manually checking the type, which would mean + // to check for all supported private key types and keep it up to date, + // better to load the certificate and its key directly from the PEM file. + cert, err := tlscommon.ReadPEMFile(log, + certStr, string(pass)) + if err != nil { + return "", "", fmt.Errorf("EndpointTLSComponentModifier: unable to load TLS certificate: %w", err) + } + key, err := tlscommon.ReadPEMFile(log, + keyStr, + string(pass)) + if err != nil { + return "", "", fmt.Errorf("EndpointTLSComponentModifier: unable to load TLS certificate key: %w", err) + } + + // tlscommon.ReadPEMFile only removes the 'DEK-Info' header, not the + // 'Proc-Type', so remove it now. Create a pem.Block to avoid editing + // the PEM data manually: + keyBlock, _ := pem.Decode(key) + delete(keyBlock.Headers, "Proc-Type") + key = pem.EncodeToMemory(keyBlock) + + return string(cert), string(key), nil +} + +// findEndpointUnit finds the endpoint component and its unit of type 'unitType'. +// It returns the component and unit index and true if found, if not, it returns +// 0, 0, false. +func findEndpointUnit(comps []component.Component, unitType client.UnitType) (int, int, bool) { + // find the endpoint component + for compIdx, comp := range comps { + if comp.InputSpec != nil && comp.InputSpec.InputType != endpoint { + continue + } + + for unitIdx, unit := range comp.Units { + if unit.Type != unitType { + continue + } + + return compIdx, unitIdx, true + } + } + return 0, 0, false +} diff --git a/internal/pkg/agent/application/endpoint_component_modifier_test.go b/internal/pkg/agent/application/endpoint_component_modifier_test.go new file mode 100644 index 00000000000..424b2709f52 --- /dev/null +++ b/internal/pkg/agent/application/endpoint_component_modifier_test.go @@ -0,0 +1,612 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package application + +import ( + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/elastic/elastic-agent-client/v7/pkg/client" + "github.com/elastic/elastic-agent-client/v7/pkg/proto" + "github.com/elastic/elastic-agent-libs/testing/certutil" + "github.com/elastic/elastic-agent/internal/pkg/agent/application/coordinator" + "github.com/elastic/elastic-agent/pkg/core/logger/loggertest" + + "github.com/elastic/elastic-agent/pkg/component" + + "google.golang.org/protobuf/types/known/structpb" +) + +func TestEndpointComponentModifier(t *testing.T) { + log, obs := loggertest.New("TestEndpointSignedComponentModifier") + defer func() { + if !t.Failed() { + return + } + + loggertest.PrintObservedLogs(obs.TakeAll(), t.Log) + }() + + pair, certPath, certKeyPath, certKeyPassPath := prepareEncTLSCertificates(t) + + tests := map[string][]struct { + name string + compModifier coordinator.ComponentsModifier + comps []component.Component + cfg map[string]interface{} + wantComps []component.Component + wantErr func(*testing.T, error) + }{ + "EndpointSignedComponentModifier": { + { + name: "nil", + compModifier: EndpointSignedComponentModifier(), + }, + { + name: "non endpoint", + compModifier: EndpointSignedComponentModifier(), + comps: []component.Component{ + { + ID: "asdfasd", + InputSpec: &component.InputRuntimeSpec{ + InputType: "osquery", + }, + Units: []component.Unit{ + { + ID: "34534", + Type: client.UnitTypeInput, + }, + }, + }, + }, + wantComps: []component.Component{ + { + ID: "asdfasd", + InputSpec: &component.InputRuntimeSpec{ + InputType: "osquery", + }, + Units: []component.Unit{ + { + ID: "34534", + Type: client.UnitTypeInput, + }, + }, + }, + }, + }, + { + name: "endpoint", + compModifier: EndpointSignedComponentModifier(), + comps: []component.Component{ + { + ID: "asdfasd", + InputSpec: &component.InputRuntimeSpec{ + InputType: "endpoint", + }, + Units: []component.Unit{ + { + ID: "34534", + Type: client.UnitTypeInput, + Config: &proto.UnitExpectedConfig{ + Type: "endpoint", + Source: &structpb.Struct{}, + }, + }, + }, + }, + }, + cfg: map[string]interface{}{ + "signed": map[string]interface{}{ + "data": "eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", + "signature": "MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E=", + }, + }, + wantComps: []component.Component{ + { + ID: "asdfasd", + InputSpec: &component.InputRuntimeSpec{ + InputType: "endpoint", + }, + Units: []component.Unit{ + { + ID: "34534", + Type: client.UnitTypeInput, + Config: &proto.UnitExpectedConfig{ + Source: func() *structpb.Struct { + var source structpb.Struct + err := source.UnmarshalJSON([]byte(`{"signed":{"data":"eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", "signature":"MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E="}}`)) + require.NoError(t, err, "could not create want component source config") + return &source + }(), + }, + }, + }, + }, + }, + }, + }, + "EndpointTLSComponentModifier": { + { + name: "nil", + compModifier: EndpointSignedComponentModifier(), + }, + { + name: "non endpoint", + compModifier: EndpointSignedComponentModifier(), + comps: makeComponent(t, "{}"), + wantComps: makeComponent(t, "{}"), + }, + + { + name: "endpoint-no-fleet", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: makeComponent(t, `{}`), + }, + { + name: "endpoint-no-fleet-wrong-type", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{"fleet": 42}`), + cfg: map[string]interface{}{ + "fleet": 1, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'fleet' node isn't a map") + }, + }, + { + name: "endpoint-no-fleet.ssl", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{"fleet": {}}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: makeComponent(t, `{"fleet": {}}`), + }, + { + name: "endpoint-wrong-fleet.ssl", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{"fleet": {"ssl": 42}}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'ssl' node isn't a map") + }, + }, + { + name: "endpoint-wrong-fleet.ssl.key_passphrase_path", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, ` + {"fleet": {"ssl": + {"key_passphrase_path": 42}}}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'key_passphrase_path' isn't a string") + }, + }, + { + name: "endpoint-wrong-fleet.ssl.key", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, ` +{"fleet": {"ssl": { + "key_passphrase_path": "/path/to/passphrase", + "key": 42}}}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'key' isn't a string") + }, + }, + { + name: "endpoint-wrong-fleet.ssl.certificate", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, ` + {"fleet": {"ssl": { + "key_passphrase_path": "/path/to/passphrase", + "key": "", + "certificate": 42}}}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'certificate' isn't a string") + }, + }, + + { + name: "endpoint-mTLS-passphrase", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q, + "key_passphrase_path": %q + } + } + }`, certPath, certKeyPath, certKeyPassPath)), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate": certPath, + "key": certKeyPath, + "key_passphrase_path": certKeyPassPath, + }, + }, + }, + wantComps: makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q + } + } + }`, pair.Cert, pair.Key)), + }, + { + name: "endpoint-mTLS-passphrase-no-key", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key_passphrase_path": %q + } + } + }`, certPath, certKeyPassPath)), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate": certPath, + "key_passphrase_path": certKeyPassPath, + }, + }, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'key' isn't present") + }, + }, + { + name: "endpoint-mTLS-passphrase-no-certificate", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{ + "fleet": { + "ssl": { + "key": "/path/to/key", + "key_passphrase_path": "/path/to/key_passphrase_path" + } + } + }`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "key": "/path/to/cert", + "key_passphrase_path": "/path/to/key_passphrase_path", + }, + }, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'certificate' isn't present") + }, + }, + { + name: "endpoint-mTLS-no-passphrase", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{ + "fleet": { + "ssl": { + "certificate": "/path/to/cert", + "key": "/path/to/key" + } + } + }`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate": "/path/to/cert", + "key": "/path/to/key", + }, + }, + }, + wantComps: makeComponent(t, `{ + "fleet": { + "ssl": { + "certificate": "/path/to/cert", + "key": "/path/to/key" + } + } + }`), + }, + { + name: "endpoint-mTLS-empty-passphrase", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{ + "fleet": { + "ssl": { + "key_passphrase_path": "", + "certificate": "/path/to/cert", + "key": "/path/to/key" + } + } + }`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "key_passphrase_path": "", + "certificate": "/path/to/cert", + "key": "/path/to/key", + }, + }, + }, + wantComps: makeComponent(t, `{ + "fleet": { + "ssl": { + "key_passphrase_path": "", + "certificate": "/path/to/cert", + "key": "/path/to/key" + } + } + }`), + }, + { + name: "endpoint-TLS", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{ + "fleet": { + "ssl": { + "certificate_authorities": ["/path/to/ca1", "/path/to/ca2"] + } + } + }`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate_authorities": []string{"/path/to/ca1", "/path/to/ca2"}, + }, + }, + }, + wantComps: makeComponent(t, `{ + "fleet": { + "ssl": { + "certificate_authorities": ["/path/to/ca1", "/path/to/ca2"] + } + } + }`), + }, + }, + } + + for name, tcs := range tests { + t.Run(name, func(t *testing.T) { + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + comps, err := tc.compModifier(tc.comps, tc.cfg) + + if tc.wantErr != nil { + tc.wantErr(t, err) + } else { + assert.NoError(t, err) + } + + // Cumbersome comparison of the source config encoded in protobuf, + // cmp panics protobufs comparison otherwise. + compareComponents(t, comps, tc.wantComps) + }) + } + }) + } +} + +func compareComponents(t *testing.T, got, want []component.Component) { + if len(want) > 0 && + len(want[0].Units) > 0 && + got[0].Units[0].Config != nil && + got[0].Units[0].Config.Source != nil { + + unitCgf := got[0].Units[0].Config.Source.AsMap() + wantUnitCfg := want[0].Units[0].Config.Source.AsMap() + + assert.Equal(t, wantUnitCfg, unitCgf, "unit config do not match") + } +} + +func TestEndpointTLSComponentModifier_cache_miss(t *testing.T) { + log, obs := loggertest.New("TestEndpointSignedComponentModifier") + defer func() { + if !t.Failed() { + return + } + + loggertest.PrintObservedLogs(obs.TakeAll(), t.Log) + }() + + cache := tlsCache{ + mu: &sync.Mutex{}, + + CacheKey: "/old-cache-key", + Certificate: "cached certificate", + Key: "cached key", + } + pair, certPath, certKeyPath, certKeyPassPath := prepareEncTLSCertificates(t) + cackeKey := cache.MakeKey(certKeyPassPath, certPath, certKeyPath) + + comps := makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q, + "key_passphrase_path": %q + } + } + }`, certPath, certKeyPath, certKeyPassPath)) + cfg := map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate": certPath, + "key": certKeyPath, + "key_passphrase_path": certKeyPassPath, + }, + }, + } + wantComps := makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q + } + } + }`, pair.Cert, pair.Key)) + + modifier := newEndpointTLSComponentModifier(log, &cache) + got, err := modifier(comps, cfg) + require.NoError(t, err, "unexpected error") + + assert.Equal(t, cackeKey, cache.CacheKey, "passphrase path did not match") + assert.Equal(t, string(pair.Cert), cache.Certificate, "certificate did not match") + assert.Equal(t, string(pair.Key), cache.Key, "key did not match") + + compareComponents(t, got, wantComps) +} + +func TestEndpointTLSComponentModifier_cache_hit(t *testing.T) { + log, obs := loggertest.New("TestEndpointSignedComponentModifier") + defer func() { + if !t.Failed() { + return + } + + loggertest.PrintObservedLogs(obs.TakeAll(), t.Log) + }() + + certPath := "/path/to/cert" + certKeyPath := "/path/to/key" + certKeyPassPath := "/path/to/key_passphrase_path" //nolint:gosec // not a real key + + cache := tlsCache{ + mu: &sync.Mutex{}, + + Certificate: "cached certificate", + Key: "cached key", + } + cacheKey := cache.MakeKey(certKeyPassPath, certPath, certKeyPath) + cache.CacheKey = cacheKey + + comps := makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q, + "key_passphrase_path": %q + } + } + }`, certPath, certKeyPath, certKeyPassPath)) + cfg := map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate": cache.Certificate, + "key": cache.Key, + "key_passphrase_path": cache.CacheKey, + }, + }, + } + + wantComps := makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q + } + } + }`, cache.Certificate, cache.Key)) + + modifier := newEndpointTLSComponentModifier(log, &cache) + got, err := modifier(comps, cfg) + require.NoError(t, err, "unexpected error") + + assert.Equal(t, cacheKey, cache.CacheKey, "passphrase should not have changed") + compareComponents(t, got, wantComps) +} + +func prepareEncTLSCertificates(t *testing.T) (certutil.Pair, string, string, string) { + passphrase := "secure_passphrase" + _, _, pair, err := certutil.NewRootCA() + require.NoError(t, err, "could not create TLS certificate") + agentChildDERKey, _ := pem.Decode(pair.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(passphrase), + x509.PEMCipherAES128) + require.NoError(t, err, "failed encrypting agent child certificate key block") + + certKeyEnc := pem.EncodeToMemory(encPem) + + // save to disk + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "cert.pem") + certKeyPath := filepath.Join(tmpDir, "key.pem") + certKeyPassPath := filepath.Join(tmpDir, "key_pass.pem") + + err = os.WriteFile(certPath, pair.Cert, 0400) + require.NoError(t, err, "could write certificate key") + err = os.WriteFile(certKeyPath, certKeyEnc, 0400) + require.NoError(t, err, "could write certificate key") + err = os.WriteFile(certKeyPassPath, []byte(passphrase), 0400) + require.NoError(t, err, "could write certificate key passphrase") + + return pair, certPath, certKeyPath, certKeyPassPath +} + +func makeComponent(t *testing.T, sourceCfg string) []component.Component { + return []component.Component{ + { + ID: "ClientCertKey", + InputSpec: &component.InputRuntimeSpec{ + InputType: "endpoint", + }, + Units: []component.Unit{ + { + ID: "34534", + Type: client.UnitTypeInput, + Config: &proto.UnitExpectedConfig{ + Type: "endpoint", + Source: func() *structpb.Struct { + var source structpb.Struct + err := source.UnmarshalJSON([]byte(sourceCfg)) + require.NoError(t, err, "could not create component source config") + return &source + }(), + }, + }, + }, + }, + } +} diff --git a/internal/pkg/agent/application/endpoint_signed_component_modifier.go b/internal/pkg/agent/application/endpoint_signed_component_modifier.go deleted file mode 100644 index 39aac35604a..00000000000 --- a/internal/pkg/agent/application/endpoint_signed_component_modifier.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -// or more contributor license agreements. Licensed under the Elastic License 2.0; -// you may not use this file except in compliance with the Elastic License 2.0. - -package application - -import ( - "github.com/elastic/elastic-agent-client/v7/pkg/client" - "github.com/elastic/elastic-agent/internal/pkg/agent/application/coordinator" - "github.com/elastic/elastic-agent/pkg/component" -) - -// EndpointSignedComponentModifier copies "signed" properties to the top level "signed" for the endpoint input. -// Enpoint team want to be able to validate the signature and parse the signed configuration (not trust the agent). -// Endpoint uses uninstall_token_hash in order to verify uninstall command token -// and signing_key in order validate the action signature. -// Example: -// -// { -// .... -// "signed": { -// "data": "eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", -// "signature": "MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E=" -// }, -// "revision": 1, -// "type": "endpoint" -// } -func EndpointSignedComponentModifier() coordinator.ComponentsModifier { - return func(comps []component.Component, cfg map[string]interface{}) ([]component.Component, error) { - const signedKey = "signed" - for i, comp := range comps { - if comp.InputSpec != nil && (comp.InputSpec.InputType == endpoint) { - for j, unit := range comp.Units { - if unit.Type == client.UnitTypeInput && (unit.Config.Type == endpoint) { - unitCfgMap := unit.Config.Source.AsMap() - if signed, ok := cfg[signedKey]; ok { - unitCfgMap[signedKey] = signed - } - - unitCfg, err := component.ExpectedConfig(unitCfgMap) - if err != nil { - return nil, err - } - - unit.Config = unitCfg - comp.Units[j] = unit - } - } - } - comps[i] = comp - } - return comps, nil - } -} diff --git a/internal/pkg/agent/application/endpoint_signed_component_modifier_test.go b/internal/pkg/agent/application/endpoint_signed_component_modifier_test.go deleted file mode 100644 index fcc16321d5c..00000000000 --- a/internal/pkg/agent/application/endpoint_signed_component_modifier_test.go +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -// or more contributor license agreements. Licensed under the Elastic License 2.0; -// you may not use this file except in compliance with the Elastic License 2.0. - -package application - -import ( - "testing" - - "github.com/elastic/elastic-agent-client/v7/pkg/client" - "github.com/elastic/elastic-agent-client/v7/pkg/proto" - - "github.com/elastic/elastic-agent/pkg/component" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestEndpointSignedComponentModifier(t *testing.T) { - compModifier := EndpointSignedComponentModifier() - - tests := []struct { - name string - comps []component.Component - cfg map[string]interface{} - wantComps []component.Component - wantErr error - }{ - { - name: "nil", - }, - { - name: "non endpoint", - comps: []component.Component{ - { - ID: "asdfasd", - InputSpec: &component.InputRuntimeSpec{ - InputType: "osquery", - }, - Units: []component.Unit{ - { - ID: "34534", - Type: client.UnitTypeInput, - }, - }, - }, - }, - wantComps: []component.Component{ - { - ID: "asdfasd", - InputSpec: &component.InputRuntimeSpec{ - InputType: "osquery", - }, - Units: []component.Unit{ - { - ID: "34534", - Type: client.UnitTypeInput, - }, - }, - }, - }, - }, - { - name: "endpoint", - comps: []component.Component{ - { - ID: "asdfasd", - InputSpec: &component.InputRuntimeSpec{ - InputType: "endpoint", - }, - Units: []component.Unit{ - { - ID: "34534", - Type: client.UnitTypeInput, - Config: &proto.UnitExpectedConfig{ - Type: "endpoint", - Source: &structpb.Struct{}, - }, - }, - }, - }, - }, - cfg: map[string]interface{}{ - "signed": map[string]interface{}{ - "data": "eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", - "signature": "MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E=", - }, - }, - wantComps: []component.Component{ - { - ID: "asdfasd", - InputSpec: &component.InputRuntimeSpec{ - InputType: "endpoint", - }, - Units: []component.Unit{ - { - ID: "34534", - Type: client.UnitTypeInput, - Config: &proto.UnitExpectedConfig{ - Source: func() *structpb.Struct { - var source structpb.Struct - err := source.UnmarshalJSON([]byte(`{"signed":{"data":"eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", "signature":"MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E="}}`)) - if err != nil { - t.Fatal(err) - } - return &source - }(), - }, - }, - }, - }, - }, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - comps, err := compModifier(tc.comps, tc.cfg) - - diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()) - if diff != "" { - t.Fatal(diff) - } - - // Cumbersome comparison of the source config encoded in protobuf, cmp panics protobufs comparison otherwise - if len(tc.wantComps) > 0 && len(tc.wantComps[0].Units) > 0 && comps[0].Units[0].Config != nil && comps[0].Units[0].Config.Source != nil { - m := comps[0].Units[0].Config.Source.AsMap() - wantM := tc.wantComps[0].Units[0].Config.Source.AsMap() - - diff = cmp.Diff(wantM, m) - if diff != "" { - t.Fatal(diff) - } - } - }) - } -} diff --git a/testing/integration/metrics_monitoring_test.go b/testing/integration/metrics_monitoring_test.go index 08eda658ce6..b4d885a19cb 100644 --- a/testing/integration/metrics_monitoring_test.go +++ b/testing/integration/metrics_monitoring_test.go @@ -8,6 +8,7 @@ package integration import ( "context" + "encoding/json" "fmt" "testing" "time" @@ -80,11 +81,14 @@ func (runner *MetricsRunner) SetupSuite() { } func (runner *MetricsRunner) TestBeatsMetrics() { + t := runner.T() + UnitOutputName := "default" ctx, cancel := context.WithTimeout(context.Background(), time.Minute*20) defer cancel() + agentStatus, err := runner.agentFixture.ExecStatus(ctx) - require.NoError(runner.T(), err) + require.NoError(t, err, "could not to get agent status") componentIds := []string{ fmt.Sprintf("system/metrics-%s", UnitOutputName), @@ -95,19 +99,36 @@ func (runner *MetricsRunner) TestBeatsMetrics() { "filestream-monitoring", } - require.Eventually(runner.T(), func() bool { + now := time.Now() + var query map[string]any + defer func() { + if t.Failed() { + bs, err := json.Marshal(query) + if err != nil { + // nothing we can do, just log the map + t.Errorf("executed at %s: %v", + now.Format(time.RFC3339Nano), query) + return + } + t.Errorf("executed at %s: query: %s", + now.Format(time.RFC3339Nano), string(bs)) + } + }() + + t.Logf("starting to ES for metrics at %s", now.Format(time.RFC3339Nano)) + require.Eventually(t, func() bool { for _, cid := range componentIds { - query := genESQuery(agentStatus.Info.ID, cid) + query = genESQuery(agentStatus.Info.ID, cid) + now = time.Now() res, err := estools.PerformQueryForRawQuery(ctx, query, "metrics-elastic_agent*", runner.info.ESClient) - require.NoError(runner.T(), err) - runner.T().Logf("Fetched metrics for %s, got %d hits", cid, res.Hits.Total.Value) + require.NoError(t, err) + t.Logf("Fetched metrics for %s, got %d hits", cid, res.Hits.Total.Value) if res.Hits.Total.Value < 1 { return false } - } return true - }, time.Minute*10, time.Second*10, "could not fetch metrics for all known beats in default install: %v", componentIds) + }, time.Minute*10, time.Second*10, "could not fetch metrics for all known components in default install: %v", componentIds) } func genESQuery(agentID string, componentID string) map[string]interface{} {