diff --git a/doc/plugin_agent_nodeattestor_k8s_sat.md b/doc/plugin_agent_nodeattestor_k8s_sat.md new file mode 100644 index 0000000000..775dd7f8ee --- /dev/null +++ b/doc/plugin_agent_nodeattestor_k8s_sat.md @@ -0,0 +1,46 @@ +# Agent plugin: NodeAttestor "k8s_sat" + +*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. It also +generates a one-time UUID that is also provided to the server. + +The plugin generates SPIFFE IDs with the form: + +``` +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 | "/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 overriden in most cases. + +A sample configuration with the default token path: + +``` + 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 a whitelisted 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 new file mode 100644 index 0000000000..91c042c5c7 --- /dev/null +++ b/doc/plugin_server_nodeattestor_k8s_sat.md @@ -0,0 +1,65 @@ +# Server plugin: NodeAttestor "k8s_sat" + +*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. It extracts +the service account name and namespace from the token claims. The server uses a +one-time UUID provided by the agent to generate a SPIFFE ID with the form: + +``` +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_key_file` | 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_whitelist` | 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 whitelist. | | + +A sample configuration: + +``` + NodeAttestor "k8s_sat" { + plugin_data { + clusters = { + "MyCluster" = { + service_account_key_file = "/path/to/key/file" + service_account_whitelist = ["production:spire-agent"] + } + } + } +``` + +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 a whitelisted 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 fc1107ebeb..8a2d34e236 100644 --- a/doc/spire_agent.md +++ b/doc/spire_agent.md @@ -87,6 +87,7 @@ communicates with spire-server via the Node API. | NodeAttestor | [aws_iid](/doc/plugin_agent_nodeattestor_aws_iid.md) | An AWS IID attestor that automatically attests EC2 instances using the AWS Instance Metadata API and the AWS Instance Identity document. | | NodeAttestor | [azure_msi](/doc/plugin_agent_nodeattestor_azure_msi.md) | An Azure Node attestor that automatically attests Azure VMs using a signed Managed Service Identity (MSI) token. | | NodeAttestor | [gcp_iit](/doc/plugin_agent_nodeattestor_gcp_iit.md) | An Google Compute Engine Node attestor that automatically attests GCE instances using a signed token from Google retrieved via the Compute Engine Metadata API. | +| NodeAttestor | [k8s_sat](/doc/plugin_agent_nodeattestor_k8s_sat.md) | A node attestor which attests agents using service account tokens inside of Kubernetes | | WorkloadAttestor | [unix](/doc/plugin_agent_workloadattestor_unix.md) | A workload attestor which generates unix-based selectors like `uid` and `gid` | | WorkloadAttestor | [k8s](/doc/plugin_agent_workloadattestor_k8s.md) | A workload attestor which allows selectors based on Kubernetes constructs such `ns` (namespace) and `sa` (service account)| diff --git a/doc/spire_server.md b/doc/spire_server.md index bb711d2888..2e92a6c8be 100644 --- a/doc/spire_server.md +++ b/doc/spire_server.md @@ -172,6 +172,7 @@ API and the Node API, with which agents communicate with the server. | NodeAttestor | [nodeattestor_azure_msi](/doc/plugin_server_nodeattestor_azure_msi.md) | A node attestor which validates agents attesting using the [azure_msi](/doc/plugin_agent_nodeattestor_azure_msi.md) node attestor plugin. | | NodeResolver | [noderesolver_azure_msi](/doc/plugin_server_noderesolver_aws_iid.md) | A node resolver which extends the [nodeattestor_azure_msi](/doc/plugin_server_nodeattestor_azure_msi.md) node attestor plugin to support selecting nodes based on additional properties (such as Network Security Group). | | NodeAttestor | [nodeattestor_gcp_iit](/doc/plugin_server_nodeattestor_gcp_iit.md) | A node attestor which validates agents attesting using the [gcp_iit](/doc/plugin_agent_nodeattestor_gcp_iit.md) node attestor plugin. | +| NodeAttestor | [nodeattestor_k8s_sat](/doc/plugin_server_nodeattestor_k8s.md) | A node attestor which validates agents using the [k8s_sat](/doc/plugin_agent_nodeattestor_k8s_sat.md) node attestor plugin. | | NodeResolver | [noop](/doc/plugin_server_noderesolver_noop.md) | It is mandatory to have at least one node resolver plugin configured. This one is a no-op | | UpstreamCA | [disk](/doc/plugin_server_upstreamca_disk.md) | Uses a CA loaded from disk to generate SPIRE server intermediate certificates for use in the ServerCA plugin | diff --git a/pkg/agent/catalog/catalog.go b/pkg/agent/catalog/catalog.go index 8ebc261829..221f6ba4f9 100644 --- a/pkg/agent/catalog/catalog.go +++ b/pkg/agent/catalog/catalog.go @@ -12,8 +12,9 @@ import ( "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/azure" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/gcp" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/jointoken" + k8s_na "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/k8s" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/x509pop" - "github.com/spiffe/spire/pkg/agent/plugin/workloadattestor/k8s" + k8s_wa "github.com/spiffe/spire/pkg/agent/plugin/workloadattestor/k8s" "github.com/spiffe/spire/pkg/agent/plugin/workloadattestor/unix" "github.com/spiffe/spire/proto/agent/keymanager" "github.com/spiffe/spire/proto/agent/nodeattestor" @@ -53,9 +54,10 @@ var ( "gcp_iit": nodeattestor.NewBuiltIn(gcp.NewIITAttestorPlugin()), "x509pop": nodeattestor.NewBuiltIn(x509pop.New()), "azure_msi": nodeattestor.NewBuiltIn(azure.NewMSIAttestorPlugin()), + "k8s_sat": nodeattestor.NewBuiltIn(k8s_na.NewSATAttestorPlugin()), }, WorkloadAttestorType: { - "k8s": workloadattestor.NewBuiltIn(k8s.New()), + "k8s": workloadattestor.NewBuiltIn(k8s_wa.New()), "unix": workloadattestor.NewBuiltIn(unix.New()), }, } diff --git a/pkg/agent/plugin/nodeattestor/k8s/sat.go b/pkg/agent/plugin/nodeattestor/k8s/sat.go new file mode 100644 index 0000000000..2a9cee8c73 --- /dev/null +++ b/pkg/agent/plugin/nodeattestor/k8s/sat.go @@ -0,0 +1,146 @@ +package k8s + +import ( + "context" + "encoding/json" + "io/ioutil" + "sync" + + "github.com/hashicorp/hcl" + uuid "github.com/satori/go.uuid" + "github.com/spiffe/spire/pkg/common/plugin/k8s" + "github.com/spiffe/spire/proto/agent/nodeattestor" + "github.com/spiffe/spire/proto/common" + spi "github.com/spiffe/spire/proto/common/plugin" + "github.com/zeebo/errs" +) + +const ( + pluginName = "k8s_sat" + + defaultTokenPath = "/run/secrets/kubernetes.io/serviceaccount/token" +) + +var ( + satError = errs.Class("k8s-sat") +) + +type SATAttestorConfig struct { + Cluster string `hcl:"cluster"` + TokenPath string `hcl:"token_path"` +} + +type satAttestorConfig struct { + trustDomain string + cluster string + tokenPath string +} + +type SATAttestorPlugin struct { + mu sync.RWMutex + config *satAttestorConfig + + hooks struct { + newUUID func() string + } +} + +var _ nodeattestor.Plugin = (*SATAttestorPlugin)(nil) + +func NewSATAttestorPlugin() *SATAttestorPlugin { + p := &SATAttestorPlugin{} + p.hooks.newUUID = func() string { + return uuid.NewV4().String() + } + return p +} + +func (p *SATAttestorPlugin) FetchAttestationData(stream nodeattestor.FetchAttestationData_PluginStream) error { + config, err := p.getConfig() + if err != nil { + return err + } + + uuid := p.hooks.newUUID() + + token, err := loadTokenFromFile(config.tokenPath) + if err != nil { + return satError.New("unable to load token from %s: %v", config.tokenPath, err) + } + + data, err := json.Marshal(k8s.SATAttestationData{ + Cluster: config.cluster, + UUID: uuid, + Token: token, + }) + if err != nil { + return satError.Wrap(err) + } + + return stream.Send(&nodeattestor.FetchAttestationDataResponse{ + AttestationData: &common.AttestationData{ + Type: pluginName, + Data: data, + }, + SpiffeId: k8s.AgentID(config.trustDomain, config.cluster, uuid), + }) +} + +func (p *SATAttestorPlugin) Configure(ctx context.Context, req *spi.ConfigureRequest) (resp *spi.ConfigureResponse, err error) { + hclConfig := new(SATAttestorConfig) + if err := hcl.Decode(hclConfig, req.Configuration); err != nil { + return nil, satError.New("unable to decode configuration: %v", err) + } + + if req.GlobalConfig == nil { + return nil, satError.New("global configuration is required") + } + if req.GlobalConfig.TrustDomain == "" { + return nil, satError.New("global configuration missing trust domain") + } + if hclConfig.Cluster == "" { + return nil, satError.New("configuration missing cluster") + } + + config := &satAttestorConfig{ + trustDomain: req.GlobalConfig.TrustDomain, + cluster: hclConfig.Cluster, + tokenPath: hclConfig.TokenPath, + } + if config.tokenPath == "" { + config.tokenPath = defaultTokenPath + } + + p.setConfig(config) + return &spi.ConfigureResponse{}, nil +} + +func (p *SATAttestorPlugin) GetPluginInfo(context.Context, *spi.GetPluginInfoRequest) (*spi.GetPluginInfoResponse, error) { + return &spi.GetPluginInfoResponse{}, nil +} + +func (p *SATAttestorPlugin) getConfig() (*satAttestorConfig, error) { + p.mu.RLock() + defer p.mu.RUnlock() + if p.config == nil { + return nil, satError.New("not configured") + } + return p.config, nil +} + +func (p *SATAttestorPlugin) setConfig(config *satAttestorConfig) { + p.mu.Lock() + defer p.mu.Unlock() + p.config = config +} + +func loadTokenFromFile(path string) (string, error) { + data, err := ioutil.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/k8s/sat_test.go b/pkg/agent/plugin/nodeattestor/k8s/sat_test.go new file mode 100644 index 0000000000..30485d3617 --- /dev/null +++ b/pkg/agent/plugin/nodeattestor/k8s/sat_test.go @@ -0,0 +1,163 @@ +package k8s + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/spiffe/spire/proto/agent/nodeattestor" + "github.com/spiffe/spire/proto/common/plugin" + "github.com/stretchr/testify/suite" +) + +func TestSATAttestorPlugin(t *testing.T) { + suite.Run(t, new(SATAttestorSuite)) +} + +type SATAttestorSuite struct { + suite.Suite + + dir string + attestor *nodeattestor.BuiltIn +} + +func (s *SATAttestorSuite) SetupTest() { + var err error + s.dir, err = ioutil.TempDir("", "spire-k8s-sat-test-") + s.Require().NoError(err) + + s.newAttestor() + s.configure(SATAttestorConfig{}) +} + +func (s *SATAttestorSuite) TestFetchAttestationDataNotConfigured() { + s.newAttestor() + s.requireFetchError("k8s-sat: not configured") +} + +func (s *SATAttestorSuite) TestFetchAttestationDataNoToken() { + s.configure(SATAttestorConfig{ + TokenPath: s.joinPath("token"), + }) + s.requireFetchError("unable to load token from") +} + +func (s *SATAttestorSuite) TestFetchAttestationDataSuccess() { + s.configure(SATAttestorConfig{ + TokenPath: s.writeValue("token", "TOKEN"), + }) + + stream, err := s.attestor.FetchAttestationData(context.Background()) + s.Require().NoError(err) + s.Require().NotNil(stream) + + resp, err := stream.Recv() + s.Require().NoError(err) + s.Require().NotNil(resp) + + // assert attestation data + s.Require().Equal("spiffe://example.org/spire/agent/k8s_sat/production/UUID", resp.SpiffeId) + s.Require().NotNil(resp.AttestationData) + s.Require().Equal("k8s_sat", resp.AttestationData.Type) + s.Require().JSONEq(`{ + "cluster": "production", + "uuid": "UUID", + "token": "TOKEN" + }`, string(resp.AttestationData.Data)) + + // node attestor should return EOF now + _, err = stream.Recv() + s.Require().Equal(io.EOF, err) +} + +func (s *SATAttestorSuite) TestConfigure() { + // malformed configuration + resp, err := s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{}, + Configuration: "blah", + }) + s.requireErrorContains(err, "k8s-sat: unable to decode configuration") + s.Require().Nil(resp) + + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{}) + s.requireErrorContains(err, "k8s-sat: global configuration is required") + s.Require().Nil(resp) + + // missing trust domain + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{}}) + s.Require().EqualError(err, "k8s-sat: global configuration missing trust domain") + s.Require().Nil(resp) + + // missing cluster + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{TrustDomain: "example.org"}, + }) + s.Require().EqualError(err, "k8s-sat: configuration missing cluster") + s.Require().Nil(resp) + + // success + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{TrustDomain: "example.org"}, + Configuration: `cluster = "production"`, + }) + s.Require().NoError(err) + s.Require().Equal(resp, &plugin.ConfigureResponse{}) +} + +func (s *SATAttestorSuite) TestGetPluginInfo() { + resp, err := s.attestor.GetPluginInfo(context.Background(), &plugin.GetPluginInfoRequest{}) + s.Require().NoError(err) + s.Require().Equal(resp, &plugin.GetPluginInfoResponse{}) +} + +func (s *SATAttestorSuite) newAttestor() { + attestor := NewSATAttestorPlugin() + attestor.hooks.newUUID = func() string { + return "UUID" + } + s.attestor = nodeattestor.NewBuiltIn(attestor) +} + +func (s *SATAttestorSuite) configure(config SATAttestorConfig) { + _, err := s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{ + TrustDomain: "example.org", + }, + Configuration: fmt.Sprintf(` + cluster = "production" + token_path = %q`, config.TokenPath), + }) + s.Require().NoError(err) + +} +func (s *SATAttestorSuite) joinPath(path string) string { + return filepath.Join(s.dir, path) +} + +func (s *SATAttestorSuite) writeValue(path, data string) string { + valuePath := s.joinPath(path) + err := os.MkdirAll(filepath.Dir(valuePath), 0755) + s.Require().NoError(err) + err = ioutil.WriteFile(valuePath, []byte(data), 0644) + s.Require().NoError(err) + return valuePath +} + +func (s *SATAttestorSuite) requireFetchError(contains string) { + stream, err := s.attestor.FetchAttestationData(context.Background()) + s.Require().NoError(err) + s.Require().NotNil(stream) + + resp, err := stream.Recv() + s.requireErrorContains(err, contains) + s.Require().Nil(resp) +} + +func (s *SATAttestorSuite) requireErrorContains(err error, contains string) { + s.Require().Error(err) + s.Require().Contains(err.Error(), contains) +} diff --git a/pkg/common/plugin/k8s/sat.go b/pkg/common/plugin/k8s/sat.go new file mode 100644 index 0000000000..3909cbc251 --- /dev/null +++ b/pkg/common/plugin/k8s/sat.go @@ -0,0 +1,38 @@ +package k8s + +import ( + "net/url" + "path" + + "gopkg.in/square/go-jose.v2/jwt" +) + +// SATClaims represents claims in a service account token, for example: +// { +// "iss": "kubernetes/serviceaccount", +// "kubernetes.io/serviceaccount/namespace": "spire", +// "kubernetes.io/serviceaccount/secret.name": "spire-agent-token-zjr8v", +// "kubernetes.io/serviceaccount/service-account.name": "spire-agent", +// "kubernetes.io/serviceaccount/service-account.uid": "1881e84f-b612-11e8-a543-0800272c6e42", +// "sub": "system:serviceaccount:spire:spire-agent" +// } +type SATClaims struct { + jwt.Claims + Namespace string `json:"kubernetes.io/serviceaccount/namespace"` + ServiceAccountName string `json:"kubernetes.io/serviceaccount/service-account.name"` +} + +type SATAttestationData struct { + Cluster string `json:"cluster"` + UUID string `json:"uuid"` + Token string `json:"token"` +} + +func AgentID(trustDomain, cluster, uuid string) string { + u := url.URL{ + Scheme: "spiffe", + Host: trustDomain, + Path: path.Join("spire", "agent", "k8s_sat", cluster, uuid), + } + return u.String() +} diff --git a/pkg/common/plugin/k8s/sat_test.go b/pkg/common/plugin/k8s/sat_test.go new file mode 100644 index 0000000000..5d99fa4ccd --- /dev/null +++ b/pkg/common/plugin/k8s/sat_test.go @@ -0,0 +1,29 @@ +package k8s + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2/jwt" +) + +const ( + rawToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJzcGlyZSIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VjcmV0Lm5hbWUiOiJzcGlyZS1hZ2VudC10b2tlbi16anI4diIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50Lm5hbWUiOiJzcGlyZS1hZ2VudCIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50LnVpZCI6IjE4ODFlODRmLWI2MTItMTFlOC1hNTQzLTA4MDAyNzJjNmU0MiIsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpzcGlyZTpzcGlyZS1hZ2VudCJ9.MKhBSMEoYvsdnosPGLklNxDLZFbacO7iMQLNSmYn1YKnX2Dep6eeeIBNMqe4LfH1jD4gmy3Y053H4cyM-uW6NkwM-ER_CyQWtd3blD4pGqu4vKGc3QizeNjcBkp6dzz_M5lDHQ-oqntaY8vNpJ8mGS8eYOiTIr_Fl4OO_t4m1Pxt8ommixmTiFH6Gx9har15qIvWmMN4y7TRjqgD7Q6XXCIpXWo2xski1frhfh5adl0xCaW97qCctAfhnLeHB0Jcug-zbo-BIoYqixXiRvqB8l9M5H5xj6jd3QwOxhiO8Xd6ZqDe_xD1bSZCWqboGpO953-2OvBlGyS3IojUl8VMtQ" +) + +func TestSATTokenClaims(t *testing.T) { + token, err := jwt.ParseSigned(rawToken) + require.NoError(t, err) + + claims := new(SATClaims) + err = token.UnsafeClaimsWithoutVerification(claims) + require.NoError(t, err) + + require.Equal(t, "kubernetes/serviceaccount", claims.Issuer) + require.Equal(t, "spire", claims.Namespace) + require.Equal(t, "spire-agent", claims.ServiceAccountName) +} + +func TestAgentID(t *testing.T) { + require.Equal(t, "spiffe://example.org/spire/agent/k8s_sat/production/1234", AgentID("example.org", "production", "1234")) +} diff --git a/pkg/server/catalog/catalog.go b/pkg/server/catalog/catalog.go index df5f773845..f4641d65e0 100644 --- a/pkg/server/catalog/catalog.go +++ b/pkg/server/catalog/catalog.go @@ -7,13 +7,14 @@ import ( "github.com/sirupsen/logrus" "github.com/spiffe/spire/pkg/server/plugin/datastore/sql" - aws_attestor "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/aws" - azure_attestor "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/azure" + aws_na "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/aws" + azure_na "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/azure" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/gcp" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/jointoken" + k8s_na "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/k8s" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/x509pop" - aws_resolver "github.com/spiffe/spire/pkg/server/plugin/noderesolver/aws" - azure_resolver "github.com/spiffe/spire/pkg/server/plugin/noderesolver/azure" + aws_nr "github.com/spiffe/spire/pkg/server/plugin/noderesolver/aws" + azure_nr "github.com/spiffe/spire/pkg/server/plugin/noderesolver/azure" "github.com/spiffe/spire/pkg/server/plugin/noderesolver/noop" "github.com/spiffe/spire/proto/server/datastore" "github.com/spiffe/spire/proto/server/keymanager" @@ -58,16 +59,17 @@ var ( "sql": datastore.NewBuiltIn(sql.New()), }, NodeAttestorType: { - "aws_iid": nodeattestor.NewBuiltIn(aws_attestor.NewIID()), + "aws_iid": nodeattestor.NewBuiltIn(aws_na.NewIID()), "join_token": nodeattestor.NewBuiltIn(jointoken.New()), "gcp_iit": nodeattestor.NewBuiltIn(gcp.NewIITAttestorPlugin()), "x509pop": nodeattestor.NewBuiltIn(x509pop.New()), - "azure_msi": nodeattestor.NewBuiltIn(azure_attestor.NewMSIAttestorPlugin()), + "azure_msi": nodeattestor.NewBuiltIn(azure_na.NewMSIAttestorPlugin()), + "k8s_sat": nodeattestor.NewBuiltIn(k8s_na.NewSATAttestorPlugin()), }, NodeResolverType: { "noop": noderesolver.NewBuiltIn(noop.New()), - "aws_iid": noderesolver.NewBuiltIn(aws_resolver.NewIIDResolverPlugin()), - "azure_msi": noderesolver.NewBuiltIn(azure_resolver.NewMSIResolverPlugin()), + "aws_iid": noderesolver.NewBuiltIn(aws_nr.NewIIDResolverPlugin()), + "azure_msi": noderesolver.NewBuiltIn(azure_nr.NewMSIResolverPlugin()), }, UpstreamCAType: { "disk": upstreamca.NewBuiltIn(upstreamca_disk.New()), diff --git a/pkg/server/plugin/nodeattestor/k8s/sat.go b/pkg/server/plugin/nodeattestor/k8s/sat.go new file mode 100644 index 0000000000..c3b75643ef --- /dev/null +++ b/pkg/server/plugin/nodeattestor/k8s/sat.go @@ -0,0 +1,313 @@ +package k8s + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io/ioutil" + "sync" + + "github.com/hashicorp/hcl" + "github.com/spiffe/spire/pkg/common/plugin/k8s" + "github.com/spiffe/spire/proto/common" + spi "github.com/spiffe/spire/proto/common/plugin" + "github.com/spiffe/spire/proto/server/nodeattestor" + "github.com/zeebo/errs" + "gopkg.in/square/go-jose.v2/jwt" +) + +const ( + pluginName = "k8s_sat" +) + +var ( + satError = errs.Class("k8s-sat") +) + +type ClusterConfig struct { + ServiceAccountKeyFile string `hcl:"service_account_key_file"` + ServiceAccountWhitelist []string `hcl:"service_account_whitelist"` +} + +type SATAttestorConfig struct { + Clusters map[string]*ClusterConfig `hcl:"clusters"` +} + +type clusterConfig struct { + serviceAccountKeys []crypto.PublicKey + serviceAccounts map[string]bool +} + +type satAttestorConfig struct { + trustDomain string + clusters map[string]*clusterConfig +} + +type SATAttestorPlugin struct { + mu sync.RWMutex + config *satAttestorConfig +} + +var _ nodeattestor.Plugin = (*SATAttestorPlugin)(nil) + +func NewSATAttestorPlugin() *SATAttestorPlugin { + return &SATAttestorPlugin{} +} + +func (p *SATAttestorPlugin) Attest(stream nodeattestor.Attest_PluginStream) error { + req, err := stream.Recv() + if err != nil { + return satError.Wrap(err) + } + + config, err := p.getConfig() + if err != nil { + return err + } + + if req.AttestedBefore { + return satError.New("node has already attested") + } + + if req.AttestationData == nil { + return satError.New("missing attestation data") + } + + if dataType := req.AttestationData.Type; dataType != pluginName { + return satError.New("unexpected attestation data type %q", dataType) + } + + if req.AttestationData.Data == nil { + return satError.New("missing attestation data payload") + } + + attestationData := new(k8s.SATAttestationData) + if err := json.Unmarshal(req.AttestationData.Data, attestationData); err != nil { + return satError.New("failed to unmarshal data payload: %v", err) + } + + if attestationData.Cluster == "" { + return satError.New("missing cluster in attestation data") + } + + if attestationData.UUID == "" { + return satError.New("missing UUID in attestation data") + } + + if attestationData.Token == "" { + return satError.New("missing token in attestation data") + } + + cluster := config.clusters[attestationData.Cluster] + if cluster == nil { + return satError.New("not configured for cluster %q", attestationData.Cluster) + } + + token, err := jwt.ParseSigned(attestationData.Token) + if err != nil { + return satError.New("unable to parse token: %v", err) + } + + claims, err := verifyTokenSignature(cluster, token) + if err != nil { + return err + } + + // TODO: service account tokens don't currently expire.... when they do, validate the time (with leeway) + if err := claims.Validate(jwt.Expected{ + Issuer: "kubernetes/serviceaccount", + }); err != nil { + return satError.New("unable to validate token claims: %v", err) + } + + if claims.Namespace == "" { + return satError.New("token missing namespace claim") + } + + if claims.ServiceAccountName == "" { + return satError.New("token missing service account name claim") + } + + serviceAccountName := fmt.Sprintf("%s:%s", claims.Namespace, claims.ServiceAccountName) + + if !cluster.serviceAccounts[serviceAccountName] { + return satError.New("%q is not a whitelisted service account", serviceAccountName) + } + + return stream.Send(&nodeattestor.AttestResponse{ + Valid: true, + BaseSPIFFEID: k8s.AgentID(config.trustDomain, attestationData.Cluster, attestationData.UUID), + Selectors: []*common.Selector{ + makeSelector("cluster", attestationData.Cluster), + makeSelector("agent_ns", claims.Namespace), + makeSelector("agent_sa", claims.ServiceAccountName), + }, + }) +} + +func (p *SATAttestorPlugin) Configure(ctx context.Context, req *spi.ConfigureRequest) (*spi.ConfigureResponse, error) { + hclConfig := new(SATAttestorConfig) + if err := hcl.Decode(hclConfig, req.Configuration); err != nil { + return nil, satError.New("unable to decode configuration: %v", err) + } + if req.GlobalConfig == nil { + return nil, satError.New("global configuration is required") + } + if req.GlobalConfig.TrustDomain == "" { + return nil, satError.New("global configuration missing trust domain") + } + + if len(hclConfig.Clusters) == 0 { + return nil, satError.New("configuration must have at least one cluster") + } + + config := &satAttestorConfig{ + trustDomain: req.GlobalConfig.TrustDomain, + clusters: make(map[string]*clusterConfig), + } + config.trustDomain = req.GlobalConfig.TrustDomain + for name, cluster := range hclConfig.Clusters { + if cluster.ServiceAccountKeyFile == "" { + return nil, satError.New("cluster %q configuration missing service account key file", name) + } + if len(cluster.ServiceAccountWhitelist) == 0 { + return nil, satError.New("cluster %q configuration must have at least one service account whitelisted", name) + } + + serviceAccountKeys, err := loadServiceAccountKeys(cluster.ServiceAccountKeyFile) + if err != nil { + return nil, satError.New("failed to load cluster %q service account keys from %q: %v", name, cluster.ServiceAccountKeyFile, err) + } + + if len(serviceAccountKeys) == 0 { + return nil, satError.New("cluster %q has no service account keys in %q", name, cluster.ServiceAccountKeyFile) + } + + serviceAccounts := make(map[string]bool) + for _, serviceAccount := range cluster.ServiceAccountWhitelist { + serviceAccounts[serviceAccount] = true + } + + config.clusters[name] = &clusterConfig{ + serviceAccountKeys: serviceAccountKeys, + serviceAccounts: serviceAccounts, + } + } + + p.setConfig(config) + return &spi.ConfigureResponse{}, nil +} + +func (p *SATAttestorPlugin) GetPluginInfo(context.Context, *spi.GetPluginInfoRequest) (*spi.GetPluginInfoResponse, error) { + return &spi.GetPluginInfoResponse{}, nil +} + +func (p *SATAttestorPlugin) getConfig() (*satAttestorConfig, error) { + p.mu.RLock() + defer p.mu.RUnlock() + if p.config == nil { + return nil, satError.New("not configured") + } + return p.config, nil +} + +func (p *SATAttestorPlugin) setConfig(config *satAttestorConfig) { + p.mu.Lock() + defer p.mu.Unlock() + p.config = config +} + +func verifyTokenSignature(cluster *clusterConfig, token *jwt.JSONWebToken) (claims *k8s.SATClaims, err error) { + var lastErr error + for _, key := range cluster.serviceAccountKeys { + claims := new(k8s.SATClaims) + if err := token.Claims(key, claims); err != nil { + lastErr = satError.New("unable to verify token: %v", err) + continue + } + return claims, nil + } + if lastErr == nil { + lastErr = satError.New("token signed by unknown authority") + } + return nil, lastErr +} + +func makeSelector(kind, value string) *common.Selector { + return &common.Selector{ + Type: pluginName, + Value: fmt.Sprintf("%s:%s", kind, value), + } +} + +func loadServiceAccountKeys(path string) ([]crypto.PublicKey, error) { + pemBytes, err := ioutil.ReadFile(path) + if err != nil { + return nil, satError.Wrap(err) + } + + var keys []crypto.PublicKey + for { + var pemBlock *pem.Block + pemBlock, pemBytes = pem.Decode(pemBytes) + if pemBlock != nil { + key, err := decodeKeyBlock(pemBlock) + if err != nil { + return nil, err + } + if key != nil { + keys = append(keys, key) + } + } + if len(pemBytes) == 0 { + return keys, nil + } + } +} + +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, satError.Wrap(err) + } + key = cert.PublicKey + case "RSA PUBLIC KEY": + rsaKey, err := x509.ParsePKCS1PublicKey(block.Bytes) + if err != nil { + return nil, satError.Wrap(err) + } + key = rsaKey + case "PUBLIC KEY": + pkixKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, satError.Wrap(err) + } + key = pkixKey + default: + return nil, nil + } + + if !isSupportedKey(key) { + return nil, satError.New("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/k8s/sat_test.go b/pkg/server/plugin/nodeattestor/k8s/sat_test.go new file mode 100644 index 0000000000..dc4ba14db4 --- /dev/null +++ b/pkg/server/plugin/nodeattestor/k8s/sat_test.go @@ -0,0 +1,480 @@ +package k8s + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "io/ioutil" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spiffe/spire/pkg/common/pemutil" + "github.com/spiffe/spire/proto/common" + "github.com/spiffe/spire/proto/common/plugin" + "github.com/spiffe/spire/proto/server/nodeattestor" + "github.com/stretchr/testify/suite" + jose "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +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 TestSATAttestorPlugin(t *testing.T) { + suite.Run(t, new(SATAttestorSuite)) +} + +type SATAttestorSuite struct { + suite.Suite + + dir string + fooKey *rsa.PrivateKey + fooSigner jose.Signer + barKey *ecdsa.PrivateKey + barSigner jose.Signer + bazSigner jose.Signer + attestor *nodeattestor.BuiltIn +} + +func (s *SATAttestorSuite) 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.barKey, err = pemutil.ParseECPrivateKey(barKeyPEM) + s.Require().NoError(err) + s.barSigner, err = jose.NewSigner(jose.SigningKey{ + Algorithm: jose.ES256, + Key: s.barKey, + }, nil) + + bazKey, err := pemutil.ParseECPrivateKey(bazKeyPEM) + s.Require().NoError(err) + s.bazSigner, err = jose.NewSigner(jose.SigningKey{ + Algorithm: jose.ES256, + Key: bazKey, + }, nil) + + s.dir, err = ioutil.TempDir("", "spire-server-nodeattestor-k8s-sat-") + s.Require().NoError(err) + + // 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 *SATAttestorSuite) TearDownSuite() { + os.RemoveAll(s.dir) +} + +func (s *SATAttestorSuite) SetupTest() { + s.attestor = s.newAttestor() + s.configureAttestor() +} + +func (s *SATAttestorSuite) TestAttestFailsWhenNotConfigured() { + resp, err := s.doAttestOnAttestor(s.newAttestor(), &nodeattestor.AttestRequest{}) + s.Require().EqualError(err, "k8s-sat: not configured") + s.Require().Nil(resp) +} + +func (s *SATAttestorSuite) TestAttestFailsWhenAttestedBefore() { + s.requireAttestError(&nodeattestor.AttestRequest{AttestedBefore: true}, + "k8s-sat: node has already attested") +} + +func (s *SATAttestorSuite) TestAttestFailsWithNoAttestationData() { + s.requireAttestError(&nodeattestor.AttestRequest{}, + "k8s-sat: missing attestation data") +} + +func (s *SATAttestorSuite) TestAttestFailsWithWrongAttestationDataType() { + s.requireAttestError(&nodeattestor.AttestRequest{ + AttestationData: &common.AttestationData{ + Type: "blah", + }, + }, `k8s-sat: unexpected attestation data type "blah"`) +} + +func (s *SATAttestorSuite) TestAttestFailsWithNoAttestationDataPayload() { + s.requireAttestError(&nodeattestor.AttestRequest{ + AttestationData: &common.AttestationData{ + Type: "k8s_sat", + }, + }, "k8s-sat: missing attestation data payload") +} + +func (s *SATAttestorSuite) TestAttestFailsWithMalformedAttestationDataPayload() { + s.requireAttestError(&nodeattestor.AttestRequest{ + AttestationData: &common.AttestationData{ + Type: "k8s_sat", + Data: []byte("{"), + }, + }, "k8s-sat: failed to unmarshal data payload") +} + +func (s *SATAttestorSuite) TestAttestFailsWithNoCluster() { + s.requireAttestError(makeAttestRequest("", "UUID", "TOKEN"), + "k8s-sat: missing cluster in attestation data") +} + +func (s *SATAttestorSuite) TestAttestFailsWithNoUUID() { + s.requireAttestError(makeAttestRequest("FOO", "", "TOKEN"), + "k8s-sat: missing UUID in attestation data") +} + +func (s *SATAttestorSuite) TestAttestFailsWithNoToken() { + s.requireAttestError(makeAttestRequest("FOO", "UUID", ""), + "k8s-sat: missing token in attestation data") +} + +func (s *SATAttestorSuite) TestAttestFailsWithMalformedToken() { + s.requireAttestError(makeAttestRequest("FOO", "UUID", "blah"), + "k8s-sat: unable to parse token") +} + +func (s *SATAttestorSuite) TestAttestFailsIfClusterNotConfigured() { + s.requireAttestError(makeAttestRequest("CLUSTER", "UUID", "blah"), + `k8s-sat: not configured for cluster "CLUSTER"`) +} + +func (s *SATAttestorSuite) 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(makeAttestRequest("FOO", "UUID", token), + "unable to verify token") +} + +func (s *SATAttestorSuite) TestAttestFailsWithInvalidIssuer() { + token, err := jwt.Signed(s.fooSigner).CompactSerialize() + s.Require().NoError(err) + s.requireAttestError(makeAttestRequest("FOO", "UUID", token), "invalid issuer claim") +} + +func (s *SATAttestorSuite) TestAttestFailsWithMissingNamespaceClaim() { + token := s.signToken(s.fooSigner, "", "") + s.requireAttestError(makeAttestRequest("FOO", "UUID", token), "token missing namespace claim") +} + +func (s *SATAttestorSuite) TestAttestFailsWithMissingServiceAccountNameClaim() { + token := s.signToken(s.fooSigner, "NAMESPACE", "") + s.requireAttestError(makeAttestRequest("FOO", "UUID", token), "token missing service account name claim") +} + +func (s *SATAttestorSuite) TestAttestFailsIfNamespaceNotWhitelisted() { + token := s.signToken(s.fooSigner, "NAMESPACE", "SERVICEACCOUNTNAME") + s.requireAttestError(makeAttestRequest("FOO", "UUID", token), `"NAMESPACE:SERVICEACCOUNTNAME" is not a whitelisted service account`) +} + +func (s *SATAttestorSuite) TestAttestFailsIfTokenSignatureCannotBeVerifiedByCluster() { + token := s.signToken(s.bazSigner, "NAMESPACE", "SERVICEACCOUNTNAME") + s.requireAttestError(makeAttestRequest("FOO", "UUID", token), "k8s-sat: unable to verify token") +} + +func (s *SATAttestorSuite) TestAttestSuccess() { + // Success with FOO signed token + resp, err := s.doAttest(s.signAttestRequest(s.fooSigner, "FOO", "NS1", "SA1")) + s.Require().NoError(err) + s.Require().NotNil(resp) + s.Require().True(resp.Valid) + s.Require().Equal(resp.BaseSPIFFEID, "spiffe://example.org/spire/agent/k8s_sat/FOO/UUID") + s.Require().Nil(resp.Challenge) + s.Require().Equal([]*common.Selector{ + {Type: "k8s_sat", Value: "cluster:FOO"}, + {Type: "k8s_sat", Value: "agent_ns:NS1"}, + {Type: "k8s_sat", Value: "agent_sa:SA1"}, + }, resp.Selectors) + + // Success with BAR signed token + resp, err = s.doAttest(s.signAttestRequest(s.barSigner, "BAR", "NS2", "SA2")) + s.Require().NoError(err) + s.Require().NotNil(resp) + s.Require().True(resp.Valid) + s.Require().Equal(resp.BaseSPIFFEID, "spiffe://example.org/spire/agent/k8s_sat/BAR/UUID") + s.Require().Nil(resp.Challenge) + s.Require().Equal([]*common.Selector{ + {Type: "k8s_sat", Value: "cluster:BAR"}, + {Type: "k8s_sat", Value: "agent_ns:NS2"}, + {Type: "k8s_sat", Value: "agent_sa:SA2"}, + }, resp.Selectors) +} + +func (s *SATAttestorSuite) TestConfigure() { + // malformed configuration + resp, err := s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: "blah", + }) + s.requireErrorContains(err, "k8s-sat: unable to decode configuration") + s.Require().Nil(resp) + + // missing global configuration + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{}) + s.Require().EqualError(err, "k8s-sat: global configuration is required") + s.Require().Nil(resp) + + // missing trust domain + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{}}) + s.Require().EqualError(err, "k8s-sat: global configuration missing trust domain") + s.Require().Nil(resp) + + // missing clusters + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: ``, + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{TrustDomain: "example.org"}, + }) + s.Require().EqualError(err, "k8s-sat: configuration must have at least one cluster") + s.Require().Nil(resp) + + // cluster missing service account key file + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: `clusters = { + "FOO" = {} + }`, + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{TrustDomain: "example.org"}, + }) + s.Require().EqualError(err, `k8s-sat: cluster "FOO" configuration missing service account key file`) + s.Require().Nil(resp) + + // cluster missing service account whitelist + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: fmt.Sprintf(`clusters = { + "FOO" = { + service_account_key_file = %q + } + }`, s.fooCertPath()), + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{TrustDomain: "example.org"}, + }) + s.Require().EqualError(err, `k8s-sat: cluster "FOO" configuration must have at least one service account whitelisted`) + s.Require().Nil(resp) + + // unable to load cluster service account keys + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: fmt.Sprintf(`clusters = { + "FOO" = { + service_account_key_file = %q + service_account_whitelist = ["A"] + } + }`, filepath.Join(s.dir, "missing.pem")), + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{TrustDomain: "example.org"}, + }) + s.requireErrorContains(err, `k8s-sat: failed to load cluster "FOO" service account keys`) + s.Require().Nil(resp) + + // no keys in PEM file + s.Require().NoError(ioutil.WriteFile(filepath.Join(s.dir, "nokeys.pem"), []byte{}, 0644)) + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: fmt.Sprintf(`clusters = { + "FOO" = { + service_account_key_file = %q + service_account_whitelist = ["A"] + } + }`, filepath.Join(s.dir, "nokeys.pem")), + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{TrustDomain: "example.org"}, + }) + s.requireErrorContains(err, `k8s-sat: cluster "FOO" has no service account keys in`) + s.Require().Nil(resp) + + // success with two CERT based key files + s.configureAttestor() +} + +func (s *SATAttestorSuite) TestServiceAccountKeyFileAlternateEncodings() { + fooPKCS1KeyPath := filepath.Join(s.dir, "foo-pkcs1.pem") + fooPKCS1Bytes := x509.MarshalPKCS1PublicKey(&s.fooKey.PublicKey) + s.Require().NoError(ioutil.WriteFile(fooPKCS1KeyPath, pem.EncodeToMemory(&pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: fooPKCS1Bytes, + }), 0644)) + + fooPKIXKeyPath := filepath.Join(s.dir, "foo-pkix.pem") + fooPKIXBytes, err := x509.MarshalPKIXPublicKey(s.fooKey.Public()) + s.Require().NoError(err) + s.Require().NoError(ioutil.WriteFile(fooPKIXKeyPath, pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: fooPKIXBytes, + }), 0644)) + + barPKIXKeyPath := filepath.Join(s.dir, "bar-pkix.pem") + barPKIXBytes, err := x509.MarshalPKIXPublicKey(s.barKey.Public()) + s.Require().NoError(err) + s.Require().NoError(ioutil.WriteFile(barPKIXKeyPath, pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: barPKIXBytes, + }), 0644)) + + _, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: fmt.Sprintf(`clusters = { + "FOO-PKCS1" = { + service_account_key_file = %q + service_account_whitelist = ["A"] + } + "FOO-PKIX" = { + service_account_key_file = %q + service_account_whitelist = ["A"] + } + "BAR-PKIX" = { + service_account_key_file = %q + service_account_whitelist = ["A"] + } + }`, fooPKCS1KeyPath, fooPKIXKeyPath, barPKIXKeyPath), + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{TrustDomain: "example.org"}, + }) + s.Require().NoError(err) +} + +func (s *SATAttestorSuite) TestGetPluginInfo() { + resp, err := s.attestor.GetPluginInfo(context.Background(), &plugin.GetPluginInfoRequest{}) + s.Require().NoError(err) + s.Require().Equal(resp, &plugin.GetPluginInfoResponse{}) +} + +func (s *SATAttestorSuite) signToken(signer jose.Signer, namespace, serviceAccountName string) string { + builder := jwt.Signed(signer) + + // build up standard claims + claims := jwt.Claims{ + Issuer: "kubernetes/serviceaccount", + } + builder = builder.Claims(claims) + builder = builder.Claims(map[string]interface{}{ + "kubernetes.io/serviceaccount/namespace": namespace, + "kubernetes.io/serviceaccount/service-account.name": serviceAccountName, + }) + + token, err := builder.CompactSerialize() + s.Require().NoError(err) + return token +} + +func (s *SATAttestorSuite) signAttestRequest(signer jose.Signer, cluster, namespace, serviceAccountName string) *nodeattestor.AttestRequest { + return makeAttestRequest(cluster, "UUID", s.signToken(signer, namespace, serviceAccountName)) +} + +func (s *SATAttestorSuite) newAttestor() *nodeattestor.BuiltIn { + attestor := NewSATAttestorPlugin() + return nodeattestor.NewBuiltIn(attestor) +} + +func (s *SATAttestorSuite) configureAttestor() { + resp, err := s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: fmt.Sprintf(` + clusters = { + "FOO" = { + service_account_key_file = %q + service_account_whitelist = ["NS1:SA1"] + } + "BAR" = { + service_account_key_file = %q + service_account_whitelist = ["NS2:SA2"] + } + } + `, s.fooCertPath(), s.barCertPath()), + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{TrustDomain: "example.org"}, + }) + s.Require().NoError(err) + s.Require().Equal(resp, &plugin.ConfigureResponse{}) +} + +func (s *SATAttestorSuite) doAttest(req *nodeattestor.AttestRequest) (*nodeattestor.AttestResponse, error) { + return s.doAttestOnAttestor(s.attestor, req) +} + +func (s *SATAttestorSuite) doAttestOnAttestor(attestor *nodeattestor.BuiltIn, req *nodeattestor.AttestRequest) (*nodeattestor.AttestResponse, error) { + stream, err := attestor.Attest(context.Background()) + s.Require().NoError(err) + + err = stream.Send(req) + s.Require().NoError(err) + + err = stream.CloseSend() + s.Require().NoError(err) + + return stream.Recv() +} + +func (s *SATAttestorSuite) requireAttestError(req *nodeattestor.AttestRequest, contains string) { + resp, err := s.doAttest(req) + s.requireErrorContains(err, contains) + s.Require().Nil(resp) +} + +func (s *SATAttestorSuite) requireErrorContains(err error, contains string) { + s.Require().Error(err) + s.Require().Contains(err.Error(), contains) +} + +func makeAttestRequest(cluster, uuid, token string) *nodeattestor.AttestRequest { + return &nodeattestor.AttestRequest{ + AttestationData: &common.AttestationData{ + Type: "k8s_sat", + Data: []byte(fmt.Sprintf(`{"cluster": %q, "uuid": %q, "token": %q}`, cluster, uuid, token)), + }, + } +} + +func (s *SATAttestorSuite) fooCertPath() string { + return filepath.Join(s.dir, "foo.pem") +} + +func (s *SATAttestorSuite) barCertPath() string { + return filepath.Join(s.dir, "bar.pem") +} + +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 + } + if err := ioutil.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), 0644); err != nil { + return err + } + return nil +}