From 11d8d149f0ba0fcdc91735129ba45e1280796547 Mon Sep 17 00:00:00 2001 From: Enrico Schiattarella Date: Fri, 20 Jul 2018 11:44:16 -0700 Subject: [PATCH 1/4] K8s server and agent nodeattestor plugin Signed-off-by: Enrico Schiattarella --- doc/plugin_agent_nodeattestor_k8s.md | 39 +++ doc/plugin_server_nodeattestor_k8s.md | 21 ++ pkg/agent/catalog/catalog.go | 6 +- pkg/agent/plugin/nodeattestor/k8s/k8s.go | 245 ++++++++++++++++ pkg/agent/plugin/nodeattestor/k8s/k8s_test.go | 235 ++++++++++++++++ pkg/common/plugin/k8s/k8s.go | 18 ++ pkg/server/catalog/catalog.go | 2 + pkg/server/plugin/nodeattestor/k8s/k8s.go | 214 ++++++++++++++ .../plugin/nodeattestor/k8s/k8s_test.go | 261 ++++++++++++++++++ test/fixture/nodeattestor/k8s/id-doc.pem | 15 + test/fixture/nodeattestor/k8s/id-key.pem | 5 + test/fixture/nodeattestor/k8s/k8s-ca.pem | 18 ++ 12 files changed, 1077 insertions(+), 2 deletions(-) create mode 100644 doc/plugin_agent_nodeattestor_k8s.md create mode 100644 doc/plugin_server_nodeattestor_k8s.md create mode 100644 pkg/agent/plugin/nodeattestor/k8s/k8s.go create mode 100644 pkg/agent/plugin/nodeattestor/k8s/k8s_test.go create mode 100644 pkg/common/plugin/k8s/k8s.go create mode 100644 pkg/server/plugin/nodeattestor/k8s/k8s.go create mode 100644 pkg/server/plugin/nodeattestor/k8s/k8s_test.go create mode 100644 test/fixture/nodeattestor/k8s/id-doc.pem create mode 100644 test/fixture/nodeattestor/k8s/id-key.pem create mode 100644 test/fixture/nodeattestor/k8s/k8s-ca.pem diff --git a/doc/plugin_agent_nodeattestor_k8s.md b/doc/plugin_agent_nodeattestor_k8s.md new file mode 100644 index 0000000000..b8afdfc102 --- /dev/null +++ b/doc/plugin_agent_nodeattestor_k8s.md @@ -0,0 +1,39 @@ +# Agent plugin: NodeAttestor "k8s" + +*Must be used in conjunction with the server-side k8s plugin* + +The `k8s` plugin retrieves an identity document from Kubernetes API Server and +uses it to prove its identity to a SPIRE server and receive a SVID. +The identity document consists of a x509 certificate signed by the Kubernetes +API Server Certificate Authority. The plugin owns the private key associated +with the identity document and is able to respond to proof-of-possession +challenges issued by the server plugin. + +In order to retrieve the identity document, the plugin needs user credentials +to access the Kubernetes API Server and submit a Certificate Signing Request (CSR). +The credentials consist of a private key and a certificate stored on disk. +It also needs a root certificate to validate the TLS certificate presented +by the Kubernetes API Server. + +The CSRs issued by the plugin follow the format used by Kubernetes Node Authorizer. +They are automatically approved if the correct RBAC roles and bindings are in place. +Alternatively, they can be approved manually by the Kubernetes administrator using +the command `kubectl certificate approve` + +The SPIFFE ID produced by the plugin is based on the common name of the certificate +and is in the form: + +``` +spiffe:///spire/agent/k8s/system/node/ +``` + +See this [design document](https://docs.google.com/document/d/14PFWpKHbXLxJwPn9NYYcUWGyO9d8HE1H_XAZ4Tz5K0E) +for more details. + +| Configuration | Description | Default | +| ------------- | ----------- | ----------------------- | +| `trust_domain` | The trust domain that the node belongs to. | | +| `k8s_private_key_path` | The path to the private key on disk (PEM encoded PKCS1 or PKCS8) used to authenticate the agent to the Kubernetes API Server| | +| `k8s_certificate_path` | The path to the certificate bundle on disk. Used to authenticate the agent to the Kubernetes API Server | | +| `k8s_ca_certificate_path` | The root certificate used to validate the certificate presented by the Kubernetes API Server | | +| `kubeconfig_path` | Optional. The path to the kubeconfig file containing Kubernetes cluster access information. If not provided, the plugin tries the paths listed in the environment variable KUBECONFIG. If KUBECONFIG is not set, the plugin tries the default location $HOME/.kube/config | | diff --git a/doc/plugin_server_nodeattestor_k8s.md b/doc/plugin_server_nodeattestor_k8s.md new file mode 100644 index 0000000000..b596960661 --- /dev/null +++ b/doc/plugin_server_nodeattestor_k8s.md @@ -0,0 +1,21 @@ +# Server plugin: NodeAttestor "k8s" + +*Must be used in conjunction with the agent-side k8s plugin* + +The `k8s` plugin attests nodes that have a valid certificate issued +by a Kubernetes Certificate Authority. It verifies that the certificate +is signed by a trusted CA and that the agent plugin has access to +the corresponding private key using a signature-based challenge. + +The SPIFFE ID produced by the plugin is based on the common name of the certificate +and is in the form: + +``` +spiffe:///spire/agent/k8s/system/node/ +``` + + +| Configuration | Description | Default | +| ------------- | ----------- | ----------------------- | +| `trust_domain` | The trust domain that the node belongs to. | | +| `ca_bundle_path` | The path to the trusted CA bundle on disk. The file must contain one or more PEM blocks forming the set of trusted root CA's for chain-of-trust verification. | | diff --git a/pkg/agent/catalog/catalog.go b/pkg/agent/catalog/catalog.go index 8ebc261829..1e35ed9077 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" + k8sna "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" + k8swa "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": nodeattestor.NewBuiltIn(k8sna.New()), }, WorkloadAttestorType: { - "k8s": workloadattestor.NewBuiltIn(k8s.New()), + "k8s": workloadattestor.NewBuiltIn(k8swa.New()), "unix": workloadattestor.NewBuiltIn(unix.New()), }, } diff --git a/pkg/agent/plugin/nodeattestor/k8s/k8s.go b/pkg/agent/plugin/nodeattestor/k8s/k8s.go new file mode 100644 index 0000000000..aefedda70c --- /dev/null +++ b/pkg/agent/plugin/nodeattestor/k8s/k8s.go @@ -0,0 +1,245 @@ +package k8s + +import ( + "context" + "crypto" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "sync" + + "github.com/hashicorp/hcl" + "github.com/spiffe/spire/pkg/common/plugin/k8s" + "github.com/spiffe/spire/pkg/common/plugin/x509pop" + "github.com/spiffe/spire/proto/agent/nodeattestor" + "github.com/spiffe/spire/proto/common" + "github.com/spiffe/spire/proto/common/plugin" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/cert" + "k8s.io/client-go/util/certificate/csr" +) + +const ( + pluginName = "k8s" +) + +type configData struct { + spiffeID string + privateKey crypto.PrivateKey + attestationData *common.AttestationData +} + +type K8sConfig struct { + TrustDomain string `hcl:"trust_domain"` + PrivateKeyPath string `hcl:"k8s_private_key_path"` + CertificatePath string `hcl:"k8s_certificate_path"` + K8sCACertPath string `hcl:"k8s_ca_certificate_path"` + KubeconfigPath string `hcl:"kubeconfig_path"` +} + +type K8sPlugin struct { + m sync.Mutex + c *K8sConfig + kubeClient kubernetes.Interface +} + +var _ nodeattestor.Plugin = (*K8sPlugin)(nil) + +func getAgentName() string { + name, err := os.Hostname() + if err != nil { + name = "unknown" + } + return name +} + +func getKubeClient(kubeConfigFilePath, clientCertFilePath, clientKeyFilePath, caCertFilePath string) (kubernetes.Interface, error) { + if kubeConfigFilePath == "" { + // Try KUBECONFIG env variable + kubeConfigFilePath = os.Getenv("KUBECONFIG") + if kubeConfigFilePath == "" { + // Still no luck, try default (home) + home := os.Getenv("HOME") + if home != "" { + kubeConfigFilePath = path.Join(home, ".kube", "config") + } + } + } + + if kubeConfigFilePath == "" { + return nil, fmt.Errorf("Unable to locate kubeconfig") + } + + config, err := clientcmd.BuildConfigFromFlags("", kubeConfigFilePath) + if err != nil { + return nil, fmt.Errorf("Error accessing kubeconfig %s: %v", kubeConfigFilePath, err) + } + + config.TLSClientConfig.CertFile = clientCertFilePath + config.TLSClientConfig.KeyFile = clientKeyFilePath + config.TLSClientConfig.CAFile = caCertFilePath + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("Error creating clientset: %v", err) + } + return clientset, nil +} + +func fetchK8sCert(kubeClient kubernetes.Interface) (*tls.Certificate, error) { + key, err := cert.MakeEllipticPrivateKeyPEM() + if err != nil { + return nil, fmt.Errorf("Error creating private key: %v", err) + } + + certsIntf := kubeClient.CertificatesV1beta1().CertificateSigningRequests() + cert, err := csr.RequestNodeCertificate(certsIntf, key, types.NodeName(getAgentName())) + if err != nil { + return nil, fmt.Errorf("Error getting certificate: %v", err) + } + + tlsCert, err := tls.X509KeyPair(cert, key) + if err != nil { + return nil, fmt.Errorf("Error forming x509 key pair: %v", err) + } + return &tlsCert, nil +} +func New() *K8sPlugin { + return &K8sPlugin{} +} + +func (p *K8sPlugin) FetchAttestationData(stream nodeattestor.FetchAttestationData_PluginStream) (err error) { + data, err := p.loadConfigData() + if err != nil { + return err + } + + // send the attestation data back to the agent + if err := stream.Send(&nodeattestor.FetchAttestationDataResponse{ + AttestationData: data.attestationData, + SpiffeId: data.spiffeID, + }); err != nil { + return err + } + + // receive challenge + resp, err := stream.Recv() + if err != nil { + return err + } + + challenge := new(x509pop.Challenge) + if err := json.Unmarshal(resp.Challenge, challenge); err != nil { + return fmt.Errorf("k8s node attestor: unable to unmarshal challenge: %v", err) + } + + // calculate and send the challenge response + response, err := x509pop.CalculateResponse(data.privateKey, challenge) + if err != nil { + return fmt.Errorf("k8s node attestor: failed to calculate challenge response: %v", err) + } + + responseBytes, err := json.Marshal(response) + if err != nil { + return fmt.Errorf("k8s node attestor: unable to marshal challenge response: %v", err) + } + + if err := stream.Send(&nodeattestor.FetchAttestationDataResponse{ + SpiffeId: data.spiffeID, + Response: responseBytes, + }); err != nil { + return err + } + + return nil +} + +func (p *K8sPlugin) Configure(ctx context.Context, req *plugin.ConfigureRequest) (*plugin.ConfigureResponse, error) { + // Parse HCL config payload into config struct + config := new(K8sConfig) + if err := hcl.Decode(config, req.Configuration); err != nil { + return nil, fmt.Errorf("k8s node attestor: unable to decode configuration: %v", err) + } + + if config.TrustDomain == "" { + return nil, errors.New("k8s node attestor: trust_domain is required") + } + if config.PrivateKeyPath == "" { + return nil, errors.New("k8s node attestor: private_key_path is required") + } + if config.CertificatePath == "" { + return nil, errors.New("k8s node attestor: certificate_path is required") + } + if config.K8sCACertPath == "" { + return nil, errors.New("k8s node attestor: ca_certificate_path is required") + } + + p.setConfig(config) + + if p.kubeClient == nil { + kubeClient, err := getKubeClient(config.KubeconfigPath, config.CertificatePath, config.PrivateKeyPath, config.K8sCACertPath) + if err != nil { + return nil, fmt.Errorf("Error creating Kubernetes client: %v", err) + } + p.kubeClient = kubeClient + } + + return &plugin.ConfigureResponse{}, nil +} + +func (p *K8sPlugin) GetPluginInfo(ctx context.Context, req *plugin.GetPluginInfoRequest) (*plugin.GetPluginInfoResponse, error) { + return &plugin.GetPluginInfoResponse{}, nil +} + +func (p *K8sPlugin) getConfig() *K8sConfig { + p.m.Lock() + defer p.m.Unlock() + return p.c +} + +func (p *K8sPlugin) setConfig(c *K8sConfig) { + p.m.Lock() + defer p.m.Unlock() + p.c = c + p.kubeClient = nil +} + +func (p *K8sPlugin) loadConfigData() (*configData, error) { + config := p.getConfig() + if config == nil { + return nil, errors.New("k8s node attestor: not configured") + } + + k8sCert, err := fetchK8sCert(p.kubeClient) + if err != nil { + return nil, fmt.Errorf("k8s node attestor: unable to retrieve identity document: %v", err) + } + + parsedCert, err := x509.ParseCertificate(k8sCert.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("k8s node attestor: error parsing identity document: %v", err) + } + + attestationDataBytes, err := json.Marshal(x509pop.AttestationData{ + Certificates: k8sCert.Certificate, + }) + if err != nil { + return nil, fmt.Errorf("k8s node attestor: unable to marshal attestation data: %v", err) + } + + return &configData{ + spiffeID: k8s.SpiffeID(config.TrustDomain, parsedCert), + privateKey: k8sCert.PrivateKey, + attestationData: &common.AttestationData{ + Type: pluginName, + Data: attestationDataBytes, + }, + }, nil +} diff --git a/pkg/agent/plugin/nodeattestor/k8s/k8s_test.go b/pkg/agent/plugin/nodeattestor/k8s/k8s_test.go new file mode 100644 index 0000000000..b4802a14f9 --- /dev/null +++ b/pkg/agent/plugin/nodeattestor/k8s/k8s_test.go @@ -0,0 +1,235 @@ +package k8s + +import ( + "context" + "crypto/rand" + "crypto/sha1" + "crypto/x509" + "encoding/json" + "fmt" + "math/big" + "testing" + + "github.com/spiffe/spire/pkg/common/plugin/x509pop" + "github.com/spiffe/spire/proto/agent/nodeattestor" + "github.com/spiffe/spire/proto/common/plugin" + "github.com/spiffe/spire/test/util" + "github.com/stretchr/testify/suite" + + certificates "k8s.io/api/certificates/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + k8scertutil "k8s.io/client-go/util/cert" + k8scsr "k8s.io/client-go/util/certificate/csr" +) + +const ( + trustDomain = "example.org" +) + +func (s *Suite) marshal(obj interface{}) []byte { + data, err := json.Marshal(obj) + s.Require().NoError(err) + return data +} + +func (s *Suite) unmarshal(data []byte, obj interface{}) { + s.Require().NoError(json.Unmarshal(data, obj)) +} + +func (s *Suite) errorContains(err error, substring string) { + s.Require().Error(err) + s.Require().Contains(err.Error(), substring) +} + +func (s *Suite) fetchAttestationData() (nodeattestor.FetchAttestationData_Stream, func()) { + stream, err := s.p.FetchAttestationData(context.Background()) + s.Require().NoError(err) + return stream, func() { + s.Require().NoError(stream.CloseSend()) + } +} + +func signCSR(csr *x509.CertificateRequest) (*x509.Certificate, error) { + caCert, caKey, err := util.LoadCAFixture() + if err != nil { + return nil, err + } + + template := &x509.Certificate{ + IsCA: false, + BasicConstraintsValid: true, + Subject: csr.Subject, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + SerialNumber: big.NewInt(1234), + + RawSubject: csr.RawSubject, + DNSNames: csr.DNSNames, + IPAddresses: csr.IPAddresses, + } + + pubBytes, err := x509.MarshalPKIXPublicKey(csr.PublicKey) + if err != nil { + return nil, fmt.Errorf("Failed to Marshall public key during Certificate signing: %v", err) + } + hash := sha1.Sum(pubBytes) + template.SubjectKeyId = hash[:] + + bytes, err := x509.CreateCertificate(rand.Reader, template, caCert, csr.PublicKey, caKey) + if err != nil { + return nil, fmt.Errorf("Failed Creating certificate %v", err) + } + cert, err := x509.ParseCertificates(bytes) + if err != nil { + return nil, fmt.Errorf("Failed to Parse cert after creation %v", err) + } + return cert[0], err +} + +type Suite struct { + suite.Suite + + p *nodeattestor.BuiltIn + client kubernetes.Interface + cert *x509.Certificate +} + +func (s *Suite) SetupTest() { + require := s.Require() + + // csr is enclosed by the two reactors + var csr *certificates.CertificateSigningRequest + + createReactor := func(action k8stesting.Action) (bool, runtime.Object, error) { + var err error + csr = action.(k8stesting.CreateAction).GetObject().(*certificates.CertificateSigningRequest) + if err != nil { + return false, nil, err + } + return true, csr, nil + } + + watchReactor := func(action k8stesting.Action) (bool, watch.Interface, error) { + var err error + watcher := watch.NewFakeWithChanSize(1, false) + req, err := k8scsr.ParseCSR(csr) + if err != nil { + return false, nil, err + } + s.cert, err = signCSR(req) + if err != nil { + return false, nil, err + } + csr.Status.Conditions = append(csr.Status.Conditions, certificates.CertificateSigningRequestCondition{Type: certificates.CertificateApproved}) + csr.Status.Certificate = k8scertutil.EncodeCertPEM(s.cert) + watcher.Modify(csr) + watcher.Stop() + return true, watcher, nil + } + + fakeClient := &fake.Clientset{} + fakeClient.AddReactor("create", "certificatesigningrequests", createReactor) + fakeClient.AddWatchReactor("certificatesigningrequests", watchReactor) + + s.client = fakeClient + p := New() + + p.setConfig(&K8sConfig{TrustDomain: trustDomain}) + p.kubeClient = fakeClient + s.p = nodeattestor.NewBuiltIn(p) + require.NotNil(s.p) +} + +func (s *Suite) TestGetPluginInfo() { + require := s.Require() + resp, err := s.p.GetPluginInfo(context.Background(), &plugin.GetPluginInfoRequest{}) + require.NoError(err) + require.Equal(resp, &plugin.GetPluginInfoResponse{}) +} + +func (s *Suite) TestConfigure() { + require := s.Require() + + type testParam struct { + trustDomain string + privateKeyPath string + certificatePath string + caCertificatePath string + expectedErr string + } + testCases := []testParam{ + {"", "pkp", "cp", "cacp", "trust_domain is required"}, + {"td", "", "cp", "cacp", "private_key_path is required"}, + {"td", "pkp", "", "cacp", "certificate_path is required"}, + {"td", "pkp", "cp", "", "ca_certificate_path is required"}, + {"td", "pkp", "cp", "cacp", "no such file or directory"}, + } + + for _, t := range testCases { + p := nodeattestor.NewBuiltIn(New()) + config := fmt.Sprintf(` + trust_domain = %q + k8s_private_key_path = %q + k8s_certificate_path = %q + k8s_ca_certificate_path = %q + kubeconfig_path = "doesnotexist"`, + t.trustDomain, t.privateKeyPath, t.certificatePath, t.caCertificatePath) + + resp, err := p.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: config, + }) + s.errorContains(err, t.expectedErr) + require.Nil(resp) + } +} + +func (s *Suite) TestFetchAttestationDataSuccess() { + require := s.Require() + + stream, done := s.fetchAttestationData() + defer done() + + spiffeID := "spiffe://" + trustDomain + "/spire/agent/k8s/system/node/" + getAgentName() + + // first response has the spiffeid and attestation data + resp, err := stream.Recv() + require.NoError(err) + require.NotNil(resp) + require.Equal(spiffeID, resp.SpiffeId) + require.Equal("k8s", resp.AttestationData.Type) + require.JSONEq(string(s.marshal(x509pop.AttestationData{ + Certificates: [][]byte{s.cert.Raw}, + })), string(resp.AttestationData.Data)) + require.Nil(resp.Response) + + // send a challenge + challenge, err := x509pop.GenerateChallenge(s.cert) + require.NoError(err) + challengeBytes, err := json.Marshal(challenge) + require.NoError(err) + err = stream.Send(&nodeattestor.FetchAttestationDataRequest{ + Challenge: challengeBytes, + }) + require.NoError(err) + + // recv the response + resp, err = stream.Recv() + require.NoError(err) + require.Equal(spiffeID, resp.SpiffeId) + require.Nil(resp.AttestationData) + require.NotEmpty(resp.Response) + + // verify signature + response := new(x509pop.Response) + s.unmarshal(resp.Response, response) + err = x509pop.VerifyChallengeResponse(s.cert.PublicKey, challenge, response) + require.NoError(err) +} + +func TestK8SAttestor(t *testing.T) { + suite.Run(t, new(Suite)) +} diff --git a/pkg/common/plugin/k8s/k8s.go b/pkg/common/plugin/k8s/k8s.go new file mode 100644 index 0000000000..684f92b221 --- /dev/null +++ b/pkg/common/plugin/k8s/k8s.go @@ -0,0 +1,18 @@ +package k8s + +import ( + "crypto/x509" + "net/url" + "path" + "strings" +) + +func SpiffeID(trustDomain string, cert *x509.Certificate) string { + agentID := strings.Replace(cert.Subject.CommonName, ":", "/", -1) + u := url.URL{ + Scheme: "spiffe", + Host: trustDomain, + Path: path.Join("spire", "agent", "k8s", agentID), + } + return u.String() +} diff --git a/pkg/server/catalog/catalog.go b/pkg/server/catalog/catalog.go index df5f773845..d2307cb996 100644 --- a/pkg/server/catalog/catalog.go +++ b/pkg/server/catalog/catalog.go @@ -11,6 +11,7 @@ import ( azure_attestor "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" + "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" @@ -63,6 +64,7 @@ var ( "gcp_iit": nodeattestor.NewBuiltIn(gcp.NewIITAttestorPlugin()), "x509pop": nodeattestor.NewBuiltIn(x509pop.New()), "azure_msi": nodeattestor.NewBuiltIn(azure_attestor.NewMSIAttestorPlugin()), + "k8s": nodeattestor.NewBuiltIn(k8s.New()), }, NodeResolverType: { "noop": noderesolver.NewBuiltIn(noop.New()), diff --git a/pkg/server/plugin/nodeattestor/k8s/k8s.go b/pkg/server/plugin/nodeattestor/k8s/k8s.go new file mode 100644 index 0000000000..eaff9337be --- /dev/null +++ b/pkg/server/plugin/nodeattestor/k8s/k8s.go @@ -0,0 +1,214 @@ +package k8s + +import ( + "context" + "crypto/x509" + "encoding/json" + "fmt" + "sync" + + "github.com/hashicorp/hcl" + "github.com/spiffe/spire/pkg/common/plugin/k8s" + "github.com/spiffe/spire/pkg/common/plugin/x509pop" + "github.com/spiffe/spire/pkg/common/util" + "github.com/spiffe/spire/proto/common" + spi "github.com/spiffe/spire/proto/common/plugin" + "github.com/spiffe/spire/proto/server/nodeattestor" +) + +const ( + pluginName = "k8s" +) + +type configuration struct { + trustDomain string + trustBundle *x509.CertPool +} + +type K8sConfig struct { + TrustDomain string `hcl:"trust_domain"` + CABundlePath string `hcl:"ca_bundle_path"` +} + +type K8sPlugin struct { + m sync.Mutex + c *configuration +} + +func New() *K8sPlugin { + return &K8sPlugin{} +} + +func (p *K8sPlugin) Attest(stream nodeattestor.Attest_PluginStream) error { + req, err := stream.Recv() + if err != nil { + return err + } + + c := p.getConfiguration() + if c == nil { + return newError("not configured") + } + + if req.AttestationData == nil { + return newError("attestation data not present") + } + + if dataType := req.AttestationData.Type; dataType != pluginName { + return newError("unexpected attestation data type %q", dataType) + } + + attestationData := new(x509pop.AttestationData) + if err := json.Unmarshal(req.AttestationData.Data, attestationData); err != nil { + return newError("failed to unmarshal data: %v", err) + } + + // build up leaf certificate and list of intermediates + if len(attestationData.Certificates) == 0 { + return newError("no certificate to attest") + } + leaf, err := x509.ParseCertificate(attestationData.Certificates[0]) + if err != nil { + return newError("unable to parse leaf certificate: %v", err) + } + intermediates := x509.NewCertPool() + for i, intermediateBytes := range attestationData.Certificates[1:] { + intermediate, err := x509.ParseCertificate(intermediateBytes) + if err != nil { + return newError("unable to parse intermediate certificate %d: %v", i, err) + } + intermediates.AddCert(intermediate) + } + + // verify the chain of trust + chains, err := leaf.Verify(x509.VerifyOptions{ + Intermediates: intermediates, + Roots: c.trustBundle, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }) + if err != nil { + return newError("certificate verification failed: %v", err) + } + + // now that the leaf certificate is trusted, issue a challenge to the node + // to prove possession of the private key. + challenge, err := x509pop.GenerateChallenge(leaf) + if err != nil { + return fmt.Errorf("unable to generate challenge: %v", err) + } + + challengeBytes, err := json.Marshal(challenge) + if err != nil { + return fmt.Errorf("unable to marshal challenge: %v", err) + } + + if err := stream.Send(&nodeattestor.AttestResponse{ + Challenge: challengeBytes, + }); err != nil { + return err + } + + // receive and validate the challenge response + responseReq, err := stream.Recv() + if err != nil { + return err + } + + response := new(x509pop.Response) + if err := json.Unmarshal(responseReq.Response, response); err != nil { + return newError("unable to unmarshal challenge response: %v", err) + } + + if err := x509pop.VerifyChallengeResponse(leaf.PublicKey, challenge, response); err != nil { + return newError("challenge response verification failed: %v", err) + } + + resp := &nodeattestor.AttestResponse{ + Valid: true, + BaseSPIFFEID: k8s.SpiffeID(c.trustDomain, leaf), + Selectors: buildSelectors(leaf, chains), + } + + if err := stream.Send(resp); err != nil { + return err + } + + return nil +} + +func (p *K8sPlugin) Configure(ctx context.Context, req *spi.ConfigureRequest) (*spi.ConfigureResponse, error) { + config := new(K8sConfig) + if err := hcl.Decode(config, req.Configuration); err != nil { + return nil, newError("unable to decode configuration: %v", err) + } + + if config.TrustDomain == "" { + return nil, newError("trust_domain is required") + } + if config.CABundlePath == "" { + return nil, newError("ca_bundle_path is required") + } + + trustBundle, err := util.LoadCertPool(config.CABundlePath) + if err != nil { + return nil, newError("unable to load trust bundle: %v", err) + } + + p.setConfiguration(&configuration{ + trustDomain: config.TrustDomain, + trustBundle: trustBundle, + }) + + return &spi.ConfigureResponse{}, nil +} + +func (*K8sPlugin) GetPluginInfo(context.Context, *spi.GetPluginInfoRequest) (*spi.GetPluginInfoResponse, error) { + return &spi.GetPluginInfoResponse{}, nil +} + +func (p *K8sPlugin) getConfiguration() *configuration { + p.m.Lock() + defer p.m.Unlock() + return p.c +} + +func (p *K8sPlugin) setConfiguration(c *configuration) { + p.m.Lock() + defer p.m.Unlock() + p.c = c +} + +func newError(format string, args ...interface{}) error { + return fmt.Errorf("k8s node attestor: "+format, args...) +} + +func buildSelectors(leaf *x509.Certificate, chains [][]*x509.Certificate) []*common.Selector { + selectors := []*common.Selector{} + + if leaf.Subject.CommonName != "" { + selectors = append(selectors, &common.Selector{ + Type: "k8s", Value: "subject:cn:" + leaf.Subject.CommonName, + }) + } + + // Used to avoid duplicating selectors. + fingerprints := map[string]*x509.Certificate{} + for _, chain := range chains { + // Iterate over all the certs in the chain (skip leaf at the 0 index) + for _, cert := range chain[1:] { + fp := x509pop.Fingerprint(cert) + // If the same fingerprint is generated, continue with the next certificate, because + // a selector should have been already created for it. + if _, ok := fingerprints[fp]; ok { + continue + } + fingerprints[fp] = cert + + selectors = append(selectors, &common.Selector{ + Type: "k8s", Value: "ca:fingerprint:" + fp, + }) + } + } + + return selectors +} diff --git a/pkg/server/plugin/nodeattestor/k8s/k8s_test.go b/pkg/server/plugin/nodeattestor/k8s/k8s_test.go new file mode 100644 index 0000000000..f263ba69a6 --- /dev/null +++ b/pkg/server/plugin/nodeattestor/k8s/k8s_test.go @@ -0,0 +1,261 @@ +package k8s + +import ( + "context" + "crypto" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/spiffe/spire/pkg/common/plugin/x509pop" + "github.com/spiffe/spire/proto/common" + "github.com/spiffe/spire/proto/common/plugin" + "github.com/spiffe/spire/proto/server/nodeattestor" + "github.com/spiffe/spire/test/fixture" + "github.com/spiffe/spire/test/util" + "github.com/stretchr/testify/suite" +) + +const ( + trustDomain = "example.org" +) + +func (s *Suite) attest() (nodeattestor.Attest_Stream, func()) { + stream, err := s.p.Attest(context.Background()) + s.Require().NoError(err) + return stream, func() { + s.Require().NoError(stream.CloseSend()) + } +} + +func (s *Suite) marshal(obj interface{}) []byte { + data, err := json.Marshal(obj) + s.Require().NoError(err) + return data +} + +func (s *Suite) unmarshal(data []byte, obj interface{}) { + s.Require().NoError(json.Unmarshal(data, obj)) +} + +func (s *Suite) errorContains(err error, substring string) { + s.Require().Error(err) + s.Require().Contains(err.Error(), substring) +} + +func TestK8sAttestor(t *testing.T) { + suite.Run(t, new(Suite)) +} + +type Suite struct { + suite.Suite + + p *nodeattestor.BuiltIn + idKey crypto.PrivateKey + idDoc *x509.Certificate + caCert *x509.Certificate +} + +func (s *Suite) SetupTest() { + require := s.Require() + idDocPath := fixture.Join("nodeattestor", "k8s", "id-doc.pem") + idKeyPath := fixture.Join("nodeattestor", "k8s", "id-key.pem") + caBundlePath := fixture.Join("nodeattestor", "k8s", "k8s-ca.pem") + + s.p = nodeattestor.NewBuiltIn(New()) + config := &plugin.ConfigureRequest{ + Configuration: fmt.Sprintf(` + trust_domain = %q + ca_bundle_path = %q`, + trustDomain, caBundlePath), + } + + resp, err := s.p.Configure(context.Background(), config) + require.NoError(err) + require.Equal(resp, &plugin.ConfigureResponse{}) + + kp, err := tls.LoadX509KeyPair(idDocPath, idKeyPath) + require.NoError(err) + s.idKey = kp.PrivateKey + s.idDoc, err = x509.ParseCertificate(kp.Certificate[0]) + s.caCert, err = util.LoadCert(caBundlePath) + require.NoError(err) +} + +func (s *Suite) TestConfigure() { + require := s.Require() + + type testParam struct { + trustDomain string + caBundlePath string + expectedErr string + } + + // negative test cases + testCases := []testParam{ + {"", "cabp", "trust_domain is required"}, + {"td", "", "ca_bundle_path is required"}, + {"td", "cabp", "unable to load trust bundle"}, + } + + for _, t := range testCases { + p := nodeattestor.NewBuiltIn(New()) + config := fmt.Sprintf(` + trust_domain = %q + ca_bundle_path = %q`, + t.trustDomain, t.caBundlePath) + + resp, err := p.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: config, + }) + s.errorContains(err, t.expectedErr) + require.Nil(resp) + } +} + +func (s *Suite) TestGetPluginInfo() { + require := s.Require() + + p := New() + + resp, err := p.GetPluginInfo(context.Background(), &plugin.GetPluginInfoRequest{}) + require.NoError(err) + require.Equal(resp, &plugin.GetPluginInfoResponse{}) +} + +func (s *Suite) TestAttestSuccess() { + require := s.Require() + + stream, done := s.attest() + defer done() + + // send down good attestation data + attestationData := &x509pop.AttestationData{ + Certificates: [][]byte{s.idDoc.Raw}, + } + err := stream.Send(&nodeattestor.AttestRequest{ + AttestationData: &common.AttestationData{ + Type: "k8s", + Data: s.marshal(attestationData), + }, + }) + require.NoError(err) + + // receive and parse challenge + resp, err := stream.Recv() + require.NoError(err) + require.Equal("", resp.BaseSPIFFEID) + s.False(resp.Valid) + s.NotEmpty(resp.Challenge) + + challenge := new(x509pop.Challenge) + s.unmarshal(resp.Challenge, challenge) + + // calculate and send the response + response, err := x509pop.CalculateResponse(s.idKey, challenge) + require.NoError(err) + err = stream.Send(&nodeattestor.AttestRequest{ + Response: s.marshal(response), + }) + require.NoError(err) + + // receive the attestation result + resp, err = stream.Recv() + require.NoError(err) + s.True(resp.Valid) + agentID := strings.Replace(s.idDoc.Subject.CommonName, ":", "/", -1) + require.Equal("spiffe://"+trustDomain+"/spire/agent/k8s/"+agentID, resp.BaseSPIFFEID) + require.Nil(resp.Challenge) + require.Len(resp.Selectors, 2) + require.EqualValues([]*common.Selector{ + {Type: "k8s", Value: "subject:cn:system:node:node1"}, + {Type: "k8s", Value: "ca:fingerprint:" + x509pop.Fingerprint(s.caCert)}, + }, resp.Selectors) +} + +func (s *Suite) TestAttestFailure() { + require := s.Require() + + makeData := func(attestationData *x509pop.AttestationData) *common.AttestationData { + return &common.AttestationData{ + Type: "k8s", + Data: s.marshal(attestationData), + } + } + + attestFails := func(attestationData *common.AttestationData, expected string) { + stream, done := s.attest() + defer done() + + require.NoError(stream.Send(&nodeattestor.AttestRequest{ + AttestationData: attestationData, + })) + + resp, err := stream.Recv() + s.errorContains(err, expected) + require.Nil(resp) + } + + challengeResponseFails := func(response string, expected string) { + stream, done := s.attest() + defer done() + + require.NoError(stream.Send(&nodeattestor.AttestRequest{ + AttestationData: makeData(&x509pop.AttestationData{ + Certificates: [][]byte{s.idDoc.Raw}, + }), + })) + + resp, err := stream.Recv() + require.NoError(err) + s.NotNil(resp) + + require.NoError(stream.Send(&nodeattestor.AttestRequest{ + Response: []byte(response), + })) + + resp, err = stream.Recv() + s.errorContains(err, expected) + require.Nil(resp) + } + + // not configured yet + stream, err := nodeattestor.NewBuiltIn(New()).Attest(context.Background()) + require.NoError(err) + defer stream.CloseSend() + require.NoError(stream.Send(&nodeattestor.AttestRequest{})) + _, err = stream.Recv() + require.EqualError(err, "k8s node attestor: not configured") + + // unexpected data type + attestFails(&common.AttestationData{Type: "foo"}, + "k8s node attestor: unexpected attestation data type \"foo\"") + + // malformed data + attestFails(&common.AttestationData{Type: "k8s"}, + "k8s node attestor: failed to unmarshal data") + + // no identity doc + attestFails(makeData(&x509pop.AttestationData{}), + "k8s node attestor: no certificate to attest") + + // malformed identity doc + attestFails(makeData(&x509pop.AttestationData{Certificates: [][]byte{{0x00}}}), + "k8s node attestor: unable to parse leaf certificate") + + // identity doc signed by unknown authority + unauthCertPath := fixture.Join("nodeattestor", "x509pop", "root-crt.pem") + unauthCert, err := util.LoadCert(unauthCertPath) + require.NoError(err) + attestFails(makeData(&x509pop.AttestationData{Certificates: [][]byte{unauthCert.Raw}}), + "k8s node attestor: certificate verification failed") + + // malformed challenge response + challengeResponseFails("", "k8s node attestor: unable to unmarshal challenge response") + + // invalid response + challengeResponseFails("{}", "k8s node attestor: challenge response verification failed") +} diff --git a/test/fixture/nodeattestor/k8s/id-doc.pem b/test/fixture/nodeattestor/k8s/id-doc.pem new file mode 100644 index 0000000000..63b3ebc560 --- /dev/null +++ b/test/fixture/nodeattestor/k8s/id-doc.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICXzCCAUegAwIBAgIUPHlRsHbx+Ucd2yVO9SGtWEQXPGcwDQYJKoZIhvcNAQEL +BQAwFTETMBEGA1UEAxMKbWluaWt1YmVDQTAeFw0xODA4MDYxNzI0MDBaFw0xOTA4 +MDYxNzI0MDBaMDMxFTATBgNVBAoTDHN5c3RlbTpub2RlczEaMBgGA1UEAxMRc3lz +dGVtOm5vZGU6bm9kZTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAT6riE0Twq7 +A7cJMjfxp9XJCz1G1DzCIXcDUvVZsbLhSq95SihjdDVYzMkS9hLzDX43aP3CWSkc +Nif2N2lGhEjSo1QwUjAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUH +AwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUyETFhv74KqQQphoL4C7VyPThHSEw +DQYJKoZIhvcNAQELBQADggEBAJwnYlE8V2syo7+2LfeUHSoIzweCI/VFtb8mLTb9 +xhCKMgOqbk4dE7iNFmCdkBZrEin0VPfE4XIlKLROTqHDL5gXS/ceimojOfhgsSFo +sTwSSBlRx0LDzutXLtZ7oDDR61r1mEzMnkpCzBNs5QjyFNvgcy2hO4wMDolpiFLL +M/RjsSxmv5feZrNtRnMEh0JMkwBIVD9NVNDQZLsrEGtMcqSonIsassl3EJW91PEV +Hjjsapnf3POXI2+sXLrk0tKkf5Xz0REaScV266/xBhY5+YKJ243s9xdi13HptZIt +TZz8N6nKrC2QVZD4DyhdpAmwDT+sl8YpdDTtkpvwCz2pblU= +-----END CERTIFICATE----- diff --git a/test/fixture/nodeattestor/k8s/id-key.pem b/test/fixture/nodeattestor/k8s/id-key.pem new file mode 100644 index 0000000000..256e639327 --- /dev/null +++ b/test/fixture/nodeattestor/k8s/id-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIPjhFjgMe043nn/TejL70WoVc0J1WqIJHaQsmOGPJ4AUoAoGCCqGSM49 +AwEHoUQDQgAE+q4hNE8KuwO3CTI38afVyQs9RtQ8wiF3A1L1WbGy4UqveUooY3Q1 +WMzJEvYS8w1+N2j9wlkpHDYn9jdpRoRI0g== +-----END EC PRIVATE KEY----- diff --git a/test/fixture/nodeattestor/k8s/k8s-ca.pem b/test/fixture/nodeattestor/k8s/k8s-ca.pem new file mode 100644 index 0000000000..ae3a627e0d --- /dev/null +++ b/test/fixture/nodeattestor/k8s/k8s-ca.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC5zCCAc+gAwIBAgIBATANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwptaW5p +a3ViZUNBMB4XDTE3MDMwMjE4NTUwOFoXDTI3MDIyODE4NTUwOFowFTETMBEGA1UE +AxMKbWluaWt1YmVDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK+g +3lyCFaxO4qteATc18tCD/8OLWcDJk7tSS8MuqzskT5ngFt/r1heFdcWnEPuBrB5O +zRxx/JnXLBtprwgh7Xw1YGXpQsDopYZl6kypgMav0vNZHn2A6cTYnnvdb0MCFELU +cbpph731b7wggTjMING478L+/hHkUjNqIW0fMokAF8ZSSoC7Rln37JnmYLnE95en +7g1LAjngR+YBgNca5h2uItfUbKAkxwa4woRCRI91ujfIFIz891l8mtgmDmP1dSIw +uxF/QbpA8WCrWKhKJ3L6Kktqo8Iw0W/FDtU5iXrL1WjOdsJ3TeLNsvldKQwzgram +ix1kJii6riw5NiB0bcMCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgKkMB0GA1UdJQQW +MBQGCCsGAQUFBwMCBggrBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQB7gqOptKoieoQEZ21uGbgEOnWvHWO+hxbtDsFEzokC7RTDVREs +pngQE6kARNV77Xs6WyjonFaNdFWiLVxncR+RgMaL5okMvsjxNKZY0U9ipsde0Wk2 +VUliqGm4ojejGnf45xIvbkHoNGVYE7388duHjEw+OwD0kymiZgniVnvZll3j0Rge +NjSPzLu8A6Fkcm87eqxgXvRcU1OHpMyLefQ1BzWYABEx/cs6t96Tb+jXtpLsrazB +gh1HB/amIexRg3+ua0qxyMx/sSfwYJy0GYNT/iNH/T/0o2jIL88AqWpXOPpMUSUj +O6xSM0gA/7y+5H5jc7QKTPDxXOTXpyo0dKAr +-----END CERTIFICATE----- From 9ddd458c6a5da328d0a9169630ec950f6726aab7 Mon Sep 17 00:00:00 2001 From: Andrew Harding Date: Thu, 4 Oct 2018 16:10:28 -0600 Subject: [PATCH 2/4] switch k8s node attestor to use service account token Signed-off-by: Andrew Harding --- doc/plugin_agent_nodeattestor_k8s.md | 39 -- doc/plugin_agent_nodeattestor_k8s_sat.md | 30 ++ doc/plugin_server_nodeattestor_k8s.md | 21 - doc/plugin_server_nodeattestor_k8s_sat.md | 52 ++ doc/spire_agent.md | 1 + doc/spire_server.md | 1 + pkg/agent/catalog/catalog.go | 8 +- pkg/agent/plugin/nodeattestor/k8s/k8s.go | 245 --------- pkg/agent/plugin/nodeattestor/k8s/k8s_test.go | 235 --------- pkg/agent/plugin/nodeattestor/k8s/sat.go | 139 ++++++ pkg/agent/plugin/nodeattestor/k8s/sat_test.go | 152 ++++++ pkg/common/plugin/k8s/k8s.go | 18 - pkg/common/plugin/k8s/sat.go | 37 ++ pkg/common/plugin/k8s/sat_test.go | 25 + pkg/server/catalog/catalog.go | 20 +- pkg/server/plugin/nodeattestor/k8s/k8s.go | 214 -------- .../plugin/nodeattestor/k8s/k8s_test.go | 261 ---------- pkg/server/plugin/nodeattestor/k8s/sat.go | 307 ++++++++++++ .../plugin/nodeattestor/k8s/sat_test.go | 470 ++++++++++++++++++ test/fixture/nodeattestor/k8s/id-doc.pem | 15 - test/fixture/nodeattestor/k8s/id-key.pem | 5 - test/fixture/nodeattestor/k8s/k8s-ca.pem | 18 - 22 files changed, 1228 insertions(+), 1085 deletions(-) delete mode 100644 doc/plugin_agent_nodeattestor_k8s.md create mode 100644 doc/plugin_agent_nodeattestor_k8s_sat.md delete mode 100644 doc/plugin_server_nodeattestor_k8s.md create mode 100644 doc/plugin_server_nodeattestor_k8s_sat.md delete mode 100644 pkg/agent/plugin/nodeattestor/k8s/k8s.go delete mode 100644 pkg/agent/plugin/nodeattestor/k8s/k8s_test.go create mode 100644 pkg/agent/plugin/nodeattestor/k8s/sat.go create mode 100644 pkg/agent/plugin/nodeattestor/k8s/sat_test.go delete mode 100644 pkg/common/plugin/k8s/k8s.go create mode 100644 pkg/common/plugin/k8s/sat.go create mode 100644 pkg/common/plugin/k8s/sat_test.go delete mode 100644 pkg/server/plugin/nodeattestor/k8s/k8s.go delete mode 100644 pkg/server/plugin/nodeattestor/k8s/k8s_test.go create mode 100644 pkg/server/plugin/nodeattestor/k8s/sat.go create mode 100644 pkg/server/plugin/nodeattestor/k8s/sat_test.go delete mode 100644 test/fixture/nodeattestor/k8s/id-doc.pem delete mode 100644 test/fixture/nodeattestor/k8s/id-key.pem delete mode 100644 test/fixture/nodeattestor/k8s/k8s-ca.pem diff --git a/doc/plugin_agent_nodeattestor_k8s.md b/doc/plugin_agent_nodeattestor_k8s.md deleted file mode 100644 index b8afdfc102..0000000000 --- a/doc/plugin_agent_nodeattestor_k8s.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent plugin: NodeAttestor "k8s" - -*Must be used in conjunction with the server-side k8s plugin* - -The `k8s` plugin retrieves an identity document from Kubernetes API Server and -uses it to prove its identity to a SPIRE server and receive a SVID. -The identity document consists of a x509 certificate signed by the Kubernetes -API Server Certificate Authority. The plugin owns the private key associated -with the identity document and is able to respond to proof-of-possession -challenges issued by the server plugin. - -In order to retrieve the identity document, the plugin needs user credentials -to access the Kubernetes API Server and submit a Certificate Signing Request (CSR). -The credentials consist of a private key and a certificate stored on disk. -It also needs a root certificate to validate the TLS certificate presented -by the Kubernetes API Server. - -The CSRs issued by the plugin follow the format used by Kubernetes Node Authorizer. -They are automatically approved if the correct RBAC roles and bindings are in place. -Alternatively, they can be approved manually by the Kubernetes administrator using -the command `kubectl certificate approve` - -The SPIFFE ID produced by the plugin is based on the common name of the certificate -and is in the form: - -``` -spiffe:///spire/agent/k8s/system/node/ -``` - -See this [design document](https://docs.google.com/document/d/14PFWpKHbXLxJwPn9NYYcUWGyO9d8HE1H_XAZ4Tz5K0E) -for more details. - -| Configuration | Description | Default | -| ------------- | ----------- | ----------------------- | -| `trust_domain` | The trust domain that the node belongs to. | | -| `k8s_private_key_path` | The path to the private key on disk (PEM encoded PKCS1 or PKCS8) used to authenticate the agent to the Kubernetes API Server| | -| `k8s_certificate_path` | The path to the certificate bundle on disk. Used to authenticate the agent to the Kubernetes API Server | | -| `k8s_ca_certificate_path` | The root certificate used to validate the certificate presented by the Kubernetes API Server | | -| `kubeconfig_path` | Optional. The path to the kubeconfig file containing Kubernetes cluster access information. If not provided, the plugin tries the paths listed in the environment variable KUBECONFIG. If KUBECONFIG is not set, the plugin tries the default location $HOME/.kube/config | | diff --git a/doc/plugin_agent_nodeattestor_k8s_sat.md b/doc/plugin_agent_nodeattestor_k8s_sat.md new file mode 100644 index 0000000000..c12e5c1e55 --- /dev/null +++ b/doc/plugin_agent_nodeattestor_k8s_sat.md @@ -0,0 +1,30 @@ +# 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 SPIFFE ID with the form: + +``` +spiffe:///spire/agent/k8s_sat/ +``` + +The main configuration accepts the following values: + +| Configuration | Description | Default | +| --------------- | ----------- | ----------------------- | +| `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 { + } + } +``` diff --git a/doc/plugin_server_nodeattestor_k8s.md b/doc/plugin_server_nodeattestor_k8s.md deleted file mode 100644 index b596960661..0000000000 --- a/doc/plugin_server_nodeattestor_k8s.md +++ /dev/null @@ -1,21 +0,0 @@ -# Server plugin: NodeAttestor "k8s" - -*Must be used in conjunction with the agent-side k8s plugin* - -The `k8s` plugin attests nodes that have a valid certificate issued -by a Kubernetes Certificate Authority. It verifies that the certificate -is signed by a trusted CA and that the agent plugin has access to -the corresponding private key using a signature-based challenge. - -The SPIFFE ID produced by the plugin is based on the common name of the certificate -and is in the form: - -``` -spiffe:///spire/agent/k8s/system/node/ -``` - - -| Configuration | Description | Default | -| ------------- | ----------- | ----------------------- | -| `trust_domain` | The trust domain that the node belongs to. | | -| `ca_bundle_path` | The path to the trusted CA bundle on disk. The file must contain one or more PEM blocks forming the set of trusted root CA's for chain-of-trust verification. | | diff --git a/doc/plugin_server_nodeattestor_k8s_sat.md b/doc/plugin_server_nodeattestor_k8s_sat.md new file mode 100644 index 0000000000..a314f74c28 --- /dev/null +++ b/doc/plugin_server_nodeattestor_k8s_sat.md @@ -0,0 +1,52 @@ +# 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 for 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 of type `k8s_sat` for each node: + +| Value | Example | Description | +| --------------------------- | ----------------------------- | ------------------------------------------ | +| `cluster-name` | `cluster-name:MyCluster` | Name of the cluster (from the plugin config) used to verify the token signature | +| `service-account:namespace` | `service-account:production` | Namespace of the service account | +| `service-account:name` | `service-account:spire-agent` | Name of the service account | + 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 1e35ed9077..221f6ba4f9 100644 --- a/pkg/agent/catalog/catalog.go +++ b/pkg/agent/catalog/catalog.go @@ -12,9 +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" - k8sna "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/k8s" + k8s_na "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/k8s" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/x509pop" - k8swa "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" @@ -54,10 +54,10 @@ var ( "gcp_iit": nodeattestor.NewBuiltIn(gcp.NewIITAttestorPlugin()), "x509pop": nodeattestor.NewBuiltIn(x509pop.New()), "azure_msi": nodeattestor.NewBuiltIn(azure.NewMSIAttestorPlugin()), - "k8s": nodeattestor.NewBuiltIn(k8sna.New()), + "k8s_sat": nodeattestor.NewBuiltIn(k8s_na.NewSATAttestorPlugin()), }, WorkloadAttestorType: { - "k8s": workloadattestor.NewBuiltIn(k8swa.New()), + "k8s": workloadattestor.NewBuiltIn(k8s_wa.New()), "unix": workloadattestor.NewBuiltIn(unix.New()), }, } diff --git a/pkg/agent/plugin/nodeattestor/k8s/k8s.go b/pkg/agent/plugin/nodeattestor/k8s/k8s.go deleted file mode 100644 index aefedda70c..0000000000 --- a/pkg/agent/plugin/nodeattestor/k8s/k8s.go +++ /dev/null @@ -1,245 +0,0 @@ -package k8s - -import ( - "context" - "crypto" - "crypto/tls" - "crypto/x509" - "encoding/json" - "errors" - "fmt" - "os" - "path" - "sync" - - "github.com/hashicorp/hcl" - "github.com/spiffe/spire/pkg/common/plugin/k8s" - "github.com/spiffe/spire/pkg/common/plugin/x509pop" - "github.com/spiffe/spire/proto/agent/nodeattestor" - "github.com/spiffe/spire/proto/common" - "github.com/spiffe/spire/proto/common/plugin" - - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/clientcmd" - "k8s.io/client-go/util/cert" - "k8s.io/client-go/util/certificate/csr" -) - -const ( - pluginName = "k8s" -) - -type configData struct { - spiffeID string - privateKey crypto.PrivateKey - attestationData *common.AttestationData -} - -type K8sConfig struct { - TrustDomain string `hcl:"trust_domain"` - PrivateKeyPath string `hcl:"k8s_private_key_path"` - CertificatePath string `hcl:"k8s_certificate_path"` - K8sCACertPath string `hcl:"k8s_ca_certificate_path"` - KubeconfigPath string `hcl:"kubeconfig_path"` -} - -type K8sPlugin struct { - m sync.Mutex - c *K8sConfig - kubeClient kubernetes.Interface -} - -var _ nodeattestor.Plugin = (*K8sPlugin)(nil) - -func getAgentName() string { - name, err := os.Hostname() - if err != nil { - name = "unknown" - } - return name -} - -func getKubeClient(kubeConfigFilePath, clientCertFilePath, clientKeyFilePath, caCertFilePath string) (kubernetes.Interface, error) { - if kubeConfigFilePath == "" { - // Try KUBECONFIG env variable - kubeConfigFilePath = os.Getenv("KUBECONFIG") - if kubeConfigFilePath == "" { - // Still no luck, try default (home) - home := os.Getenv("HOME") - if home != "" { - kubeConfigFilePath = path.Join(home, ".kube", "config") - } - } - } - - if kubeConfigFilePath == "" { - return nil, fmt.Errorf("Unable to locate kubeconfig") - } - - config, err := clientcmd.BuildConfigFromFlags("", kubeConfigFilePath) - if err != nil { - return nil, fmt.Errorf("Error accessing kubeconfig %s: %v", kubeConfigFilePath, err) - } - - config.TLSClientConfig.CertFile = clientCertFilePath - config.TLSClientConfig.KeyFile = clientKeyFilePath - config.TLSClientConfig.CAFile = caCertFilePath - - clientset, err := kubernetes.NewForConfig(config) - if err != nil { - return nil, fmt.Errorf("Error creating clientset: %v", err) - } - return clientset, nil -} - -func fetchK8sCert(kubeClient kubernetes.Interface) (*tls.Certificate, error) { - key, err := cert.MakeEllipticPrivateKeyPEM() - if err != nil { - return nil, fmt.Errorf("Error creating private key: %v", err) - } - - certsIntf := kubeClient.CertificatesV1beta1().CertificateSigningRequests() - cert, err := csr.RequestNodeCertificate(certsIntf, key, types.NodeName(getAgentName())) - if err != nil { - return nil, fmt.Errorf("Error getting certificate: %v", err) - } - - tlsCert, err := tls.X509KeyPair(cert, key) - if err != nil { - return nil, fmt.Errorf("Error forming x509 key pair: %v", err) - } - return &tlsCert, nil -} -func New() *K8sPlugin { - return &K8sPlugin{} -} - -func (p *K8sPlugin) FetchAttestationData(stream nodeattestor.FetchAttestationData_PluginStream) (err error) { - data, err := p.loadConfigData() - if err != nil { - return err - } - - // send the attestation data back to the agent - if err := stream.Send(&nodeattestor.FetchAttestationDataResponse{ - AttestationData: data.attestationData, - SpiffeId: data.spiffeID, - }); err != nil { - return err - } - - // receive challenge - resp, err := stream.Recv() - if err != nil { - return err - } - - challenge := new(x509pop.Challenge) - if err := json.Unmarshal(resp.Challenge, challenge); err != nil { - return fmt.Errorf("k8s node attestor: unable to unmarshal challenge: %v", err) - } - - // calculate and send the challenge response - response, err := x509pop.CalculateResponse(data.privateKey, challenge) - if err != nil { - return fmt.Errorf("k8s node attestor: failed to calculate challenge response: %v", err) - } - - responseBytes, err := json.Marshal(response) - if err != nil { - return fmt.Errorf("k8s node attestor: unable to marshal challenge response: %v", err) - } - - if err := stream.Send(&nodeattestor.FetchAttestationDataResponse{ - SpiffeId: data.spiffeID, - Response: responseBytes, - }); err != nil { - return err - } - - return nil -} - -func (p *K8sPlugin) Configure(ctx context.Context, req *plugin.ConfigureRequest) (*plugin.ConfigureResponse, error) { - // Parse HCL config payload into config struct - config := new(K8sConfig) - if err := hcl.Decode(config, req.Configuration); err != nil { - return nil, fmt.Errorf("k8s node attestor: unable to decode configuration: %v", err) - } - - if config.TrustDomain == "" { - return nil, errors.New("k8s node attestor: trust_domain is required") - } - if config.PrivateKeyPath == "" { - return nil, errors.New("k8s node attestor: private_key_path is required") - } - if config.CertificatePath == "" { - return nil, errors.New("k8s node attestor: certificate_path is required") - } - if config.K8sCACertPath == "" { - return nil, errors.New("k8s node attestor: ca_certificate_path is required") - } - - p.setConfig(config) - - if p.kubeClient == nil { - kubeClient, err := getKubeClient(config.KubeconfigPath, config.CertificatePath, config.PrivateKeyPath, config.K8sCACertPath) - if err != nil { - return nil, fmt.Errorf("Error creating Kubernetes client: %v", err) - } - p.kubeClient = kubeClient - } - - return &plugin.ConfigureResponse{}, nil -} - -func (p *K8sPlugin) GetPluginInfo(ctx context.Context, req *plugin.GetPluginInfoRequest) (*plugin.GetPluginInfoResponse, error) { - return &plugin.GetPluginInfoResponse{}, nil -} - -func (p *K8sPlugin) getConfig() *K8sConfig { - p.m.Lock() - defer p.m.Unlock() - return p.c -} - -func (p *K8sPlugin) setConfig(c *K8sConfig) { - p.m.Lock() - defer p.m.Unlock() - p.c = c - p.kubeClient = nil -} - -func (p *K8sPlugin) loadConfigData() (*configData, error) { - config := p.getConfig() - if config == nil { - return nil, errors.New("k8s node attestor: not configured") - } - - k8sCert, err := fetchK8sCert(p.kubeClient) - if err != nil { - return nil, fmt.Errorf("k8s node attestor: unable to retrieve identity document: %v", err) - } - - parsedCert, err := x509.ParseCertificate(k8sCert.Certificate[0]) - if err != nil { - return nil, fmt.Errorf("k8s node attestor: error parsing identity document: %v", err) - } - - attestationDataBytes, err := json.Marshal(x509pop.AttestationData{ - Certificates: k8sCert.Certificate, - }) - if err != nil { - return nil, fmt.Errorf("k8s node attestor: unable to marshal attestation data: %v", err) - } - - return &configData{ - spiffeID: k8s.SpiffeID(config.TrustDomain, parsedCert), - privateKey: k8sCert.PrivateKey, - attestationData: &common.AttestationData{ - Type: pluginName, - Data: attestationDataBytes, - }, - }, nil -} diff --git a/pkg/agent/plugin/nodeattestor/k8s/k8s_test.go b/pkg/agent/plugin/nodeattestor/k8s/k8s_test.go deleted file mode 100644 index b4802a14f9..0000000000 --- a/pkg/agent/plugin/nodeattestor/k8s/k8s_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package k8s - -import ( - "context" - "crypto/rand" - "crypto/sha1" - "crypto/x509" - "encoding/json" - "fmt" - "math/big" - "testing" - - "github.com/spiffe/spire/pkg/common/plugin/x509pop" - "github.com/spiffe/spire/proto/agent/nodeattestor" - "github.com/spiffe/spire/proto/common/plugin" - "github.com/spiffe/spire/test/util" - "github.com/stretchr/testify/suite" - - certificates "k8s.io/api/certificates/v1beta1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/watch" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/kubernetes/fake" - k8stesting "k8s.io/client-go/testing" - k8scertutil "k8s.io/client-go/util/cert" - k8scsr "k8s.io/client-go/util/certificate/csr" -) - -const ( - trustDomain = "example.org" -) - -func (s *Suite) marshal(obj interface{}) []byte { - data, err := json.Marshal(obj) - s.Require().NoError(err) - return data -} - -func (s *Suite) unmarshal(data []byte, obj interface{}) { - s.Require().NoError(json.Unmarshal(data, obj)) -} - -func (s *Suite) errorContains(err error, substring string) { - s.Require().Error(err) - s.Require().Contains(err.Error(), substring) -} - -func (s *Suite) fetchAttestationData() (nodeattestor.FetchAttestationData_Stream, func()) { - stream, err := s.p.FetchAttestationData(context.Background()) - s.Require().NoError(err) - return stream, func() { - s.Require().NoError(stream.CloseSend()) - } -} - -func signCSR(csr *x509.CertificateRequest) (*x509.Certificate, error) { - caCert, caKey, err := util.LoadCAFixture() - if err != nil { - return nil, err - } - - template := &x509.Certificate{ - IsCA: false, - BasicConstraintsValid: true, - Subject: csr.Subject, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - SerialNumber: big.NewInt(1234), - - RawSubject: csr.RawSubject, - DNSNames: csr.DNSNames, - IPAddresses: csr.IPAddresses, - } - - pubBytes, err := x509.MarshalPKIXPublicKey(csr.PublicKey) - if err != nil { - return nil, fmt.Errorf("Failed to Marshall public key during Certificate signing: %v", err) - } - hash := sha1.Sum(pubBytes) - template.SubjectKeyId = hash[:] - - bytes, err := x509.CreateCertificate(rand.Reader, template, caCert, csr.PublicKey, caKey) - if err != nil { - return nil, fmt.Errorf("Failed Creating certificate %v", err) - } - cert, err := x509.ParseCertificates(bytes) - if err != nil { - return nil, fmt.Errorf("Failed to Parse cert after creation %v", err) - } - return cert[0], err -} - -type Suite struct { - suite.Suite - - p *nodeattestor.BuiltIn - client kubernetes.Interface - cert *x509.Certificate -} - -func (s *Suite) SetupTest() { - require := s.Require() - - // csr is enclosed by the two reactors - var csr *certificates.CertificateSigningRequest - - createReactor := func(action k8stesting.Action) (bool, runtime.Object, error) { - var err error - csr = action.(k8stesting.CreateAction).GetObject().(*certificates.CertificateSigningRequest) - if err != nil { - return false, nil, err - } - return true, csr, nil - } - - watchReactor := func(action k8stesting.Action) (bool, watch.Interface, error) { - var err error - watcher := watch.NewFakeWithChanSize(1, false) - req, err := k8scsr.ParseCSR(csr) - if err != nil { - return false, nil, err - } - s.cert, err = signCSR(req) - if err != nil { - return false, nil, err - } - csr.Status.Conditions = append(csr.Status.Conditions, certificates.CertificateSigningRequestCondition{Type: certificates.CertificateApproved}) - csr.Status.Certificate = k8scertutil.EncodeCertPEM(s.cert) - watcher.Modify(csr) - watcher.Stop() - return true, watcher, nil - } - - fakeClient := &fake.Clientset{} - fakeClient.AddReactor("create", "certificatesigningrequests", createReactor) - fakeClient.AddWatchReactor("certificatesigningrequests", watchReactor) - - s.client = fakeClient - p := New() - - p.setConfig(&K8sConfig{TrustDomain: trustDomain}) - p.kubeClient = fakeClient - s.p = nodeattestor.NewBuiltIn(p) - require.NotNil(s.p) -} - -func (s *Suite) TestGetPluginInfo() { - require := s.Require() - resp, err := s.p.GetPluginInfo(context.Background(), &plugin.GetPluginInfoRequest{}) - require.NoError(err) - require.Equal(resp, &plugin.GetPluginInfoResponse{}) -} - -func (s *Suite) TestConfigure() { - require := s.Require() - - type testParam struct { - trustDomain string - privateKeyPath string - certificatePath string - caCertificatePath string - expectedErr string - } - testCases := []testParam{ - {"", "pkp", "cp", "cacp", "trust_domain is required"}, - {"td", "", "cp", "cacp", "private_key_path is required"}, - {"td", "pkp", "", "cacp", "certificate_path is required"}, - {"td", "pkp", "cp", "", "ca_certificate_path is required"}, - {"td", "pkp", "cp", "cacp", "no such file or directory"}, - } - - for _, t := range testCases { - p := nodeattestor.NewBuiltIn(New()) - config := fmt.Sprintf(` - trust_domain = %q - k8s_private_key_path = %q - k8s_certificate_path = %q - k8s_ca_certificate_path = %q - kubeconfig_path = "doesnotexist"`, - t.trustDomain, t.privateKeyPath, t.certificatePath, t.caCertificatePath) - - resp, err := p.Configure(context.Background(), &plugin.ConfigureRequest{ - Configuration: config, - }) - s.errorContains(err, t.expectedErr) - require.Nil(resp) - } -} - -func (s *Suite) TestFetchAttestationDataSuccess() { - require := s.Require() - - stream, done := s.fetchAttestationData() - defer done() - - spiffeID := "spiffe://" + trustDomain + "/spire/agent/k8s/system/node/" + getAgentName() - - // first response has the spiffeid and attestation data - resp, err := stream.Recv() - require.NoError(err) - require.NotNil(resp) - require.Equal(spiffeID, resp.SpiffeId) - require.Equal("k8s", resp.AttestationData.Type) - require.JSONEq(string(s.marshal(x509pop.AttestationData{ - Certificates: [][]byte{s.cert.Raw}, - })), string(resp.AttestationData.Data)) - require.Nil(resp.Response) - - // send a challenge - challenge, err := x509pop.GenerateChallenge(s.cert) - require.NoError(err) - challengeBytes, err := json.Marshal(challenge) - require.NoError(err) - err = stream.Send(&nodeattestor.FetchAttestationDataRequest{ - Challenge: challengeBytes, - }) - require.NoError(err) - - // recv the response - resp, err = stream.Recv() - require.NoError(err) - require.Equal(spiffeID, resp.SpiffeId) - require.Nil(resp.AttestationData) - require.NotEmpty(resp.Response) - - // verify signature - response := new(x509pop.Response) - s.unmarshal(resp.Response, response) - err = x509pop.VerifyChallengeResponse(s.cert.PublicKey, challenge, response) - require.NoError(err) -} - -func TestK8SAttestor(t *testing.T) { - suite.Run(t, new(Suite)) -} diff --git a/pkg/agent/plugin/nodeattestor/k8s/sat.go b/pkg/agent/plugin/nodeattestor/k8s/sat.go new file mode 100644 index 0000000000..eb93ed8ff1 --- /dev/null +++ b/pkg/agent/plugin/nodeattestor/k8s/sat.go @@ -0,0 +1,139 @@ +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 { + TokenPath string `hcl:"token_path"` +} + +type satAttestorConfig struct { + trustDomain 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 get token value: %v", err) + } + + data, err := json.Marshal(k8s.SATAttestationData{ + 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, 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") + } + + config := &satAttestorConfig{ + trustDomain: req.GlobalConfig.TrustDomain, + 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..5364fb45f8 --- /dev/null +++ b/pkg/agent/plugin/nodeattestor/k8s/sat_test.go @@ -0,0 +1,152 @@ +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 get token value") +} + +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/UUID", resp.SpiffeId) + s.Require().NotNil(resp.AttestationData) + s.Require().Equal("k8s_sat", resp.AttestationData.Type) + s.Require().JSONEq(`{ + "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) + + // success + resp, err = s.attestor.Configure(context.Background(), &plugin.ConfigureRequest{ + GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{TrustDomain: "example.org"}, + }) + 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(`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/k8s.go b/pkg/common/plugin/k8s/k8s.go deleted file mode 100644 index 684f92b221..0000000000 --- a/pkg/common/plugin/k8s/k8s.go +++ /dev/null @@ -1,18 +0,0 @@ -package k8s - -import ( - "crypto/x509" - "net/url" - "path" - "strings" -) - -func SpiffeID(trustDomain string, cert *x509.Certificate) string { - agentID := strings.Replace(cert.Subject.CommonName, ":", "/", -1) - u := url.URL{ - Scheme: "spiffe", - Host: trustDomain, - Path: path.Join("spire", "agent", "k8s", agentID), - } - return u.String() -} diff --git a/pkg/common/plugin/k8s/sat.go b/pkg/common/plugin/k8s/sat.go new file mode 100644 index 0000000000..4fbb67263d --- /dev/null +++ b/pkg/common/plugin/k8s/sat.go @@ -0,0 +1,37 @@ +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 { + UUID string `json:"uuid"` + Token string `json:"token"` +} + +func AgentID(trustDomain, podUID string) string { + u := url.URL{ + Scheme: "spiffe", + Host: trustDomain, + Path: path.Join("spire", "agent", "k8s_sat", podUID), + } + 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..3c62816e57 --- /dev/null +++ b/pkg/common/plugin/k8s/sat_test.go @@ -0,0 +1,25 @@ +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) +} diff --git a/pkg/server/catalog/catalog.go b/pkg/server/catalog/catalog.go index d2307cb996..f4641d65e0 100644 --- a/pkg/server/catalog/catalog.go +++ b/pkg/server/catalog/catalog.go @@ -7,14 +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" - "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/k8s" + 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" @@ -59,17 +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()), - "k8s": nodeattestor.NewBuiltIn(k8s.New()), + "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/k8s.go b/pkg/server/plugin/nodeattestor/k8s/k8s.go deleted file mode 100644 index eaff9337be..0000000000 --- a/pkg/server/plugin/nodeattestor/k8s/k8s.go +++ /dev/null @@ -1,214 +0,0 @@ -package k8s - -import ( - "context" - "crypto/x509" - "encoding/json" - "fmt" - "sync" - - "github.com/hashicorp/hcl" - "github.com/spiffe/spire/pkg/common/plugin/k8s" - "github.com/spiffe/spire/pkg/common/plugin/x509pop" - "github.com/spiffe/spire/pkg/common/util" - "github.com/spiffe/spire/proto/common" - spi "github.com/spiffe/spire/proto/common/plugin" - "github.com/spiffe/spire/proto/server/nodeattestor" -) - -const ( - pluginName = "k8s" -) - -type configuration struct { - trustDomain string - trustBundle *x509.CertPool -} - -type K8sConfig struct { - TrustDomain string `hcl:"trust_domain"` - CABundlePath string `hcl:"ca_bundle_path"` -} - -type K8sPlugin struct { - m sync.Mutex - c *configuration -} - -func New() *K8sPlugin { - return &K8sPlugin{} -} - -func (p *K8sPlugin) Attest(stream nodeattestor.Attest_PluginStream) error { - req, err := stream.Recv() - if err != nil { - return err - } - - c := p.getConfiguration() - if c == nil { - return newError("not configured") - } - - if req.AttestationData == nil { - return newError("attestation data not present") - } - - if dataType := req.AttestationData.Type; dataType != pluginName { - return newError("unexpected attestation data type %q", dataType) - } - - attestationData := new(x509pop.AttestationData) - if err := json.Unmarshal(req.AttestationData.Data, attestationData); err != nil { - return newError("failed to unmarshal data: %v", err) - } - - // build up leaf certificate and list of intermediates - if len(attestationData.Certificates) == 0 { - return newError("no certificate to attest") - } - leaf, err := x509.ParseCertificate(attestationData.Certificates[0]) - if err != nil { - return newError("unable to parse leaf certificate: %v", err) - } - intermediates := x509.NewCertPool() - for i, intermediateBytes := range attestationData.Certificates[1:] { - intermediate, err := x509.ParseCertificate(intermediateBytes) - if err != nil { - return newError("unable to parse intermediate certificate %d: %v", i, err) - } - intermediates.AddCert(intermediate) - } - - // verify the chain of trust - chains, err := leaf.Verify(x509.VerifyOptions{ - Intermediates: intermediates, - Roots: c.trustBundle, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, - }) - if err != nil { - return newError("certificate verification failed: %v", err) - } - - // now that the leaf certificate is trusted, issue a challenge to the node - // to prove possession of the private key. - challenge, err := x509pop.GenerateChallenge(leaf) - if err != nil { - return fmt.Errorf("unable to generate challenge: %v", err) - } - - challengeBytes, err := json.Marshal(challenge) - if err != nil { - return fmt.Errorf("unable to marshal challenge: %v", err) - } - - if err := stream.Send(&nodeattestor.AttestResponse{ - Challenge: challengeBytes, - }); err != nil { - return err - } - - // receive and validate the challenge response - responseReq, err := stream.Recv() - if err != nil { - return err - } - - response := new(x509pop.Response) - if err := json.Unmarshal(responseReq.Response, response); err != nil { - return newError("unable to unmarshal challenge response: %v", err) - } - - if err := x509pop.VerifyChallengeResponse(leaf.PublicKey, challenge, response); err != nil { - return newError("challenge response verification failed: %v", err) - } - - resp := &nodeattestor.AttestResponse{ - Valid: true, - BaseSPIFFEID: k8s.SpiffeID(c.trustDomain, leaf), - Selectors: buildSelectors(leaf, chains), - } - - if err := stream.Send(resp); err != nil { - return err - } - - return nil -} - -func (p *K8sPlugin) Configure(ctx context.Context, req *spi.ConfigureRequest) (*spi.ConfigureResponse, error) { - config := new(K8sConfig) - if err := hcl.Decode(config, req.Configuration); err != nil { - return nil, newError("unable to decode configuration: %v", err) - } - - if config.TrustDomain == "" { - return nil, newError("trust_domain is required") - } - if config.CABundlePath == "" { - return nil, newError("ca_bundle_path is required") - } - - trustBundle, err := util.LoadCertPool(config.CABundlePath) - if err != nil { - return nil, newError("unable to load trust bundle: %v", err) - } - - p.setConfiguration(&configuration{ - trustDomain: config.TrustDomain, - trustBundle: trustBundle, - }) - - return &spi.ConfigureResponse{}, nil -} - -func (*K8sPlugin) GetPluginInfo(context.Context, *spi.GetPluginInfoRequest) (*spi.GetPluginInfoResponse, error) { - return &spi.GetPluginInfoResponse{}, nil -} - -func (p *K8sPlugin) getConfiguration() *configuration { - p.m.Lock() - defer p.m.Unlock() - return p.c -} - -func (p *K8sPlugin) setConfiguration(c *configuration) { - p.m.Lock() - defer p.m.Unlock() - p.c = c -} - -func newError(format string, args ...interface{}) error { - return fmt.Errorf("k8s node attestor: "+format, args...) -} - -func buildSelectors(leaf *x509.Certificate, chains [][]*x509.Certificate) []*common.Selector { - selectors := []*common.Selector{} - - if leaf.Subject.CommonName != "" { - selectors = append(selectors, &common.Selector{ - Type: "k8s", Value: "subject:cn:" + leaf.Subject.CommonName, - }) - } - - // Used to avoid duplicating selectors. - fingerprints := map[string]*x509.Certificate{} - for _, chain := range chains { - // Iterate over all the certs in the chain (skip leaf at the 0 index) - for _, cert := range chain[1:] { - fp := x509pop.Fingerprint(cert) - // If the same fingerprint is generated, continue with the next certificate, because - // a selector should have been already created for it. - if _, ok := fingerprints[fp]; ok { - continue - } - fingerprints[fp] = cert - - selectors = append(selectors, &common.Selector{ - Type: "k8s", Value: "ca:fingerprint:" + fp, - }) - } - } - - return selectors -} diff --git a/pkg/server/plugin/nodeattestor/k8s/k8s_test.go b/pkg/server/plugin/nodeattestor/k8s/k8s_test.go deleted file mode 100644 index f263ba69a6..0000000000 --- a/pkg/server/plugin/nodeattestor/k8s/k8s_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package k8s - -import ( - "context" - "crypto" - "crypto/tls" - "crypto/x509" - "encoding/json" - "fmt" - "strings" - "testing" - - "github.com/spiffe/spire/pkg/common/plugin/x509pop" - "github.com/spiffe/spire/proto/common" - "github.com/spiffe/spire/proto/common/plugin" - "github.com/spiffe/spire/proto/server/nodeattestor" - "github.com/spiffe/spire/test/fixture" - "github.com/spiffe/spire/test/util" - "github.com/stretchr/testify/suite" -) - -const ( - trustDomain = "example.org" -) - -func (s *Suite) attest() (nodeattestor.Attest_Stream, func()) { - stream, err := s.p.Attest(context.Background()) - s.Require().NoError(err) - return stream, func() { - s.Require().NoError(stream.CloseSend()) - } -} - -func (s *Suite) marshal(obj interface{}) []byte { - data, err := json.Marshal(obj) - s.Require().NoError(err) - return data -} - -func (s *Suite) unmarshal(data []byte, obj interface{}) { - s.Require().NoError(json.Unmarshal(data, obj)) -} - -func (s *Suite) errorContains(err error, substring string) { - s.Require().Error(err) - s.Require().Contains(err.Error(), substring) -} - -func TestK8sAttestor(t *testing.T) { - suite.Run(t, new(Suite)) -} - -type Suite struct { - suite.Suite - - p *nodeattestor.BuiltIn - idKey crypto.PrivateKey - idDoc *x509.Certificate - caCert *x509.Certificate -} - -func (s *Suite) SetupTest() { - require := s.Require() - idDocPath := fixture.Join("nodeattestor", "k8s", "id-doc.pem") - idKeyPath := fixture.Join("nodeattestor", "k8s", "id-key.pem") - caBundlePath := fixture.Join("nodeattestor", "k8s", "k8s-ca.pem") - - s.p = nodeattestor.NewBuiltIn(New()) - config := &plugin.ConfigureRequest{ - Configuration: fmt.Sprintf(` - trust_domain = %q - ca_bundle_path = %q`, - trustDomain, caBundlePath), - } - - resp, err := s.p.Configure(context.Background(), config) - require.NoError(err) - require.Equal(resp, &plugin.ConfigureResponse{}) - - kp, err := tls.LoadX509KeyPair(idDocPath, idKeyPath) - require.NoError(err) - s.idKey = kp.PrivateKey - s.idDoc, err = x509.ParseCertificate(kp.Certificate[0]) - s.caCert, err = util.LoadCert(caBundlePath) - require.NoError(err) -} - -func (s *Suite) TestConfigure() { - require := s.Require() - - type testParam struct { - trustDomain string - caBundlePath string - expectedErr string - } - - // negative test cases - testCases := []testParam{ - {"", "cabp", "trust_domain is required"}, - {"td", "", "ca_bundle_path is required"}, - {"td", "cabp", "unable to load trust bundle"}, - } - - for _, t := range testCases { - p := nodeattestor.NewBuiltIn(New()) - config := fmt.Sprintf(` - trust_domain = %q - ca_bundle_path = %q`, - t.trustDomain, t.caBundlePath) - - resp, err := p.Configure(context.Background(), &plugin.ConfigureRequest{ - Configuration: config, - }) - s.errorContains(err, t.expectedErr) - require.Nil(resp) - } -} - -func (s *Suite) TestGetPluginInfo() { - require := s.Require() - - p := New() - - resp, err := p.GetPluginInfo(context.Background(), &plugin.GetPluginInfoRequest{}) - require.NoError(err) - require.Equal(resp, &plugin.GetPluginInfoResponse{}) -} - -func (s *Suite) TestAttestSuccess() { - require := s.Require() - - stream, done := s.attest() - defer done() - - // send down good attestation data - attestationData := &x509pop.AttestationData{ - Certificates: [][]byte{s.idDoc.Raw}, - } - err := stream.Send(&nodeattestor.AttestRequest{ - AttestationData: &common.AttestationData{ - Type: "k8s", - Data: s.marshal(attestationData), - }, - }) - require.NoError(err) - - // receive and parse challenge - resp, err := stream.Recv() - require.NoError(err) - require.Equal("", resp.BaseSPIFFEID) - s.False(resp.Valid) - s.NotEmpty(resp.Challenge) - - challenge := new(x509pop.Challenge) - s.unmarshal(resp.Challenge, challenge) - - // calculate and send the response - response, err := x509pop.CalculateResponse(s.idKey, challenge) - require.NoError(err) - err = stream.Send(&nodeattestor.AttestRequest{ - Response: s.marshal(response), - }) - require.NoError(err) - - // receive the attestation result - resp, err = stream.Recv() - require.NoError(err) - s.True(resp.Valid) - agentID := strings.Replace(s.idDoc.Subject.CommonName, ":", "/", -1) - require.Equal("spiffe://"+trustDomain+"/spire/agent/k8s/"+agentID, resp.BaseSPIFFEID) - require.Nil(resp.Challenge) - require.Len(resp.Selectors, 2) - require.EqualValues([]*common.Selector{ - {Type: "k8s", Value: "subject:cn:system:node:node1"}, - {Type: "k8s", Value: "ca:fingerprint:" + x509pop.Fingerprint(s.caCert)}, - }, resp.Selectors) -} - -func (s *Suite) TestAttestFailure() { - require := s.Require() - - makeData := func(attestationData *x509pop.AttestationData) *common.AttestationData { - return &common.AttestationData{ - Type: "k8s", - Data: s.marshal(attestationData), - } - } - - attestFails := func(attestationData *common.AttestationData, expected string) { - stream, done := s.attest() - defer done() - - require.NoError(stream.Send(&nodeattestor.AttestRequest{ - AttestationData: attestationData, - })) - - resp, err := stream.Recv() - s.errorContains(err, expected) - require.Nil(resp) - } - - challengeResponseFails := func(response string, expected string) { - stream, done := s.attest() - defer done() - - require.NoError(stream.Send(&nodeattestor.AttestRequest{ - AttestationData: makeData(&x509pop.AttestationData{ - Certificates: [][]byte{s.idDoc.Raw}, - }), - })) - - resp, err := stream.Recv() - require.NoError(err) - s.NotNil(resp) - - require.NoError(stream.Send(&nodeattestor.AttestRequest{ - Response: []byte(response), - })) - - resp, err = stream.Recv() - s.errorContains(err, expected) - require.Nil(resp) - } - - // not configured yet - stream, err := nodeattestor.NewBuiltIn(New()).Attest(context.Background()) - require.NoError(err) - defer stream.CloseSend() - require.NoError(stream.Send(&nodeattestor.AttestRequest{})) - _, err = stream.Recv() - require.EqualError(err, "k8s node attestor: not configured") - - // unexpected data type - attestFails(&common.AttestationData{Type: "foo"}, - "k8s node attestor: unexpected attestation data type \"foo\"") - - // malformed data - attestFails(&common.AttestationData{Type: "k8s"}, - "k8s node attestor: failed to unmarshal data") - - // no identity doc - attestFails(makeData(&x509pop.AttestationData{}), - "k8s node attestor: no certificate to attest") - - // malformed identity doc - attestFails(makeData(&x509pop.AttestationData{Certificates: [][]byte{{0x00}}}), - "k8s node attestor: unable to parse leaf certificate") - - // identity doc signed by unknown authority - unauthCertPath := fixture.Join("nodeattestor", "x509pop", "root-crt.pem") - unauthCert, err := util.LoadCert(unauthCertPath) - require.NoError(err) - attestFails(makeData(&x509pop.AttestationData{Certificates: [][]byte{unauthCert.Raw}}), - "k8s node attestor: certificate verification failed") - - // malformed challenge response - challengeResponseFails("", "k8s node attestor: unable to unmarshal challenge response") - - // invalid response - challengeResponseFails("{}", "k8s node attestor: challenge response verification failed") -} diff --git a/pkg/server/plugin/nodeattestor/k8s/sat.go b/pkg/server/plugin/nodeattestor/k8s/sat.go new file mode 100644 index 0000000000..5528e0e7dd --- /dev/null +++ b/pkg/server/plugin/nodeattestor/k8s/sat.go @@ -0,0 +1,307 @@ +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 { + name string + serviceAccountKeys []crypto.PublicKey + serviceAccounts map[string]bool +} + +type satAttestorConfig struct { + trustDomain string + clusters []*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.UUID == "" { + return satError.New("missing UUID in attestation data") + } + + if attestationData.Token == "" { + return satError.New("missing token in attestation data") + } + + token, err := jwt.ParseSigned(attestationData.Token) + if err != nil { + return satError.New("unable to parse token: %v", err) + } + + cluster, claims, err := verifyTokenSignature(config.clusters, 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.UUID), + Selectors: []*common.Selector{ + makeSelector("cluster:name", cluster.name), + makeSelector("service-account:namespace", claims.Namespace), + makeSelector("service-account:name", 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, + } + 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 = append(config.clusters, &clusterConfig{ + name: name, + 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(clusters []*clusterConfig, token *jwt.JSONWebToken) (cluster *clusterConfig, claims *k8s.SATClaims, err error) { + var lastErr error + for _, cluster := range clusters { + 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 cluster, claims, nil + } + } + if lastErr == nil { + lastErr = satError.New("token was not validated by any cluster") + } + return nil, 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..8582e513f7 --- /dev/null +++ b/pkg/server/plugin/nodeattestor/k8s/sat_test.go @@ -0,0 +1,470 @@ +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) TestAttestFailsWithNoUUID() { + s.requireAttestError(makeAttestRequest("", "TOKEN"), + "k8s-sat: missing UUID in attestation data") +} + +func (s *SATAttestorSuite) TestAttestFailsWithNoToken() { + s.requireAttestError(makeAttestRequest("UUID", ""), + "k8s-sat: missing token in attestation data") +} + +func (s *SATAttestorSuite) TestAttestFailsWithMalformedToken() { + s.requireAttestError(makeAttestRequest("UUID", "blah"), + "k8s-sat: unable to parse token") +} + +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("UUID", token), + "unable to verify token") +} + +func (s *SATAttestorSuite) TestAttestFailsWithInvalidIssuer() { + token, err := jwt.Signed(s.fooSigner).CompactSerialize() + s.Require().NoError(err) + s.requireAttestError(makeAttestRequest("UUID", token), "invalid issuer claim") +} + +func (s *SATAttestorSuite) TestAttestFailsWithMissingNamespaceClaim() { + token := s.signToken(s.fooSigner, "", "") + s.requireAttestError(makeAttestRequest("UUID", token), "token missing namespace claim") +} + +func (s *SATAttestorSuite) TestAttestFailsWithMissingServiceAccountNameClaim() { + token := s.signToken(s.fooSigner, "NAMESPACE", "") + s.requireAttestError(makeAttestRequest("UUID", token), "token missing service account name claim") +} + +func (s *SATAttestorSuite) TestAttestFailsIfNamespaceNotWhitelisted() { + token := s.signToken(s.fooSigner, "NAMESPACE", "SERVICEACCOUNTNAME") + s.requireAttestError(makeAttestRequest("UUID", token), `"NAMESPACE:SERVICEACCOUNTNAME" is not a whitelisted service account`) +} + +func (s *SATAttestorSuite) TestAttestFailsIfTokenSignatureCannotBeVerifiedByAnyCluster() { + token := s.signToken(s.bazSigner, "NAMESPACE", "SERVICEACCOUNTNAME") + s.requireAttestError(makeAttestRequest("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, "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/UUID") + s.Require().Nil(resp.Challenge) + s.Require().Equal([]*common.Selector{ + {Type: "k8s_sat", Value: "cluster:name:FOO"}, + {Type: "k8s_sat", Value: "service-account:namespace:NS1"}, + {Type: "k8s_sat", Value: "service-account:name:SA1"}, + }, resp.Selectors) + + // Success with BAR signed token + resp, err = s.doAttest(s.signAttestRequest(s.barSigner, "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/UUID") + s.Require().Nil(resp.Challenge) + s.Require().Equal([]*common.Selector{ + {Type: "k8s_sat", Value: "cluster:name:BAR"}, + {Type: "k8s_sat", Value: "service-account:namespace:NS2"}, + {Type: "k8s_sat", Value: "service-account:name: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, namespace, serviceAccountName string) *nodeattestor.AttestRequest { + return makeAttestRequest("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(uuid, token string) *nodeattestor.AttestRequest { + return &nodeattestor.AttestRequest{ + AttestationData: &common.AttestationData{ + Type: "k8s_sat", + Data: []byte(fmt.Sprintf(`{"uuid": %q, "token": %q}`, 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 +} diff --git a/test/fixture/nodeattestor/k8s/id-doc.pem b/test/fixture/nodeattestor/k8s/id-doc.pem deleted file mode 100644 index 63b3ebc560..0000000000 --- a/test/fixture/nodeattestor/k8s/id-doc.pem +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICXzCCAUegAwIBAgIUPHlRsHbx+Ucd2yVO9SGtWEQXPGcwDQYJKoZIhvcNAQEL -BQAwFTETMBEGA1UEAxMKbWluaWt1YmVDQTAeFw0xODA4MDYxNzI0MDBaFw0xOTA4 -MDYxNzI0MDBaMDMxFTATBgNVBAoTDHN5c3RlbTpub2RlczEaMBgGA1UEAxMRc3lz -dGVtOm5vZGU6bm9kZTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAT6riE0Twq7 -A7cJMjfxp9XJCz1G1DzCIXcDUvVZsbLhSq95SihjdDVYzMkS9hLzDX43aP3CWSkc -Nif2N2lGhEjSo1QwUjAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUH -AwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUyETFhv74KqQQphoL4C7VyPThHSEw -DQYJKoZIhvcNAQELBQADggEBAJwnYlE8V2syo7+2LfeUHSoIzweCI/VFtb8mLTb9 -xhCKMgOqbk4dE7iNFmCdkBZrEin0VPfE4XIlKLROTqHDL5gXS/ceimojOfhgsSFo -sTwSSBlRx0LDzutXLtZ7oDDR61r1mEzMnkpCzBNs5QjyFNvgcy2hO4wMDolpiFLL -M/RjsSxmv5feZrNtRnMEh0JMkwBIVD9NVNDQZLsrEGtMcqSonIsassl3EJW91PEV -Hjjsapnf3POXI2+sXLrk0tKkf5Xz0REaScV266/xBhY5+YKJ243s9xdi13HptZIt -TZz8N6nKrC2QVZD4DyhdpAmwDT+sl8YpdDTtkpvwCz2pblU= ------END CERTIFICATE----- diff --git a/test/fixture/nodeattestor/k8s/id-key.pem b/test/fixture/nodeattestor/k8s/id-key.pem deleted file mode 100644 index 256e639327..0000000000 --- a/test/fixture/nodeattestor/k8s/id-key.pem +++ /dev/null @@ -1,5 +0,0 @@ ------BEGIN EC PRIVATE KEY----- -MHcCAQEEIPjhFjgMe043nn/TejL70WoVc0J1WqIJHaQsmOGPJ4AUoAoGCCqGSM49 -AwEHoUQDQgAE+q4hNE8KuwO3CTI38afVyQs9RtQ8wiF3A1L1WbGy4UqveUooY3Q1 -WMzJEvYS8w1+N2j9wlkpHDYn9jdpRoRI0g== ------END EC PRIVATE KEY----- diff --git a/test/fixture/nodeattestor/k8s/k8s-ca.pem b/test/fixture/nodeattestor/k8s/k8s-ca.pem deleted file mode 100644 index ae3a627e0d..0000000000 --- a/test/fixture/nodeattestor/k8s/k8s-ca.pem +++ /dev/null @@ -1,18 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIC5zCCAc+gAwIBAgIBATANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwptaW5p -a3ViZUNBMB4XDTE3MDMwMjE4NTUwOFoXDTI3MDIyODE4NTUwOFowFTETMBEGA1UE -AxMKbWluaWt1YmVDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK+g -3lyCFaxO4qteATc18tCD/8OLWcDJk7tSS8MuqzskT5ngFt/r1heFdcWnEPuBrB5O -zRxx/JnXLBtprwgh7Xw1YGXpQsDopYZl6kypgMav0vNZHn2A6cTYnnvdb0MCFELU -cbpph731b7wggTjMING478L+/hHkUjNqIW0fMokAF8ZSSoC7Rln37JnmYLnE95en -7g1LAjngR+YBgNca5h2uItfUbKAkxwa4woRCRI91ujfIFIz891l8mtgmDmP1dSIw -uxF/QbpA8WCrWKhKJ3L6Kktqo8Iw0W/FDtU5iXrL1WjOdsJ3TeLNsvldKQwzgram -ix1kJii6riw5NiB0bcMCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgKkMB0GA1UdJQQW -MBQGCCsGAQUFBwMCBggrBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 -DQEBCwUAA4IBAQB7gqOptKoieoQEZ21uGbgEOnWvHWO+hxbtDsFEzokC7RTDVREs -pngQE6kARNV77Xs6WyjonFaNdFWiLVxncR+RgMaL5okMvsjxNKZY0U9ipsde0Wk2 -VUliqGm4ojejGnf45xIvbkHoNGVYE7388duHjEw+OwD0kymiZgniVnvZll3j0Rge -NjSPzLu8A6Fkcm87eqxgXvRcU1OHpMyLefQ1BzWYABEx/cs6t96Tb+jXtpLsrazB -gh1HB/amIexRg3+ua0qxyMx/sSfwYJy0GYNT/iNH/T/0o2jIL88AqWpXOPpMUSUj -O6xSM0gA/7y+5H5jc7QKTPDxXOTXpyo0dKAr ------END CERTIFICATE----- From f72dd3a79909d7c4d2f0065393602be6fe322f07 Mon Sep 17 00:00:00 2001 From: Andrew Harding Date: Wed, 17 Oct 2018 09:56:48 -0600 Subject: [PATCH 3/4] address PR comments Signed-off-by: Andrew Harding --- doc/plugin_agent_nodeattestor_k8s_sat.md | 20 ++++++- doc/plugin_server_nodeattestor_k8s_sat.md | 29 +++++++--- pkg/agent/plugin/nodeattestor/k8s/sat.go | 15 +++-- pkg/agent/plugin/nodeattestor/k8s/sat_test.go | 19 ++++-- pkg/common/plugin/k8s/sat.go | 9 +-- pkg/common/plugin/k8s/sat_test.go | 4 ++ pkg/server/plugin/nodeattestor/k8s/sat.go | 46 ++++++++------- .../plugin/nodeattestor/k8s/sat_test.go | 58 +++++++++++-------- 8 files changed, 134 insertions(+), 66 deletions(-) diff --git a/doc/plugin_agent_nodeattestor_k8s_sat.md b/doc/plugin_agent_nodeattestor_k8s_sat.md index c12e5c1e55..775dd7f8ee 100644 --- a/doc/plugin_agent_nodeattestor_k8s_sat.md +++ b/doc/plugin_agent_nodeattestor_k8s_sat.md @@ -6,16 +6,17 @@ 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 SPIFFE ID with the form: +The plugin generates SPIFFE IDs with the form: ``` -spiffe:///spire/agent/k8s_sat/ +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. @@ -25,6 +26,21 @@ 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 index a314f74c28..91c042c5c7 100644 --- a/doc/plugin_server_nodeattestor_k8s_sat.md +++ b/doc/plugin_server_nodeattestor_k8s_sat.md @@ -5,10 +5,10 @@ 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 for generate a SPIFFE ID with the form: +one-time UUID provided by the agent to generate a SPIFFE ID with the form: ``` -spiffe:///spire/agent/k8s_sat/ +spiffe:///spire/agent/k8s_sat// ``` The server does not need to be running in Kubernetes in order to perform node @@ -42,11 +42,24 @@ A sample configuration: } ``` -In addition, this plugin generates the following selectors of type `k8s_sat` for each node: +In addition, this plugin generates the following selectors: -| Value | Example | Description | -| --------------------------- | ----------------------------- | ------------------------------------------ | -| `cluster-name` | `cluster-name:MyCluster` | Name of the cluster (from the plugin config) used to verify the token signature | -| `service-account:namespace` | `service-account:production` | Namespace of the service account | -| `service-account:name` | `service-account:spire-agent` | Name of the service account | +| 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/pkg/agent/plugin/nodeattestor/k8s/sat.go b/pkg/agent/plugin/nodeattestor/k8s/sat.go index eb93ed8ff1..2a9cee8c73 100644 --- a/pkg/agent/plugin/nodeattestor/k8s/sat.go +++ b/pkg/agent/plugin/nodeattestor/k8s/sat.go @@ -26,11 +26,13 @@ var ( ) type SATAttestorConfig struct { + Cluster string `hcl:"cluster"` TokenPath string `hcl:"token_path"` } type satAttestorConfig struct { trustDomain string + cluster string tokenPath string } @@ -63,12 +65,13 @@ func (p *SATAttestorPlugin) FetchAttestationData(stream nodeattestor.FetchAttest token, err := loadTokenFromFile(config.tokenPath) if err != nil { - return satError.New("unable to get token value: %v", err) + return satError.New("unable to load token from %s: %v", config.tokenPath, err) } data, err := json.Marshal(k8s.SATAttestationData{ - UUID: uuid, - Token: token, + Cluster: config.cluster, + UUID: uuid, + Token: token, }) if err != nil { return satError.Wrap(err) @@ -79,7 +82,7 @@ func (p *SATAttestorPlugin) FetchAttestationData(stream nodeattestor.FetchAttest Type: pluginName, Data: data, }, - SpiffeId: k8s.AgentID(config.trustDomain, uuid), + SpiffeId: k8s.AgentID(config.trustDomain, config.cluster, uuid), }) } @@ -95,9 +98,13 @@ func (p *SATAttestorPlugin) Configure(ctx context.Context, req *spi.ConfigureReq 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 == "" { diff --git a/pkg/agent/plugin/nodeattestor/k8s/sat_test.go b/pkg/agent/plugin/nodeattestor/k8s/sat_test.go index 5364fb45f8..30485d3617 100644 --- a/pkg/agent/plugin/nodeattestor/k8s/sat_test.go +++ b/pkg/agent/plugin/nodeattestor/k8s/sat_test.go @@ -43,7 +43,7 @@ func (s *SATAttestorSuite) TestFetchAttestationDataNoToken() { s.configure(SATAttestorConfig{ TokenPath: s.joinPath("token"), }) - s.requireFetchError("unable to get token value") + s.requireFetchError("unable to load token from") } func (s *SATAttestorSuite) TestFetchAttestationDataSuccess() { @@ -60,10 +60,11 @@ func (s *SATAttestorSuite) TestFetchAttestationDataSuccess() { s.Require().NotNil(resp) // assert attestation data - s.Require().Equal("spiffe://example.org/spire/agent/k8s_sat/UUID", resp.SpiffeId) + 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)) @@ -91,10 +92,18 @@ func (s *SATAttestorSuite) TestConfigure() { s.Require().EqualError(err, "k8s-sat: global configuration missing trust domain") s.Require().Nil(resp) - // success + // 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{}) } @@ -118,7 +127,9 @@ func (s *SATAttestorSuite) configure(config SATAttestorConfig) { GlobalConfig: &plugin.ConfigureRequest_GlobalConfig{ TrustDomain: "example.org", }, - Configuration: fmt.Sprintf(`token_path = %q`, config.TokenPath), + Configuration: fmt.Sprintf(` + cluster = "production" + token_path = %q`, config.TokenPath), }) s.Require().NoError(err) diff --git a/pkg/common/plugin/k8s/sat.go b/pkg/common/plugin/k8s/sat.go index 4fbb67263d..3909cbc251 100644 --- a/pkg/common/plugin/k8s/sat.go +++ b/pkg/common/plugin/k8s/sat.go @@ -23,15 +23,16 @@ type SATClaims struct { } type SATAttestationData struct { - UUID string `json:"uuid"` - Token string `json:"token"` + Cluster string `json:"cluster"` + UUID string `json:"uuid"` + Token string `json:"token"` } -func AgentID(trustDomain, podUID string) string { +func AgentID(trustDomain, cluster, uuid string) string { u := url.URL{ Scheme: "spiffe", Host: trustDomain, - Path: path.Join("spire", "agent", "k8s_sat", podUID), + 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 index 3c62816e57..5d99fa4ccd 100644 --- a/pkg/common/plugin/k8s/sat_test.go +++ b/pkg/common/plugin/k8s/sat_test.go @@ -23,3 +23,7 @@ func TestSATTokenClaims(t *testing.T) { 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/plugin/nodeattestor/k8s/sat.go b/pkg/server/plugin/nodeattestor/k8s/sat.go index 5528e0e7dd..299df7f631 100644 --- a/pkg/server/plugin/nodeattestor/k8s/sat.go +++ b/pkg/server/plugin/nodeattestor/k8s/sat.go @@ -39,14 +39,13 @@ type SATAttestorConfig struct { } type clusterConfig struct { - name string serviceAccountKeys []crypto.PublicKey serviceAccounts map[string]bool } type satAttestorConfig struct { trustDomain string - clusters []*clusterConfig + clusters map[string]*clusterConfig } type SATAttestorPlugin struct { @@ -92,6 +91,10 @@ func (p *SATAttestorPlugin) Attest(stream nodeattestor.Attest_PluginStream) erro 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") } @@ -100,12 +103,17 @@ func (p *SATAttestorPlugin) Attest(stream nodeattestor.Attest_PluginStream) erro 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) } - cluster, claims, err := verifyTokenSignature(config.clusters, token) + claims, err := verifyTokenSignature(cluster, token) if err != nil { return err } @@ -133,11 +141,11 @@ func (p *SATAttestorPlugin) Attest(stream nodeattestor.Attest_PluginStream) erro return stream.Send(&nodeattestor.AttestResponse{ Valid: true, - BaseSPIFFEID: k8s.AgentID(config.trustDomain, attestationData.UUID), + BaseSPIFFEID: k8s.AgentID(config.trustDomain, attestationData.Cluster, attestationData.UUID), Selectors: []*common.Selector{ - makeSelector("cluster:name", cluster.name), - makeSelector("service-account:namespace", claims.Namespace), - makeSelector("service-account:name", claims.ServiceAccountName), + makeSelector("cluster", attestationData.Cluster), + makeSelector("agent_ns", claims.Namespace), + makeSelector("agent_sa", claims.ServiceAccountName), }, }) } @@ -160,6 +168,7 @@ func (p *SATAttestorPlugin) Configure(ctx context.Context, req *spi.ConfigureReq config := &satAttestorConfig{ trustDomain: req.GlobalConfig.TrustDomain, + clusters: make(map[string]*clusterConfig), } config.trustDomain = req.GlobalConfig.TrustDomain for name, cluster := range hclConfig.Clusters { @@ -184,11 +193,10 @@ func (p *SATAttestorPlugin) Configure(ctx context.Context, req *spi.ConfigureReq serviceAccounts[serviceAccount] = true } - config.clusters = append(config.clusters, &clusterConfig{ - name: name, + config.clusters[name] = &clusterConfig{ serviceAccountKeys: serviceAccountKeys, serviceAccounts: serviceAccounts, - }) + } } p.setConfig(config) @@ -214,22 +222,20 @@ func (p *SATAttestorPlugin) setConfig(config *satAttestorConfig) { p.config = config } -func verifyTokenSignature(clusters []*clusterConfig, token *jwt.JSONWebToken) (cluster *clusterConfig, claims *k8s.SATClaims, err error) { +func verifyTokenSignature(cluster *clusterConfig, token *jwt.JSONWebToken) (claims *k8s.SATClaims, err error) { var lastErr error - for _, cluster := range clusters { - 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 cluster, claims, nil + 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 was not validated by any cluster") } - return nil, nil, lastErr + return nil, lastErr } func makeSelector(kind, value string) *common.Selector { diff --git a/pkg/server/plugin/nodeattestor/k8s/sat_test.go b/pkg/server/plugin/nodeattestor/k8s/sat_test.go index 8582e513f7..dc4ba14db4 100644 --- a/pkg/server/plugin/nodeattestor/k8s/sat_test.go +++ b/pkg/server/plugin/nodeattestor/k8s/sat_test.go @@ -149,21 +149,31 @@ func (s *SATAttestorSuite) TestAttestFailsWithMalformedAttestationDataPayload() }, "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("", "TOKEN"), + s.requireAttestError(makeAttestRequest("FOO", "", "TOKEN"), "k8s-sat: missing UUID in attestation data") } func (s *SATAttestorSuite) TestAttestFailsWithNoToken() { - s.requireAttestError(makeAttestRequest("UUID", ""), + s.requireAttestError(makeAttestRequest("FOO", "UUID", ""), "k8s-sat: missing token in attestation data") } func (s *SATAttestorSuite) TestAttestFailsWithMalformedToken() { - s.requireAttestError(makeAttestRequest("UUID", "blah"), + 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, "", "") @@ -172,61 +182,61 @@ func (s *SATAttestorSuite) TestAttestFailsWithBadSignature() { parts[2] = "aaaa" token = strings.Join(parts, ".") - s.requireAttestError(makeAttestRequest("UUID", token), + 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("UUID", token), "invalid issuer claim") + s.requireAttestError(makeAttestRequest("FOO", "UUID", token), "invalid issuer claim") } func (s *SATAttestorSuite) TestAttestFailsWithMissingNamespaceClaim() { token := s.signToken(s.fooSigner, "", "") - s.requireAttestError(makeAttestRequest("UUID", token), "token missing namespace claim") + s.requireAttestError(makeAttestRequest("FOO", "UUID", token), "token missing namespace claim") } func (s *SATAttestorSuite) TestAttestFailsWithMissingServiceAccountNameClaim() { token := s.signToken(s.fooSigner, "NAMESPACE", "") - s.requireAttestError(makeAttestRequest("UUID", token), "token missing service account name claim") + 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("UUID", token), `"NAMESPACE:SERVICEACCOUNTNAME" is not a whitelisted service account`) + s.requireAttestError(makeAttestRequest("FOO", "UUID", token), `"NAMESPACE:SERVICEACCOUNTNAME" is not a whitelisted service account`) } -func (s *SATAttestorSuite) TestAttestFailsIfTokenSignatureCannotBeVerifiedByAnyCluster() { +func (s *SATAttestorSuite) TestAttestFailsIfTokenSignatureCannotBeVerifiedByCluster() { token := s.signToken(s.bazSigner, "NAMESPACE", "SERVICEACCOUNTNAME") - s.requireAttestError(makeAttestRequest("UUID", token), "k8s-sat: unable to verify token") + 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, "NS1", "SA1")) + 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/UUID") + 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:name:FOO"}, - {Type: "k8s_sat", Value: "service-account:namespace:NS1"}, - {Type: "k8s_sat", Value: "service-account:name:SA1"}, + {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, "NS2", "SA2")) + 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/UUID") + 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:name:BAR"}, - {Type: "k8s_sat", Value: "service-account:namespace:NS2"}, - {Type: "k8s_sat", Value: "service-account:name:SA2"}, + {Type: "k8s_sat", Value: "cluster:BAR"}, + {Type: "k8s_sat", Value: "agent_ns:NS2"}, + {Type: "k8s_sat", Value: "agent_sa:SA2"}, }, resp.Selectors) } @@ -377,8 +387,8 @@ func (s *SATAttestorSuite) signToken(signer jose.Signer, namespace, serviceAccou return token } -func (s *SATAttestorSuite) signAttestRequest(signer jose.Signer, namespace, serviceAccountName string) *nodeattestor.AttestRequest { - return makeAttestRequest("UUID", s.signToken(signer, namespace, serviceAccountName)) +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 { @@ -434,11 +444,11 @@ func (s *SATAttestorSuite) requireErrorContains(err error, contains string) { s.Require().Contains(err.Error(), contains) } -func makeAttestRequest(uuid, token string) *nodeattestor.AttestRequest { +func makeAttestRequest(cluster, uuid, token string) *nodeattestor.AttestRequest { return &nodeattestor.AttestRequest{ AttestationData: &common.AttestationData{ Type: "k8s_sat", - Data: []byte(fmt.Sprintf(`{"uuid": %q, "token": %q}`, uuid, token)), + Data: []byte(fmt.Sprintf(`{"cluster": %q, "uuid": %q, "token": %q}`, cluster, uuid, token)), }, } } From b133029b7edeccb7e12c5343ee28f56cc3d155a9 Mon Sep 17 00:00:00 2001 From: Andrew Harding Date: Fri, 19 Oct 2018 15:05:07 -0600 Subject: [PATCH 4/4] fix error message Signed-off-by: Andrew Harding --- pkg/server/plugin/nodeattestor/k8s/sat.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/server/plugin/nodeattestor/k8s/sat.go b/pkg/server/plugin/nodeattestor/k8s/sat.go index 299df7f631..c3b75643ef 100644 --- a/pkg/server/plugin/nodeattestor/k8s/sat.go +++ b/pkg/server/plugin/nodeattestor/k8s/sat.go @@ -233,7 +233,7 @@ func verifyTokenSignature(cluster *clusterConfig, token *jwt.JSONWebToken) (clai return claims, nil } if lastErr == nil { - lastErr = satError.New("token was not validated by any cluster") + lastErr = satError.New("token signed by unknown authority") } return nil, lastErr }