diff --git a/conf/agent/agent_full.conf b/conf/agent/agent_full.conf index bed26aeacb..e5b21a3047 100644 --- a/conf/agent/agent_full.conf +++ b/conf/agent/agent_full.conf @@ -204,20 +204,6 @@ plugins { } } - # NodeAttestor "k8s_sat" (deprecated): A node attestor which attests agent identity - # using a Kubernetes Service Account token. - NodeAttestor "k8s_sat" { - plugin_data { - # cluster: Name of the cluster. It must correspond to a cluster - # configured in the server plugin. - # cluster = "" - - # token_path: Path to the service account token on disk. - # Default: /var/run/secrets/kubernetes.io/serviceaccount/token. - # token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" - } - } - # NodeAttestor "sshpop": A node attestor which attests agent identity # using an existing ssh certificate. NodeAttestor "sshpop" { diff --git a/conf/server/server_full.conf b/conf/server/server_full.conf index 796745d348..afb7cecf1e 100644 --- a/conf/server/server_full.conf +++ b/conf/server/server_full.conf @@ -533,47 +533,6 @@ plugins { # } # } - # NodeAttestor "k8s_sat" (deprecated): A node attestor which attests agent identity - # using a Kubernetes Service Account token. - # NodeAttestor "k8s_sat" { - # plugin_data { - # # clusters: A map of clusters, keyed by an arbitrary ID, that are - # # authorized for attestation. - # # clusters = { - # # "" = { - # # service_account_allow_list: A list of service account names, - # # qualified by namespace (for example, "default:blog" or - # # "production:web") to allow for node attestation. Attestation - # # will be rejected for tokens bound to service accounts that - # # aren't in the allow list. - # # service_account_allow_list = [] - - # # use_token_review_api_validation: Specifies how the service - # # account token is validated. If false, validation is done - # # locally using the provided key. If true, validation is done - # # using token review API. Default: false. - # # use_token_review_api_validation = false - - # # service_account_key_file: It is only used if - # # use_token_review_api_validation is set to false. Path on disk - # # to a PEM encoded file containing public keys used in - # # validating tokens for that cluster. RSA and ECDSA keys are - # # supported. For RSA, X509 certificates, PKCS1, and PKIX encoded - # # public keys are accepted. For ECDSA, X509 certificates, and - # # PKIX encoded public keys are accepted. - # # service_account_key_file = "" - - # # kube_config_file: It is only used if - # # use_token_review_api_validation is set to true. Path to a k8s - # # configuration file for API Server authentication. A kubernetes - # # configuration file must be specified if SPIRE server runs - # # outside of the k8s cluster. If empty, SPIRE server is assumed - # # to be running inside the cluster and in-cluster configuration - # # is used. Default: "". - # # kube_config_file = "" - # } - # } - # NodeAttestor "sshpop": A node attestor which attests agent identity # using an existing ssh certificate. # NodeAttestor "sshpop" { diff --git a/doc/plugin_agent_nodeattestor_k8s_sat.md b/doc/plugin_agent_nodeattestor_k8s_sat.md deleted file mode 100644 index 1dbd2b7774..0000000000 --- a/doc/plugin_agent_nodeattestor_k8s_sat.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent plugin: NodeAttestor "k8s_sat" (deprecated) - -**This plugin has been deprecated in favor of the ["k8s_psat"](plugin_agent_nodeattestor_k8s_psat.md) plugin and will be removed in a future release.** - -*Must be used in conjunction with the server-side k8s_sat plugin* - -The `k8s_sat` plugin attests nodes running in inside of Kubernetes. The agent -reads and provides the signed service account token to the server. - -*Note: If your cluster supports [Service Account Token Volume Projection](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#service-account-token-volume-projection) -you should instead consider using the `k8s_psat` attestor due to the [security considerations](#security-considerations) below.* - -The server-side `k8s_sat` plugin generates a one-time UUID and generates a SPIFFE ID with the form: - -```xml -spiffe:///spire/agent/k8s_sat// -``` - -The main configuration accepts the following values: - -| Configuration | Description | Default | -|---------------|---------------------------------------------------------------------------------------|-------------------------------------------------------| -| `cluster` | Name of the cluster. It must correspond to a cluster configured in the server plugin. | -| `token_path` | Path to the service account token on disk | "/var/run/secrets/kubernetes.io/serviceaccount/token" | - -The token path defaults to the default location Kubernetes uses to place the token and should not need to be overridden in most cases. - -A sample configuration with the default token path: - -```hcl - NodeAttestor "k8s_sat" { - plugin_data { - cluster = "MyCluster" - } - } -``` - -## Security Considerations - -At this time, the service account token does not contain claims that could be -used to strongly identify the node/daemonset/pod running the agent. This means -that any container running in an allowed service account can masquerade as -an agent, giving it access to any identity the agent is capable of issuing. It -is **STRONGLY** recommended that agents run under a dedicated service account. - -It should be noted that due to the fact that SPIRE can't positively -identify a node using this method, it is not possible to directly authorize -identities for a distinct node or sets of nodes. Instead, this must be -accomplished indirectly using a service account and deployment that -leverages node affinity or node selectors. diff --git a/doc/plugin_server_nodeattestor_k8s_sat.md b/doc/plugin_server_nodeattestor_k8s_sat.md deleted file mode 100644 index 2935e6224b..0000000000 --- a/doc/plugin_server_nodeattestor_k8s_sat.md +++ /dev/null @@ -1,105 +0,0 @@ -# Server plugin: NodeAttestor "k8s_sat" (deprecated) - -**This plugin has been deprecated in favor of the ["k8s_psat"](plugin_server_nodeattestor_k8s_psat.md) plugin and will be removed in a future release.** - -*Must be used in conjunction with the agent-side k8s_sat plugin* - -The `k8s_sat` plugin attests nodes running in inside of Kubernetes. The server validates the signed service -account token provided by the agent. This validation can be done in two different ways depending on the value -of the `use_token_review_api_validation` flag: - -+ If this value is set to `false` (default behavior), the attestor validates the token locally using the key provided in `service_account_key_file`. -+ If this value is set to `true`, the validation is performed using the Kubernetes [Token Review API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/). - -*Note: If your cluster supports [Service Account Token Volume Projection](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#service-account-token-volume-projection) -you should instead consider using the `k8s_psat` attestor due to the [security considerations](#security-considerations) below.* - -The server uses a one-time UUID provided by the agent to generate a SPIFFE ID with the form: - -```xml -spiffe:///spire/agent/k8s_sat// -``` - -The server does not need to be running in Kubernetes in order to perform node -attestation. In fact, the plugin can be configured to attest nodes running in -multiple clusters. - -The main configuration accepts the following values: - -| Configuration | Description | Default | -|---------------|-----------------------------------------------------------------------------------|---------| -| `clusters` | A map of clusters, keyed by an arbitrary ID, that are authorized for attestation. | | - -Each cluster in the main configuration requires the following configuration: - -| Configuration | Description | Default | -|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| -| `service_account_allow_list` | A list of service account names, qualified by namespace (for example, "default:blog" or "production:web") to allow for node attestation. Attestation will be rejected for tokens bound to service accounts that aren't in the allow list. | | -| `use_token_review_api_validation` | Specifies how the service account token is validated. If false, validation is done locally using the provided key. If true, validation is done using token review API. | false | -| `service_account_key_file` | It is only used if `use_token_review_api_validation` is set to `false`. Path on disk to a PEM encoded file containing public keys used in validating tokens for that cluster. RSA and ECDSA keys are supported. For RSA, X509 certificates, PKCS1, and PKIX encoded public keys are accepted. For ECDSA, X509 certificates, and PKIX encoded public keys are accepted. | | -| `kube_config_file` | It is only used if `use_token_review_api_validation` is set to `true`. Path to a k8s configuration file for API Server authentication. A Kubernetes configuration file must be specified if SPIRE server runs outside of the k8s cluster. If empty, SPIRE server is assumed to be running inside the cluster and in-cluster configuration is used. | "" | - -A sample configuration for SPIRE server running inside or outside a Kubernetes cluster and validating the service account token with a key file located at `"/run/k8s-certs/sa.pub"`: - -```hcl - NodeAttestor "k8s_sat" { - plugin_data { - clusters = { - "MyCluster" = { - service_account_allow_list = ["production:spire-agent"] - service_account_key_file = "/run/k8s-certs/sa.pub" - } - } - } -``` - -A sample configuration for SPIRE server running inside of a Kubernetes cluster and validating the service account token with the kubernetes token review API: - -```hcl - NodeAttestor "k8s_sat" { - plugin_data { - clusters = { - "MyCluster" = { - service_account_allow_list = ["production:spire-agent"] - use_token_review_api_validation = true - } - } - } -``` - -A sample configuration for SPIRE server running outside of a Kubernetes cluster and validating the service account token with the kubernetes token review API: - -```hcl - NodeAttestor "k8s_sat" { - plugin_data { - clusters = { - "MyCluster" = { - service_account_allow_list = ["production:spire-agent"] - use_token_review_api_validation = true - kube_config_file = "path/to/kubeconfig/file" - } - } - } -``` - -In addition, this plugin generates the following selectors: - -| Selector | Example | Description | -|--------------------|--------------------------------|---------------------------------------------------------------------------------| -| `k8s_sat:cluster` | `k8s_sat:cluster:MyCluster` | Name of the cluster (from the plugin config) used to verify the token signature | -| `k8s_sat:agent_ns` | `k8s_sat:agent_ns:production` | Namespace that the agent is running under | -| `k8s_sat:agent_sa` | `k8s_sat:agent_sa:spire-agent` | Service Account the agent is running under | - -## Security Considerations - -At this time, the service account token does not contain claims that could be -used to strongly identify the node/daemonset/pod running the agent. This means -that any container running in an allowed service account can masquerade as -an agent, giving it access to any identity the agent is capable of issuing. It -is **STRONGLY** recommended that agents run under a dedicated service account. - -It should be noted that due to the fact that SPIRE can't positively -identify a node using this method, it is not possible to directly authorize -identities for a distinct node or sets of nodes. Instead, this must be -accomplished indirectly using a service account and deployment that -leverages node affinity or node selectors. diff --git a/doc/spire_agent.md b/doc/spire_agent.md index 456bc44cce..ea93f2e5ad 100644 --- a/doc/spire_agent.md +++ b/doc/spire_agent.md @@ -21,7 +21,6 @@ This document is a configuration reference for SPIRE Agent. It includes informat | NodeAttestor | [azure_msi](/doc/plugin_agent_nodeattestor_azure_msi.md) | A node attestor which attests agent identity using an Azure MSI token | | NodeAttestor | [gcp_iit](/doc/plugin_agent_nodeattestor_gcp_iit.md) | A node attestor which attests agent identity using a GCP Instance Identity Token | | NodeAttestor | [join_token](/doc/plugin_agent_nodeattestor_jointoken.md) | A node attestor which uses a server-generated join token | -| NodeAttestor | [k8s_sat](/doc/plugin_agent_nodeattestor_k8s_sat.md) (deprecated) | A node attestor which attests agent identity using a Kubernetes Service Account token | | NodeAttestor | [k8s_psat](/doc/plugin_agent_nodeattestor_k8s_psat.md) | A node attestor which attests agent identity using a Kubernetes Projected Service Account token | | NodeAttestor | [sshpop](/doc/plugin_agent_nodeattestor_sshpop.md) | A node attestor which attests agent identity using an existing ssh certificate | | NodeAttestor | [x509pop](/doc/plugin_agent_nodeattestor_x509pop.md) | A node attestor which attests agent identity using an existing X.509 certificate | diff --git a/doc/spire_server.md b/doc/spire_server.md index f2651b121b..18cd4e037d 100644 --- a/doc/spire_server.md +++ b/doc/spire_server.md @@ -27,7 +27,6 @@ This document is a configuration reference for SPIRE Server. It includes informa | NodeAttestor | [azure_msi](/doc/plugin_server_nodeattestor_azure_msi.md) | A node attestor which attests agent identity using an Azure MSI token | | NodeAttestor | [gcp_iit](/doc/plugin_server_nodeattestor_gcp_iit.md) | A node attestor which attests agent identity using a GCP Instance Identity Token | | NodeAttestor | [join_token](/doc/plugin_server_nodeattestor_jointoken.md) | A node attestor which validates agents attesting with server-generated join tokens | -| NodeAttestor | [k8s_sat](/doc/plugin_server_nodeattestor_k8s_sat.md) (deprecated) | A node attestor which attests agent identity using a Kubernetes Service Account token | | NodeAttestor | [k8s_psat](/doc/plugin_server_nodeattestor_k8s_psat.md) | A node attestor which attests agent identity using a Kubernetes Projected Service Account token | | NodeAttestor | [sshpop](/doc/plugin_server_nodeattestor_sshpop.md) | A node attestor which attests agent identity using an existing ssh certificate | | NodeAttestor | [tpm_devid](/doc/plugin_server_nodeattestor_tpm_devid.md) | A node attestor which attests agent identity using a TPM that has been provisioned with a DevID certificate | diff --git a/pkg/agent/catalog/nodeattestor.go b/pkg/agent/catalog/nodeattestor.go index 5db0ca8d26..0649897147 100644 --- a/pkg/agent/catalog/nodeattestor.go +++ b/pkg/agent/catalog/nodeattestor.go @@ -8,7 +8,6 @@ import ( "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/httpchallenge" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/jointoken" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/k8spsat" - "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/k8ssat" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/sshpop" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/tpmdevid" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/x509pop" @@ -41,7 +40,6 @@ func (repo *nodeAttestorRepository) BuiltIns() []catalog.BuiltIn { httpchallenge.BuiltIn(), jointoken.BuiltIn(), k8spsat.BuiltIn(), - k8ssat.BuiltIn(), sshpop.BuiltIn(), tpmdevid.BuiltIn(), x509pop.BuiltIn(), diff --git a/pkg/agent/plugin/nodeattestor/k8ssat/sat.go b/pkg/agent/plugin/nodeattestor/k8ssat/sat.go deleted file mode 100644 index bce6fd91e6..0000000000 --- a/pkg/agent/plugin/nodeattestor/k8ssat/sat.go +++ /dev/null @@ -1,157 +0,0 @@ -package k8ssat - -import ( - "context" - "encoding/json" - "fmt" - "os" - "sync" - - "github.com/hashicorp/go-hclog" - "github.com/hashicorp/hcl" - nodeattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/agent/nodeattestor/v1" - configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" - "github.com/spiffe/spire/pkg/common/catalog" - "github.com/spiffe/spire/pkg/common/plugin/k8s" - "github.com/spiffe/spire/pkg/common/pluginconf" - "github.com/zeebo/errs" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -const ( - pluginName = "k8s_sat" - - defaultTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint: gosec // false positive -) - -func BuiltIn() catalog.BuiltIn { - return builtin(New()) -} - -func builtin(p *AttestorPlugin) catalog.BuiltIn { - return catalog.MakeBuiltIn(pluginName, - nodeattestorv1.NodeAttestorPluginServer(p), - configv1.ConfigServiceServer(p)) -} - -type AttestorConfig struct { - Cluster string `hcl:"cluster"` - TokenPath string `hcl:"token_path"` -} - -func buildConfig(coreConfig catalog.CoreConfig, hclText string, status *pluginconf.Status) *attestorConfig { - hclConfig := new(AttestorConfig) - if err := hcl.Decode(hclConfig, hclText); err != nil { - status.ReportErrorf("unable to decode configuration: %v", err) - return nil - } - - if hclConfig.Cluster == "" { - status.ReportError("configuration missing cluster") - } - - newConfig := &attestorConfig{ - cluster: hclConfig.Cluster, - tokenPath: hclConfig.TokenPath, - } - - if newConfig.tokenPath == "" { - newConfig.tokenPath = getDefaultTokenPath() - } - - return newConfig -} - -type attestorConfig struct { - cluster string - tokenPath string -} - -type AttestorPlugin struct { - nodeattestorv1.UnsafeNodeAttestorServer - configv1.UnsafeConfigServer - log hclog.Logger - - mu sync.RWMutex - config *attestorConfig -} - -func New() *AttestorPlugin { - return &AttestorPlugin{} -} - -// SetLogger sets a logger in the plugin. -func (p *AttestorPlugin) SetLogger(log hclog.Logger) { - p.log = log -} - -func (p *AttestorPlugin) AidAttestation(stream nodeattestorv1.NodeAttestor_AidAttestationServer) error { - config, err := p.getConfig() - if err != nil { - return err - } - - token, err := loadTokenFromFile(config.tokenPath) - if err != nil { - return status.Errorf(codes.InvalidArgument, "unable to load token from %s: %v", config.tokenPath, err) - } - - payload, err := json.Marshal(k8s.SATAttestationData{ - Cluster: config.cluster, - Token: token, - }) - if err != nil { - return status.Errorf(codes.Internal, "unable to marshal SAT token data: %v", err) - } - - return stream.Send(&nodeattestorv1.PayloadOrChallengeResponse{ - Data: &nodeattestorv1.PayloadOrChallengeResponse_Payload{ - Payload: payload, - }, - }) -} - -func (p *AttestorPlugin) Configure(_ context.Context, req *configv1.ConfigureRequest) (resp *configv1.ConfigureResponse, err error) { - p.log.Warn(fmt.Sprintf("The %q node attestor plugin has been deprecated in favor of the \"k8s_psat\" plugin and will be removed in a future release", pluginName)) - - newConfig, _, err := pluginconf.Build(req, buildConfig) - if err != nil { - return nil, err - } - - p.mu.Lock() - defer p.mu.Unlock() - p.config = newConfig - - return &configv1.ConfigureResponse{}, nil -} - -func (p *AttestorPlugin) Validate(_ context.Context, req *configv1.ValidateRequest) (resp *configv1.ValidateResponse, err error) { - _, notes, err := pluginconf.Build(req, buildConfig) - - return &configv1.ValidateResponse{ - Valid: err == nil, - Notes: notes, - }, nil -} - -func (p *AttestorPlugin) getConfig() (*attestorConfig, error) { - p.mu.RLock() - defer p.mu.RUnlock() - if p.config == nil { - return nil, status.Error(codes.FailedPrecondition, "not configured") - } - return p.config, nil -} - -func loadTokenFromFile(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", errs.Wrap(err) - } - if len(data) == 0 { - return "", errs.New("%q is empty", path) - } - return string(data), nil -} diff --git a/pkg/agent/plugin/nodeattestor/k8ssat/sat_posix.go b/pkg/agent/plugin/nodeattestor/k8ssat/sat_posix.go deleted file mode 100644 index adc6dc0ee7..0000000000 --- a/pkg/agent/plugin/nodeattestor/k8ssat/sat_posix.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows - -package k8ssat - -func getDefaultTokenPath() string { - return defaultTokenPath -} diff --git a/pkg/agent/plugin/nodeattestor/k8ssat/sat_posix_test.go b/pkg/agent/plugin/nodeattestor/k8ssat/sat_posix_test.go deleted file mode 100644 index 344bf3f63a..0000000000 --- a/pkg/agent/plugin/nodeattestor/k8ssat/sat_posix_test.go +++ /dev/null @@ -1,40 +0,0 @@ -//go:build !windows - -package k8ssat - -import ( - "testing" - - "github.com/spiffe/go-spiffe/v2/spiffeid" - "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor" - "github.com/spiffe/spire/pkg/common/catalog" - "github.com/spiffe/spire/test/plugintest" - "github.com/stretchr/testify/require" -) - -func TestConfigureDefaultToken(t *testing.T) { - p := New() - var err error - plugintest.Load(t, builtin(p), new(nodeattestor.V1), - plugintest.CaptureConfigureError(&err), - plugintest.CoreConfig(catalog.CoreConfig{ - TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), - }), - plugintest.Configure(`cluster = "production"`), - ) - require.NoError(t, err) - require.Equal(t, "/var/run/secrets/kubernetes.io/serviceaccount/token", p.config.tokenPath) - - plugintest.Load(t, builtin(p), new(nodeattestor.V1), - plugintest.CaptureConfigureError(&err), - plugintest.CoreConfig(catalog.CoreConfig{ - TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), - }), - plugintest.Configure(` - cluster = "production" - token_path = "/tmp/token"`), - ) - require.NoError(t, err) - - require.Equal(t, "/tmp/token", p.config.tokenPath) -} diff --git a/pkg/agent/plugin/nodeattestor/k8ssat/sat_test.go b/pkg/agent/plugin/nodeattestor/k8ssat/sat_test.go deleted file mode 100644 index 3ecb83171a..0000000000 --- a/pkg/agent/plugin/nodeattestor/k8ssat/sat_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package k8ssat - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/spiffe/go-spiffe/v2/spiffeid" - "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor" - nodeattestortest "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/test" - "github.com/spiffe/spire/pkg/common/catalog" - "github.com/spiffe/spire/test/plugintest" - "github.com/spiffe/spire/test/spiretest" - "google.golang.org/grpc/codes" -) - -var ( - streamBuilder = nodeattestortest.ServerStream(pluginName) -) - -func TestAttestorPlugin(t *testing.T) { - spiretest.Run(t, new(AttestorSuite)) -} - -type AttestorSuite struct { - spiretest.Suite - - dir string -} - -func (s *AttestorSuite) SetupTest() { - s.dir = s.TempDir() -} - -func (s *AttestorSuite) TestAttestNotConfigured() { - na := s.loadPlugin() - err := na.Attest(context.Background(), streamBuilder.Build()) - s.RequireGRPCStatus(err, codes.FailedPrecondition, "nodeattestor(k8s_sat): not configured") -} - -func (s *AttestorSuite) TestAttestNoToken() { - na := s.loadPluginWithTokenPath("example.org", s.joinPath("token")) - err := na.Attest(context.Background(), streamBuilder.Build()) - s.RequireGRPCStatusContains(err, codes.InvalidArgument, "nodeattestor(k8s_sat): unable to load token from") -} - -func (s *AttestorSuite) TestAttestEmptyToken() { - na := s.loadPluginWithTokenPath("example.org", s.writeValue("token", "")) - err := na.Attest(context.Background(), streamBuilder.Build()) - s.RequireGRPCStatusContains(err, codes.InvalidArgument, "nodeattestor(k8s_sat): unable to load token from") -} - -func (s *AttestorSuite) TestAttestSuccess() { - na := s.loadPluginWithTokenPath("example.org", s.writeValue("token", "TOKEN")) - - err := na.Attest(context.Background(), streamBuilder.ExpectAndBuild([]byte(`{"cluster":"production","token":"TOKEN"}`))) - s.Require().NoError(err) -} - -func (s *AttestorSuite) TestConfigure() { - var err error - - // malformed configuration - s.loadPlugin(plugintest.CaptureConfigureError(&err), - plugintest.CoreConfig(catalog.CoreConfig{ - TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), - }), - plugintest.Configure("malformed"), - ) - s.RequireGRPCStatusContains(err, codes.InvalidArgument, "unable to decode configuration") - - // missing cluster - s.loadPlugin(plugintest.CaptureConfigureError(&err), - plugintest.CoreConfig(catalog.CoreConfig{ - TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), - }), - plugintest.Configure(""), - ) - s.RequireGRPCStatus(err, codes.InvalidArgument, "configuration missing cluster") - - // success - s.loadPlugin(plugintest.CaptureConfigureError(&err), - plugintest.CoreConfig(catalog.CoreConfig{ - TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), - }), - plugintest.Configure(`cluster = "production"`), - ) - s.Require().NoError(err) -} - -func (s *AttestorSuite) loadPluginWithTokenPath(trustDomain string, tokenPath string) nodeattestor.NodeAttestor { - return s.loadPlugin( - plugintest.CoreConfig(catalog.CoreConfig{ - TrustDomain: spiffeid.RequireTrustDomainFromString(trustDomain), - }), - plugintest.Configuref(` - cluster = "production" - token_path = %q`, tokenPath), - ) -} - -func (s *AttestorSuite) loadPlugin(options ...plugintest.Option) nodeattestor.NodeAttestor { - na := new(nodeattestor.V1) - plugintest.Load(s.T(), BuiltIn(), na, options...) - return na -} - -func (s *AttestorSuite) joinPath(path string) string { - return filepath.Join(s.dir, path) -} - -func (s *AttestorSuite) writeValue(path, data string) string { - valuePath := s.joinPath(path) - err := os.MkdirAll(filepath.Dir(valuePath), 0755) - s.Require().NoError(err) - err = os.WriteFile(valuePath, []byte(data), 0600) - s.Require().NoError(err) - return valuePath -} diff --git a/pkg/agent/plugin/nodeattestor/k8ssat/sat_windows.go b/pkg/agent/plugin/nodeattestor/k8ssat/sat_windows.go deleted file mode 100644 index ff25688658..0000000000 --- a/pkg/agent/plugin/nodeattestor/k8ssat/sat_windows.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build windows - -package k8ssat - -import ( - "os" - "path/filepath" -) - -const ( - containerMountPointEnvVar = "CONTAINER_SANDBOX_MOUNT_POINT" -) - -func getDefaultTokenPath() string { - mountPoint := os.Getenv(containerMountPointEnvVar) - if mountPoint == "" { - return filepath.FromSlash(defaultTokenPath) - } - return filepath.Join(mountPoint, defaultTokenPath) -} diff --git a/pkg/agent/plugin/nodeattestor/k8ssat/sat_windows_test.go b/pkg/agent/plugin/nodeattestor/k8ssat/sat_windows_test.go deleted file mode 100644 index 95e1a011d8..0000000000 --- a/pkg/agent/plugin/nodeattestor/k8ssat/sat_windows_test.go +++ /dev/null @@ -1,65 +0,0 @@ -//go:build windows - -package k8ssat - -import ( - "testing" - - "github.com/spiffe/go-spiffe/v2/spiffeid" - "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor" - "github.com/spiffe/spire/pkg/common/catalog" - "github.com/spiffe/spire/test/plugintest" - "github.com/stretchr/testify/require" -) - -func TestConfigureDefaultToken(t *testing.T) { - for _, tt := range []struct { - name string - trustDomain string - mountPoint string - config string - expectTokenPath string - }{ - { - name: "mountPoint set", - trustDomain: "example.org", - mountPoint: "c:\\somepath", - config: `cluster = "production"`, - expectTokenPath: "c:\\somepath\\var\\run\\secrets\\kubernetes.io\\serviceaccount\\token", - }, - { - name: "no mountPoint", - trustDomain: "example.org", - config: `cluster = "production"`, - expectTokenPath: "\\var\\run\\secrets\\kubernetes.io\\serviceaccount\\token", - }, - { - name: "token path set on configuration", - trustDomain: "example.org", - mountPoint: "c:\\somepath", - config: ` - cluster = "production" - token_path = "c:\\token"`, - expectTokenPath: "c:\\token", - }, - } { - t.Run(tt.name, func(t *testing.T) { - if tt.mountPoint != "" { - t.Setenv(containerMountPointEnvVar, tt.mountPoint) - } - - p := New() - var err error - plugintest.Load(t, builtin(p), new(nodeattestor.V1), - plugintest.CaptureConfigureError(&err), - plugintest.CoreConfig(catalog.CoreConfig{ - TrustDomain: spiffeid.RequireTrustDomainFromString(tt.trustDomain), - }), - plugintest.Configure(tt.config), - ) - require.NoError(t, err) - - require.Equal(t, tt.expectTokenPath, p.config.tokenPath) - }) - } -} diff --git a/pkg/server/catalog/nodeattestor.go b/pkg/server/catalog/nodeattestor.go index fe743889dc..e06d227035 100644 --- a/pkg/server/catalog/nodeattestor.go +++ b/pkg/server/catalog/nodeattestor.go @@ -9,7 +9,6 @@ import ( "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/httpchallenge" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/jointoken" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/k8spsat" - "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/k8ssat" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/sshpop" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/tpmdevid" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/x509pop" @@ -41,7 +40,6 @@ func (repo *nodeAttestorRepository) BuiltIns() []catalog.BuiltIn { httpchallenge.BuiltIn(), jointoken.BuiltIn(), k8spsat.BuiltIn(), - k8ssat.BuiltIn(), sshpop.BuiltIn(), tpmdevid.BuiltIn(), x509pop.BuiltIn(), diff --git a/pkg/server/plugin/nodeattestor/k8ssat/sat.go b/pkg/server/plugin/nodeattestor/k8ssat/sat.go deleted file mode 100644 index f80045a399..0000000000 --- a/pkg/server/plugin/nodeattestor/k8ssat/sat.go +++ /dev/null @@ -1,449 +0,0 @@ -package k8ssat - -import ( - "context" - "crypto" - "crypto/ecdsa" - "crypto/rsa" - "crypto/x509" - "encoding/json" - "encoding/pem" - "errors" - "fmt" - "os" - "sync" - "time" - - "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" - "github.com/gofrs/uuid/v5" - hclog "github.com/hashicorp/go-hclog" - "github.com/hashicorp/hcl" - nodeattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/nodeattestor/v1" - configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" - "github.com/spiffe/spire/pkg/common/catalog" - "github.com/spiffe/spire/pkg/common/plugin/k8s" - "github.com/spiffe/spire/pkg/common/plugin/k8s/apiserver" - "github.com/spiffe/spire/pkg/common/pluginconf" - nodeattestorbase "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/base" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - authv1 "k8s.io/api/authentication/v1" -) - -const ( - pluginName = "k8s_sat" - - // If there are clock differences between the agent and server then token - // validation may fail unless we give a little leeway. - tokenLeeway = time.Minute * 5 -) - -// Kubernetes doesn't appear to publicly document what signature algorithms it uses for tokens it issues. -// Accommodate the most commonly used signature algorithms that are known to be secure. -var allowedJWTSignatureAlgorithms = []jose.SignatureAlgorithm{ - jose.RS256, - jose.RS384, - jose.RS512, - jose.ES256, - jose.ES384, - jose.ES512, - jose.PS256, - jose.PS384, - jose.PS512, -} - -func BuiltIn() catalog.BuiltIn { - return builtin(New()) -} - -func builtin(p *AttestorPlugin) catalog.BuiltIn { - return catalog.MakeBuiltIn("k8s_sat", - nodeattestorv1.NodeAttestorPluginServer(p), - configv1.ConfigServiceServer(p), - ) -} - -type ClusterConfig struct { - // Path on disk to a PEM encoded file containing public keys used in validating tokens for that cluster - // If use_token_review_api_validation is true, then this path is ignored and TokenReview API is used for validation - ServiceAccountKeyFile string `hcl:"service_account_key_file"` - - // ServiceAccountAllowList is a list of service account names, qualified by - // namespace (for example, "default:blog" or "production:web") to allow for node attestation - ServiceAccountAllowList []string `hcl:"service_account_allow_list"` - - // UseTokenReviewAPI - // If true token review API will be used for token validation - // If false ServiceAccountKeyFile will be used for token validation - UseTokenReviewAPI bool `hcl:"use_token_review_api_validation"` - - // Kubernetes configuration file path - // Used to create a client to query the Kubernetes API server. If string is empty, in-cluster configuration is used - KubeConfigFile string `hcl:"kube_config_file"` -} - -type AttestorConfig struct { - Clusters map[string]*ClusterConfig `hcl:"clusters"` -} - -func (p *AttestorPlugin) buildConfig(coreConfig catalog.CoreConfig, hclText string, status *pluginconf.Status) *attestorConfig { - status.ReportInfof("The %q node attestor plugin has been deprecated in favor of the \"k8s_psat\" plugin and will be removed in a future release", pluginName) - p.log.Warn(fmt.Sprintf("The %q node attestor plugin has been deprecated in favor of the \"k8s_psat\" plugin and will be removed in a future release", pluginName)) - - hclConfig := new(AttestorConfig) - if err := hcl.Decode(hclConfig, hclText); err != nil { - status.ReportErrorf("unable to decode configuration: %v", err) - return nil - } - - if len(hclConfig.Clusters) == 0 { - status.ReportError("configuration must have at least one cluster") - } - - newConfig := &attestorConfig{ - trustDomain: coreConfig.TrustDomain.String(), - clusters: make(map[string]*clusterConfig), - } - - for name, cluster := range hclConfig.Clusters { - var serviceAccountKeys []crypto.PublicKey - var apiserverClient apiServerClient - var err error - if cluster.UseTokenReviewAPI { - apiserverClient = apiserver.New(cluster.KubeConfigFile) - } else { - if cluster.ServiceAccountKeyFile == "" { - status.ReportErrorf("cluster %q configuration missing service account key file", name) - } - - serviceAccountKeys, err = loadServiceAccountKeys(cluster.ServiceAccountKeyFile) - if err != nil { - status.ReportErrorf("failed to load cluster %q service account keys from %q: %v", name, cluster.ServiceAccountKeyFile, err) - } - - if len(serviceAccountKeys) == 0 { - status.ReportErrorf("cluster %q has no service account keys in %q", name, cluster.ServiceAccountKeyFile) - } - } - - if len(cluster.ServiceAccountAllowList) == 0 { - status.ReportErrorf("cluster %q configuration must have at least one service account allowed", name) - } - - serviceAccounts := make(map[string]bool) - for _, serviceAccount := range cluster.ServiceAccountAllowList { - serviceAccounts[serviceAccount] = true - } - - newConfig.clusters[name] = &clusterConfig{ - serviceAccountKeys: serviceAccountKeys, - serviceAccounts: serviceAccounts, - useTokenReviewAPI: cluster.UseTokenReviewAPI, - client: apiserverClient, - } - } - - return newConfig -} - -type apiServerClient interface { - ValidateToken(ctx context.Context, token string, audiences []string) (*authv1.TokenReviewStatus, error) -} - -type clusterConfig struct { - serviceAccountKeys []crypto.PublicKey - serviceAccounts map[string]bool - useTokenReviewAPI bool - client apiServerClient -} - -type attestorConfig struct { - trustDomain string - clusters map[string]*clusterConfig -} - -type AttestorPlugin struct { - nodeattestorbase.Base - nodeattestorv1.UnsafeNodeAttestorServer - configv1.UnsafeConfigServer - - mu sync.RWMutex - config *attestorConfig - log hclog.Logger - - hooks struct { - newUUID func() (string, error) - now func() time.Time - } -} - -func New() *AttestorPlugin { - p := &AttestorPlugin{} - p.hooks.newUUID = func() (string, error) { - u, err := uuid.NewV4() - if err != nil { - return "", err - } - return u.String(), nil - } - p.hooks.now = time.Now - return p -} - -// SetLogger sets up plugin logging -func (p *AttestorPlugin) SetLogger(log hclog.Logger) { - p.log = log -} - -func (p *AttestorPlugin) Attest(stream nodeattestorv1.NodeAttestor_AttestServer) error { - req, err := stream.Recv() - if err != nil { - return err - } - - config, err := p.getConfig() - if err != nil { - return err - } - - payload := req.GetPayload() - if payload == nil { - return status.Error(codes.InvalidArgument, "missing attestation payload") - } - - attestationData := new(k8s.SATAttestationData) - if err := json.Unmarshal(payload, attestationData); err != nil { - return status.Errorf(codes.InvalidArgument, "failed to unmarshal attestation data: %v", err) - } - - if attestationData.Cluster == "" { - return status.Error(codes.InvalidArgument, "missing cluster in attestation data") - } - - if attestationData.Token == "" { - return status.Error(codes.InvalidArgument, "missing token in attestation data") - } - - cluster := config.clusters[attestationData.Cluster] - if cluster == nil { - return status.Errorf(codes.InvalidArgument, "not configured for cluster %q", attestationData.Cluster) - } - - uuid, err := p.hooks.newUUID() - if err != nil { - return err - } - - // It is incredibly unlikely the agent will have already attested since we - // generate a new UUID on each attestation but just in case... - agentID := k8s.AgentID(pluginName, config.trustDomain, attestationData.Cluster, uuid) - if err := p.AssessTOFU(stream.Context(), agentID, p.log); err != nil { - return err - } - - var namespace, serviceAccountName string - if cluster.useTokenReviewAPI { - // Empty audience is used since SAT does not support audiences - tokenStatus, err := cluster.client.ValidateToken(stream.Context(), attestationData.Token, []string{}) - if err != nil { - return status.Errorf(codes.Internal, "unable to validate token with TokenReview API: %v", err) - } - - if !tokenStatus.Authenticated { - return status.Error(codes.PermissionDenied, "token not authenticated according to TokenReview API") - } - - namespace, serviceAccountName, err = k8s.GetNamesFromTokenStatus(tokenStatus) - if err != nil { - return status.Errorf(codes.Internal, "fail to parse username from token review status: %v", err) - } - } else { - token, err := jwt.ParseSigned(attestationData.Token, allowedJWTSignatureAlgorithms) - if err != nil { - return status.Errorf(codes.InvalidArgument, "unable to parse token: %v", err) - } - - claims := new(k8s.SATClaims) - err = verifyTokenSignature(cluster.serviceAccountKeys, token, claims) - if err != nil { - return err - } - if !claims.Expiry.Time().IsZero() { - // This is an indication that this may be a projected service account token - p.log.Warn("The service account token has an expiration time, which is an indication that may be a projected service account token. If your cluster supports Service Account Token Volume Projection you should instead use the `k8s_psat` attestor as soon as possible. The `k8s_sat` attestor has been deprecated in favor of the `k8s_psat` attestor and will be removed in a future release. Please look at https://github.com/spiffe/spire/blob/main/doc/plugin_server_nodeattestor_k8s_sat.md#security-considerations for details about security considerations when using the `k8s_sat` attestor.") - - // Validate the time with leeway - if err := claims.ValidateWithLeeway(jwt.Expected{ - Time: p.hooks.now(), - }, tokenLeeway); err != nil { - return status.Errorf(codes.InvalidArgument, "unable to validate token claims: %v", err) - } - } - - namespace, serviceAccountName, err = p.getNamesFromClaims(claims) - if err != nil { - return status.Errorf(codes.InvalidArgument, "error parsing token claims: %v", err) - } - } - - fullServiceAccountName := fmt.Sprintf("%v:%v", namespace, serviceAccountName) - if !cluster.serviceAccounts[fullServiceAccountName] { - return status.Errorf(codes.PermissionDenied, "%q is not an allowed service account", fullServiceAccountName) - } - - return stream.Send(&nodeattestorv1.AttestResponse{ - Response: &nodeattestorv1.AttestResponse_AgentAttributes{ - AgentAttributes: &nodeattestorv1.AgentAttributes{ - SpiffeId: agentID, - SelectorValues: []string{ - k8s.MakeSelectorValue("cluster", attestationData.Cluster), - k8s.MakeSelectorValue("agent_ns", namespace), - k8s.MakeSelectorValue("agent_sa", serviceAccountName), - }, - CanReattest: false, - }, - }, - }) -} - -// getNamesFromClaims parses claims from a k8s.SATClaims struct -// to extract the namespace and service account name -func (p *AttestorPlugin) getNamesFromClaims(claims *k8s.SATClaims) (namespace string, serviceAccountName string, err error) { - if claims.Namespace == "" { - if claims.K8s.Namespace == "" { - return "", "", errors.New("token missing namespace claim") - } - namespace = claims.K8s.Namespace - } else { - if claims.K8s.Namespace != "" { - return "", "", errors.New("malformed token: namespace found in two claims") - } - namespace = claims.Namespace - } - - if claims.ServiceAccountName == "" { - if claims.K8s.ServiceAccount.Name == "" { - return "", "", errors.New("token missing service account name claim") - } - serviceAccountName = claims.K8s.ServiceAccount.Name - } else { - if claims.K8s.ServiceAccount.Name != "" { - return "", "", errors.New("malformed token: service account name found in two claims") - } - serviceAccountName = claims.ServiceAccountName - } - - return namespace, serviceAccountName, nil -} - -func (p *AttestorPlugin) Configure(_ context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { - newConfig, _, err := pluginconf.Build(req, p.buildConfig) - if err != nil { - return nil, err - } - - p.mu.Lock() - defer p.mu.Unlock() - p.config = newConfig - - return &configv1.ConfigureResponse{}, nil -} - -func (p *AttestorPlugin) Validate(_ context.Context, req *configv1.ValidateRequest) (*configv1.ValidateResponse, error) { - _, notes, err := pluginconf.Build(req, p.buildConfig) - - return &configv1.ValidateResponse{ - Valid: err == nil, - Notes: notes, - }, err -} - -func (p *AttestorPlugin) getConfig() (*attestorConfig, error) { - p.mu.RLock() - defer p.mu.RUnlock() - if p.config == nil { - return nil, status.Errorf(codes.FailedPrecondition, "not configured") - } - return p.config, nil -} - -func verifyTokenSignature(keys []crypto.PublicKey, token *jwt.JSONWebToken, claims any) (err error) { - var lastErr error - for _, key := range keys { - if err := token.Claims(key, claims); err != nil { - lastErr = status.Errorf(codes.InvalidArgument, "unable to verify token: %v", err) - continue - } - return nil - } - if lastErr == nil { - lastErr = status.Error(codes.InvalidArgument, "token signed by unknown authority") - } - return lastErr -} - -func loadServiceAccountKeys(path string) ([]crypto.PublicKey, error) { - pemBytes, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - var keys []crypto.PublicKey - for { - var pemBlock *pem.Block - pemBlock, pemBytes = pem.Decode(pemBytes) - if pemBlock == nil { - return keys, nil - } - key, err := decodeKeyBlock(pemBlock) - if err != nil { - return nil, err - } - if key != nil { - keys = append(keys, key) - } - } -} - -func decodeKeyBlock(block *pem.Block) (crypto.PublicKey, error) { - var key crypto.PublicKey - switch block.Type { - case "CERTIFICATE": - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, err - } - key = cert.PublicKey - case "RSA PUBLIC KEY": - rsaKey, err := x509.ParsePKCS1PublicKey(block.Bytes) - if err != nil { - return nil, err - } - key = rsaKey - case "PUBLIC KEY": - pkixKey, err := x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - return nil, err - } - key = pkixKey - default: - return nil, nil - } - - if !isSupportedKey(key) { - return nil, fmt.Errorf("unsupported %T in %s block", key, block.Type) - } - return key, nil -} - -func isSupportedKey(key crypto.PublicKey) bool { - switch key.(type) { - case *rsa.PublicKey: - return true - case *ecdsa.PublicKey: - return true - default: - return false - } -} diff --git a/pkg/server/plugin/nodeattestor/k8ssat/sat_test.go b/pkg/server/plugin/nodeattestor/k8ssat/sat_test.go deleted file mode 100644 index 40bdb38955..0000000000 --- a/pkg/server/plugin/nodeattestor/k8ssat/sat_test.go +++ /dev/null @@ -1,554 +0,0 @@ -package k8ssat - -import ( - "context" - "crypto" - "crypto/ecdsa" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "errors" - "fmt" - "math/big" - "os" - "path/filepath" - "strings" - "testing" - "time" - - jose "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" - "github.com/spiffe/go-spiffe/v2/spiffeid" - agentstorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/hostservice/server/agentstore/v1" - "github.com/spiffe/spire/pkg/common/catalog" - "github.com/spiffe/spire/pkg/common/pemutil" - sat_common "github.com/spiffe/spire/pkg/common/plugin/k8s" - "github.com/spiffe/spire/pkg/server/plugin/nodeattestor" - "github.com/spiffe/spire/proto/spire/common" - "github.com/spiffe/spire/test/fakes/fakeagentstore" - "github.com/spiffe/spire/test/plugintest" - "github.com/spiffe/spire/test/spiretest" - "google.golang.org/grpc/codes" - authv1 "k8s.io/api/authentication/v1" -) - -var ( - fooKeyPEM = []byte(`-----BEGIN RSA PRIVATE KEY----- -MIIBywIBAAJhAMB4gbT09H2RKXaxbu6IV9C3WY+pvkGAbrlQRIHLHwV3Xt1HchjX -c08v1VEoTBN2YTjhZJlDb/VUsNMJsmBFBBted5geRcbrDtXFlUJ8tQoQx1dWM4Aa -xcdULJ83A9ICKwIDAQABAmBR1asInrIphYQEtHJ/NzdnRd3tqHV9cjch0dAfA5dA -Ar4yBYOsrkaX37WqWSDnkYgN4FWYBWn7WxeotCtA5UQ3SM5hLld67rUqAm2dLrs1 -z8va6SwLzrPTu2+rmRgovFECMQDpbfPBRex7FY/xWu1pYv6X9XZ26SrC2Wc6RIpO -38AhKGjTFEMAPJQlud4e2+4I3KkCMQDTFLUvBSXokw2NvcNiM9Kqo5zCnCIkgc+C -hM3EzSh2jh4gZvRzPOhXYvNKgLx8+LMCMQDL4meXlpV45Fp3eu4GsJqi65jvP7VD -v1P0hs0vGyvbSkpUo0vqNv9G/FNQLNR6FRECMFXEMz5wxA91OOuf8HTFg9Lr+fUl -RcY5rJxm48kUZ12Mr3cQ/kCYvftL7HkYR/4rewIxANdritlIPu4VziaEhYZg7dvz -pG3eEhiqPxE++QHpwU78O+F1GznOPBvpZOB3GfyjNQ== ------END RSA PRIVATE KEY-----`) - barKeyPEM = []byte(`-----BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgOIAksqKX+ByhLcme -T7MXn5Qz58BJCSvvAyRoz7+7jXGhRANCAATUWB+7Xo/JyFuh1KQ6umUbihP+AGzy -da0ItHUJ/C5HElB5cSuyOAXDQbM5fuxJIefEVpodjqsQP6D0D8CPLJ5H ------END PRIVATE KEY-----`) - bazKeyPEM = []byte(`-----BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgpHVYFq6Z/LgGIG/X -+i+PWZEFjGVEUpjrMzlz95tDl4yhRANCAAQAc/I3bBO9XhgTTbLBuNA6XJBSvds9 -c4gThKYxugN3V398Eieoo2HTO2L7BBjTp5yh+EUtHQD52bFseBCnZT3d ------END PRIVATE KEY-----`) -) - -func TestAttestorPlugin(t *testing.T) { - spiretest.Run(t, new(AttestorSuite)) -} - -type AttestorSuite struct { - spiretest.Suite - - dir string - fooKey *rsa.PrivateKey - fooSigner jose.Signer - barKey *ecdsa.PrivateKey - barSigner jose.Signer - bazSigner jose.Signer - attestor nodeattestor.NodeAttestor - agentStore *fakeagentstore.AgentStore - now time.Time - apiServerClient *fakeAPIServerClient -} - -func (s *AttestorSuite) SetupSuite() { - var err error - s.fooKey, err = pemutil.ParseRSAPrivateKey(fooKeyPEM) - s.Require().NoError(err) - s.fooSigner, err = jose.NewSigner(jose.SigningKey{ - Algorithm: jose.RS256, - Key: s.fooKey, - }, nil) - s.Require().NoError(err) - - s.barKey, err = pemutil.ParseECPrivateKey(barKeyPEM) - s.Require().NoError(err) - s.barSigner, err = jose.NewSigner(jose.SigningKey{ - Algorithm: jose.ES256, - Key: s.barKey, - }, nil) - s.Require().NoError(err) - - bazKey, err := pemutil.ParseECPrivateKey(bazKeyPEM) - s.Require().NoError(err) - s.bazSigner, err = jose.NewSigner(jose.SigningKey{ - Algorithm: jose.ES256, - Key: bazKey, - }, nil) - s.Require().NoError(err) - - s.dir = s.TempDir() - - // generate a self-signed certificate for signing tokens - s.Require().NoError(createAndWriteSelfSignedCert("FOO", s.fooKey, s.fooCertPath())) - s.Require().NoError(createAndWriteSelfSignedCert("BAR", s.barKey, s.barCertPath())) -} - -func (s *AttestorSuite) SetupTest() { - s.agentStore = fakeagentstore.New() - s.attestor = s.loadPlugin() - s.now = time.Now() -} - -func (s *AttestorSuite) TestAttestFailsWhenNotConfigured() { - attestor := new(nodeattestor.V1) - plugintest.Load(s.T(), BuiltIn(), attestor, - plugintest.HostServices(agentstorev1.AgentStoreServiceServer(s.agentStore)), - ) - s.attestor = attestor - s.requireAttestError([]byte("payload"), codes.FailedPrecondition, "nodeattestor(k8s_sat): not configured") -} - -func (s *AttestorSuite) TestAttestFailsWhenAttestedBefore() { - agentID := "spiffe://example.org/spire/agent/k8s_sat/FOO/UUID" - s.agentStore.SetAgentInfo(&agentstorev1.AgentInfo{ - AgentId: agentID, - }) - - token := s.signToken(s.fooSigner, "NS1", "SA1") - s.requireAttestError(makePayload("FOO", token), - codes.PermissionDenied, - "nodeattestor(k8s_sat): attestation data has already been used to attest an agent") -} - -func (s *AttestorSuite) TestAttestFailsWithMalformedPayload() { - s.requireAttestError([]byte("{"), - codes.InvalidArgument, - "nodeattestor(k8s_sat): failed to unmarshal attestation data") -} - -func (s *AttestorSuite) TestAttestFailsWithNoClusterInPayload() { - s.requireAttestError(makePayload("", "TOKEN"), - codes.InvalidArgument, - "nodeattestor(k8s_sat): missing cluster in attestation data") -} - -func (s *AttestorSuite) TestAttestFailsWithNoTokenInPayload() { - s.requireAttestError(makePayload("FOO", ""), - codes.InvalidArgument, - "nodeattestor(k8s_sat): missing token in attestation data") -} - -func (s *AttestorSuite) TestAttestFailsWithMalformedTokenInPayload() { - s.requireAttestError(makePayload("FOO", "blah"), - codes.InvalidArgument, - "nodeattestor(k8s_sat): unable to parse token") -} - -func (s *AttestorSuite) TestAttestFailsIfClusterNotConfigured() { - s.requireAttestError(makePayload("CLUSTER", "blah"), - codes.InvalidArgument, - `nodeattestor(k8s_sat): not configured for cluster "CLUSTER"`) -} - -func (s *AttestorSuite) TestAttestFailsWithBadSignature() { - // sign a token and replace the signature - token := s.signToken(s.fooSigner, "", "") - parts := strings.Split(token, ".") - s.Require().Len(parts, 3) - parts[2] = "aaaa" - token = strings.Join(parts, ".") - - s.requireAttestError(makePayload("FOO", token), - codes.InvalidArgument, - "unable to verify token") -} - -func (s *AttestorSuite) TestAttestFailsIfTokenReviewAPIFails() { - token := s.signToken(s.barSigner, "NS2", "SA2") - s.requireAttestError(makePayload("BAR", token), - codes.Internal, - "unable to validate token with TokenReview API") -} - -func (s *AttestorSuite) TestAttestFailsWithMalformedToken() { - claims := sat_common.SATClaims{} - claims.Namespace = "ns1" - claims.K8s.Namespace = "ns2" - - builder := jwt.Signed(s.fooSigner) - builder = builder.Claims(claims) - token, err := builder.Serialize() - s.Require().NoError(err) - s.requireAttestError(makePayload("FOO", token), codes.InvalidArgument, "malformed token: namespace found in two claims") - - // Clear the namespace from the SAT space but leave the duplicated service account name - claims.K8s.Namespace = "" - claims.ServiceAccountName = "sa1" - claims.K8s.ServiceAccount.Name = "sa2" - - builder = builder.Claims(claims) - token, err = builder.Serialize() - s.Require().NoError(err) - s.requireAttestError(makePayload("FOO", token), codes.InvalidArgument, "malformed token: service account name found in two claims") -} - -func (s *AttestorSuite) TestAttestFailsIfTokenNotAuthenticated() { - token := s.signToken(s.barSigner, "NS2", "SA2") - status := createTokenStatus("NS2", "SA2", false) - s.apiServerClient.SetTokenStatus(token, status) - s.requireAttestError(makePayload("BAR", token), codes.PermissionDenied, "token not authenticated") -} - -func (s *AttestorSuite) TestAttestFailsWithMissingNamespaceClaim() { - token := s.signToken(s.fooSigner, "", "") - s.requireAttestError(makePayload("FOO", token), codes.InvalidArgument, "token missing namespace claim") -} - -func (s *AttestorSuite) TestAttestFailsWithMissingNamespaceFromTokenStatus() { - token := s.signToken(s.barSigner, "", "SA2") - status := createTokenStatus("", "SA2", true) - s.apiServerClient.SetTokenStatus(token, status) - s.requireAttestError(makePayload("BAR", token), codes.Internal, "fail to parse username from token review status") -} - -func (s *AttestorSuite) TestAttestFailsWithMissingServiceAccountNameClaim() { - token := s.signToken(s.fooSigner, "NAMESPACE", "") - s.requireAttestError(makePayload("FOO", token), codes.InvalidArgument, "token missing service account name claim") -} - -func (s *AttestorSuite) TestAttestFailsWithMissingServiceAccountNameFromTokenStatus() { - token := s.signToken(s.barSigner, "NS2", "") - status := createTokenStatus("NS2", "", true) - s.apiServerClient.SetTokenStatus(token, status) - s.requireAttestError(makePayload("BAR", token), codes.Internal, "fail to parse username from token review status") -} - -func (s *AttestorSuite) TestAttestFailsIfServiceAccountNotAllowListedFromTokenClaim() { - token := s.signToken(s.fooSigner, "NS1", "NO-WHITHELISTED-SA") - s.requireAttestError(makePayload("FOO", token), codes.PermissionDenied, `"NS1:NO-WHITHELISTED-SA" is not an allowed service account`) -} - -func (s *AttestorSuite) TestAttestFailsIfServiceAccountNotAllowListedFromTokenStatus() { - token := s.signToken(s.barSigner, "NS2", "NO-WHITHELISTED-SA") - status := createTokenStatus("NS2", "NO-WHITHELISTED-SA", true) - s.apiServerClient.SetTokenStatus(token, status) - s.requireAttestError(makePayload("BAR", token), codes.PermissionDenied, `"NS2:NO-WHITHELISTED-SA" is not an allowed service account`) -} - -func (s *AttestorSuite) TestAttestFailsIfTokenSignatureCannotBeVerifiedByCluster() { - token := s.signToken(s.bazSigner, "NAMESPACE", "SERVICEACCOUNTNAME") - s.requireAttestError(makePayload("FOO", token), codes.InvalidArgument, "nodeattestor(k8s_sat): unable to verify token") -} - -func (s *AttestorSuite) TestAttestSuccess() { - // Success with FOO signed token (local validation) - token := s.signToken(s.fooSigner, "NS1", "SA1") - result, err := s.attestor.Attest(context.Background(), makePayload("FOO", token), expectNoChallenge) - s.Require().NoError(err) - s.Require().NotNil(result) - s.Require().Equal(result.AgentID, "spiffe://example.org/spire/agent/k8s_sat/FOO/UUID") - s.RequireProtoListEqual([]*common.Selector{ - {Type: "k8s_sat", Value: "cluster:FOO"}, - {Type: "k8s_sat", Value: "agent_ns:NS1"}, - {Type: "k8s_sat", Value: "agent_sa:SA1"}, - }, result.Selectors) - - // Success with BAR signed token (token review API validation) - token = s.signToken(s.barSigner, "NS2", "SA2") - status := createTokenStatus("NS2", "SA2", true) - s.apiServerClient.SetTokenStatus(token, status) - result, err = s.attestor.Attest(context.Background(), makePayload("BAR", token), expectNoChallenge) - s.Require().NoError(err) - s.Require().NotNil(result) - s.Require().Equal(result.AgentID, "spiffe://example.org/spire/agent/k8s_sat/BAR/UUID") - s.RequireProtoListEqual([]*common.Selector{ - {Type: "k8s_sat", Value: "cluster:BAR"}, - {Type: "k8s_sat", Value: "agent_ns:NS2"}, - {Type: "k8s_sat", Value: "agent_sa:SA2"}, - }, result.Selectors) -} - -func (s *AttestorSuite) TestConfigure() { - doConfig := func(coreConfig catalog.CoreConfig, config string) error { - var err error - plugintest.Load(s.T(), BuiltIn(), nil, - plugintest.CaptureConfigureError(&err), - plugintest.HostServices(agentstorev1.AgentStoreServiceServer(s.agentStore)), - plugintest.CoreConfig(coreConfig), - plugintest.Configure(config), - ) - return err - } - - coreConfig := catalog.CoreConfig{ - TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), - } - - // malformed configuration - err := doConfig(coreConfig, "blah") - s.RequireErrorContains(err, "unable to decode configuration") - - // missing trust domain - err = doConfig(catalog.CoreConfig{}, "") - s.RequireGRPCStatus(err, codes.InvalidArgument, "server core configuration must contain trust_domain") - - // missing clusters - err = doConfig(coreConfig, "") - s.RequireGRPCStatus(err, codes.InvalidArgument, "configuration must have at least one cluster") - - // cluster missing service account key file - err = doConfig(coreConfig, `clusters = { - "FOO" = {} - }`) - s.RequireGRPCStatus(err, codes.InvalidArgument, `cluster "FOO" configuration missing service account key file`) - - // cluster missing service account allow list (local validation config) - err = doConfig(coreConfig, fmt.Sprintf(`clusters = { - "FOO" = { - service_account_key_file = %q - } - }`, s.fooCertPath())) - s.RequireGRPCStatus(err, codes.InvalidArgument, `cluster "FOO" configuration must have at least one service account allowed`) - - // cluster missing service account allow list (token review validation config) - err = doConfig(coreConfig, `clusters = { - "BAR" = { - use_token_review_api_validation = true - } - }`) - s.RequireGRPCStatus(err, codes.InvalidArgument, `cluster "BAR" configuration must have at least one service account allowed`) - - // unable to load cluster service account keys - err = doConfig(coreConfig, fmt.Sprintf(`clusters = { - "FOO" = { - service_account_key_file = %q - service_account_allow_list = ["A"] - } - }`, filepath.Join(s.dir, "missing.pem"))) - s.RequireErrorContains(err, `failed to load cluster "FOO" service account keys`) - - // no keys in PEM file - s.Require().NoError(os.WriteFile(filepath.Join(s.dir, "nokeys.pem"), []byte{}, 0o600)) - err = doConfig(coreConfig, fmt.Sprintf(`clusters = { - "FOO" = { - service_account_key_file = %q - service_account_allow_list = ["A"] - } - }`, filepath.Join(s.dir, "nokeys.pem"))) - s.RequireErrorContains(err, `cluster "FOO" has no service account keys in`) -} - -func (s *AttestorSuite) TestServiceAccountKeyFileAlternateEncodings() { - fooPKCS1KeyPath := filepath.Join(s.dir, "foo-pkcs1.pem") - fooPKCS1Bytes := x509.MarshalPKCS1PublicKey(&s.fooKey.PublicKey) - s.Require().NoError(os.WriteFile(fooPKCS1KeyPath, pem.EncodeToMemory(&pem.Block{ - Type: "RSA PUBLIC KEY", - Bytes: fooPKCS1Bytes, - }), 0o600)) - - fooPKIXKeyPath := filepath.Join(s.dir, "foo-pkix.pem") - fooPKIXBytes, err := x509.MarshalPKIXPublicKey(s.fooKey.Public()) - s.Require().NoError(err) - s.Require().NoError(os.WriteFile(fooPKIXKeyPath, pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: fooPKIXBytes, - }), 0o600)) - - barPKIXKeyPath := filepath.Join(s.dir, "bar-pkix.pem") - barPKIXBytes, err := x509.MarshalPKIXPublicKey(s.barKey.Public()) - s.Require().NoError(err) - s.Require().NoError(os.WriteFile(barPKIXKeyPath, pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: barPKIXBytes, - }), 0o600)) - - plugintest.Load(s.T(), BuiltIn(), nil, - plugintest.HostServices(agentstorev1.AgentStoreServiceServer(s.agentStore)), - plugintest.CoreConfig(catalog.CoreConfig{ - TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), - }), - plugintest.Configuref(`clusters = { - "FOO-PKCS1" = { - service_account_key_file = %q - service_account_allow_list = ["A"] - } - "FOO-PKIX" = { - service_account_key_file = %q - service_account_allow_list = ["A"] - } - "BAR-PKIX" = { - service_account_key_file = %q - service_account_allow_list = ["A"] - } - }`, fooPKCS1KeyPath, fooPKIXKeyPath, barPKIXKeyPath), - ) -} - -func (s *AttestorSuite) TestAttestTokenExpiration() { - token := s.signTokenWithExpiry(s.fooSigner, "NS1", "SA1") - - // within 5m leeway (token expires at 1m + 5m leeway = 6m) - s.adjustTime(4 * time.Minute) - result, err := s.attestor.Attest(context.Background(), makePayload("FOO", token), expectNoChallenge) - s.Require().NoError(err) - s.Require().NotNil(result) - - // after 5m leeway - s.adjustTime(3 * time.Minute) - s.requireAttestError(makePayload("FOO", token), codes.InvalidArgument, "token is expired (exp)") -} - -func (s *AttestorSuite) signToken(signer jose.Signer, namespace, serviceAccountName string) string { - builder := s.createBuilder(signer, namespace, serviceAccountName, jwt.NewNumericDate(time.Time{})) - - token, err := builder.Serialize() - s.Require().NoError(err) - return token -} - -func (s *AttestorSuite) signTokenWithExpiry(signer jose.Signer, namespace, serviceAccountName string) string { - builder := s.createBuilder(signer, namespace, serviceAccountName, jwt.NewNumericDate(s.now.Add(time.Minute))) - - token, err := builder.Serialize() - s.Require().NoError(err) - return token -} - -func (s *AttestorSuite) loadPlugin() nodeattestor.NodeAttestor { - attestor := New() - attestor.hooks.newUUID = func() (string, error) { - return "UUID", nil - } - attestor.hooks.now = func() time.Time { - return s.now - } - v1 := new(nodeattestor.V1) - plugintest.Load(s.T(), builtin(attestor), v1, - plugintest.HostServices(agentstorev1.AgentStoreServiceServer(s.agentStore)), - plugintest.CoreConfig(catalog.CoreConfig{ - TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), - }), - plugintest.Configuref(` - clusters = { - "FOO" = { - service_account_key_file = %q - service_account_allow_list = ["NS1:SA1"] - } - "BAR" = { - use_token_review_api_validation = true - service_account_allow_list = ["NS2:SA2"] - } - } - `, s.fooCertPath()), - ) - - // TODO: provide this client in a cleaner way - s.apiServerClient = newFakeAPIServerClient() - attestor.config.clusters["FOO"].client = s.apiServerClient - attestor.config.clusters["BAR"].client = s.apiServerClient - return v1 -} - -func (s *AttestorSuite) requireAttestError(payload []byte, expectCode codes.Code, expectMsg string) { - result, err := s.attestor.Attest(context.Background(), payload, expectNoChallenge) - s.RequireGRPCStatusContains(err, expectCode, expectMsg) - s.Require().Nil(result) -} - -func (s *AttestorSuite) fooCertPath() string { - return filepath.Join(s.dir, "foo.pem") -} - -func (s *AttestorSuite) barCertPath() string { - return filepath.Join(s.dir, "bar.pem") -} - -func (s *AttestorSuite) adjustTime(d time.Duration) { - s.now = s.now.Add(d) -} - -func (s *AttestorSuite) createBuilder(signer jose.Signer, namespace, serviceAccountName string, numericDate *jwt.NumericDate) jwt.Builder { - claims := sat_common.SATClaims{} - claims.Namespace = namespace - claims.ServiceAccountName = serviceAccountName - claims.Expiry = numericDate - - builder := jwt.Signed(signer) - builder = builder.Claims(claims) - return builder -} - -func makePayload(cluster, token string) []byte { - return []byte(fmt.Sprintf(`{"cluster": %q, "token": %q}`, cluster, token)) -} - -func createAndWriteSelfSignedCert(cn string, signer crypto.Signer, path string) error { - now := time.Now() - tmpl := &x509.Certificate{ - SerialNumber: big.NewInt(0), - NotAfter: now.Add(time.Hour), - NotBefore: now, - Subject: pkix.Name{CommonName: cn}, - } - certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, signer.Public(), signer) - if err != nil { - return err - } - return os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), 0o600) -} - -func createTokenStatus(namespace, serviceAccountName string, authenticated bool) *authv1.TokenReviewStatus { - return &authv1.TokenReviewStatus{ - Authenticated: authenticated, - User: authv1.UserInfo{ - Username: fmt.Sprintf("system:serviceaccount:%s:%s", namespace, serviceAccountName), - }, - } -} - -func expectNoChallenge(context.Context, []byte) ([]byte, error) { - return nil, errors.New("challenge is not expected") -} - -type fakeAPIServerClient struct { - status map[string]*authv1.TokenReviewStatus -} - -func newFakeAPIServerClient() *fakeAPIServerClient { - return &fakeAPIServerClient{ - status: make(map[string]*authv1.TokenReviewStatus), - } -} - -func (c *fakeAPIServerClient) SetTokenStatus(token string, status *authv1.TokenReviewStatus) { - c.status[token] = status -} - -func (c *fakeAPIServerClient) ValidateToken(_ context.Context, token string, audiences []string) (*authv1.TokenReviewStatus, error) { - if len(audiences) > 0 { - return nil, fmt.Errorf("unexpected audiences %q", audiences) - } - status, ok := c.status[token] - if !ok { - return nil, errors.New("no status configured by test for token") - } - return status, nil -}