diff --git a/doc/plugin_agent_nodeattestor_k8s.md b/doc/plugin_agent_nodeattestor_k8s.md new file mode 100644 index 0000000000..460d85618c --- /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 disks. +It also needs a root certificate to validate the TLS certificate presented +ny 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 | | diff --git a/doc/plugin_server_nodeattestor_k8s.md b/doc/plugin_server_nodeattestor_k8s.md new file mode 100644 index 0000000000..90c1d895d9 --- /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/glide.lock b/glide.lock index 00bc78b854..b552c678db 100644 --- a/glide.lock +++ b/glide.lock @@ -1,12 +1,12 @@ -hash: 35f27a42ef95c2096d1e978ff87429ab29a41fb0e5d4fd4f8ef3a89c2964ee5d -updated: 2018-07-31T10:13:45.167019-06:00 +hash: 7fb9bbe6af1223178e659dc7b74cdb1fee8212a997a96e2b403dbb60fdcc7d32 +updated: 2018-08-13T12:06:16.228023439-07:00 imports: - name: github.com/armon/go-metrics - version: 783273d703149aaeb9897cf58613d5af48861c25 + version: 3c58d8115a78a6879e5df75ae900846768d36895 - name: github.com/armon/go-radix - version: 1fca145dffbcaa8fe914309b1ec0cfc67500fe61 + version: 7fddfc383310abc091d79a27f116d30cf0424032 - name: github.com/aws/aws-sdk-go - version: 123c8c8326730972861f45fe2e6e7b1d64b4d487 + version: 254f2f37e83d1b3585237fe166aacd14c1783e0b subpackages: - aws - aws/awserr @@ -36,15 +36,20 @@ imports: - private/protocol/rest - private/protocol/xml/xmlutil - service/ec2 + - service/iam - service/sts - name: github.com/Azure/azure-sdk-for-go version: 4e8cbbfb1aeab140cd0fa97fd16b64ee18c3ca6a subpackages: - - services/compute/mgmt/2017-12-01/compute + - profiles/latest/compute/mgmt/compute + - profiles/latest/network/mgmt/network + - profiles/latest/resources/mgmt/resources + - services/compute/mgmt/2018-04-01/compute + - services/network/mgmt/2018-01-01/network - services/resources/mgmt/2018-02-01/resources - version - name: github.com/Azure/go-autorest - version: dd94e014aaf16d1df746762e392aa201c1b4c461 + version: bca49d5b51a50dc5bb17bbf6204c711c6dbded06 subpackages: - autorest - autorest/adal @@ -53,24 +58,34 @@ imports: - autorest/date - autorest/to - autorest/validation - - logger - version - name: github.com/bgentry/speakeasy version: 4aabc24848ce5fd31929f7d1e4ea74d3709c14cd - name: github.com/davecgh/go-spew - version: ecdeabc65495df2dec95d7c4a4c3e021903035e5 + version: 782f4967f2dc4564575ca782fe2d04090b5faca8 subpackages: - spew - name: github.com/dgrijalva/jwt-go - version: 06ea1031745cb8b3dab3f6a236daf2b0aa468b7e + version: 0b96aaa707760d6ab28d9b9d1913ff5993328bae - name: github.com/dimchansky/utfbom version: 5448fe645cb1964ba70ac8f9f2ffe975e61a536c +- name: github.com/fatih/color + version: 2d684516a8861da43017284349b7e303e809ac21 +- name: github.com/ghodss/yaml + version: 73d445a93680fa1a78ae23a5839bad48f32ba1ee - name: github.com/go-ini/ini - version: 32e4be5f41bb918afb6e37c07426e2ddbcb6647e + version: d58d458bec3cb5adec4b7ddb41131855eac0b33f - name: github.com/go-ole/go-ole - version: a41e3c4b706f6ae8dfbff342b06e40fa4d2d0506 + version: 7a0fa49edf48165190530c675167e2f319a05268 subpackages: - oleutil +- name: github.com/gogo/protobuf + version: c0656edd0d9eab7c66d1eb0c568f9039345796f7 + subpackages: + - proto + - sortkeys +- name: github.com/golang/glog + version: 44145f04b68cf362d9c4df2182967c2275eaefed - name: github.com/golang/mock version: 87106cff4623c0d8f41c0fef931007bc18b61f78 subpackages: @@ -90,6 +105,20 @@ imports: - ptypes/struct - ptypes/timestamp - ptypes/wrappers +- name: github.com/google/btree + version: 7d79101e329e5a3adf994758c578dab82b90c017 +- name: github.com/google/gofuzz + version: 44d81051d367757e1c7c6a5a86423ece9afcf63c +- name: github.com/googleapis/gnostic + version: 0c5108395e2debce0d731cf0287ddf7242066aba + subpackages: + - OpenAPIv2 + - compiler + - extensions +- name: github.com/gregjones/httpcache + version: 787624de3eb7bd915c329cba748687a3b22666a6 + subpackages: + - diskcache - name: github.com/grpc-ecosystem/grpc-gateway version: 8cc3a55af3bcf171a1c23a90c4df9cf591706104 subpackages: @@ -97,17 +126,17 @@ imports: - runtime/internal - utilities - name: github.com/hashicorp/errwrap - version: 7554cd9344cec97297fa6649b055a8c98c2a1e55 + version: d6c0cd88035724dd42e0f335ae30161c20575ecc - name: github.com/hashicorp/go-hclog version: ca137eb4b4389c9bc6f1a6d887f056bf16c00510 - name: github.com/hashicorp/go-immutable-radix version: 7f3cd4390caab3250a57f30efdb2a65dd7649ecf - name: github.com/hashicorp/go-multierror - version: b7773ae218740a7be65057fc60b366a49b538a44 + version: 3d5d8f294aa03d8e98859feac328afbdf1ae0703 - name: github.com/hashicorp/go-plugin version: e37881a3f1a07fce82b3d99ce0342a72e53386bc - name: github.com/hashicorp/golang-lru - version: 0fb14efe8c47ae851c0034ed7a448854d3d34cf3 + version: a0d98a5f288019575c6d1f4bb1573fef2d1fcdc4 subpackages: - simplelru - name: github.com/hashicorp/hcl @@ -123,7 +152,9 @@ imports: - json/scanner - json/token - name: github.com/hashicorp/yamux - version: 683f49123a33db61abfb241b7ac5e4af4dc54d55 + version: 3520598351bb3500a49ae9563f5539666ae0a27c +- name: github.com/imdario/mergo + version: 6633656539c1639d9d78127b7d47c622b5d7b6dc - name: github.com/imkira/go-observer version: 2b5c0039075a41408f1a33aa6391bd77d3e5a132 - name: github.com/jinzhu/gorm @@ -132,32 +163,42 @@ imports: - dialects/postgres - dialects/sqlite - name: github.com/jinzhu/inflection - version: 1c35d901db3da928c72a72d8458480cc9ade058f + version: 04140366298a54a039076d798123ffa108fff46c - name: github.com/jmespath/go-jmespath version: c2b33e8439af944379acbdd9c3a5fe0bc44bd8a5 +- name: github.com/json-iterator/go + version: f2b4162afba35581b6d4a50d3b8f34e33c144682 - name: github.com/jstemmer/go-junit-report - version: a009038a6334cbda8dd5984dfd2f387a2ce9fdbf + version: 385fac0ced9acaae6dc5b39144194008ded00697 - name: github.com/jteeuwen/go-bindata version: bbd0c6e271208dce66d8fda4bc536453cd27fc4a - name: github.com/lib/pq - version: d34b9ff171c21ad295489235aec8b6626023cd04 + version: 90697d60dd844d5ef6ff15135d0203f65d2f53b8 subpackages: - hstore - oid +- name: github.com/mattn/go-colorable + version: efa589957cd060542a26d2dd7832fd6a6c6c3ade - name: github.com/mattn/go-isatty version: 6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c - name: github.com/mattn/go-sqlite3 version: ca5e3819723d8eeaf170ad510e7da1d6d2e94a08 - name: github.com/mitchellh/cli - version: 518dc677a1e1222682f4e7db06721942cb8e9e4c + version: c48282d14eba4b0817ddef3f832ff8d13851aefd - name: github.com/mitchellh/go-testing-interface version: a61a99592b77c9ba629d254a693acffaeb4b7e28 +- name: github.com/modern-go/concurrent + version: bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94 +- name: github.com/modern-go/reflect2 + version: 05fbef0ca5da472bbf96c9322b84a53edc03c9fd +- name: github.com/peterbourgon/diskv + version: 5f041e8faa004a95c88a202771f4cc3e991971e6 - name: github.com/pmezard/go-difflib - version: 792786c7400a136282c1664665ae0a8db921c6c2 + version: d8ed2627bdf02c080bf22230dbb337003b7aba2d subpackages: - difflib - name: github.com/posener/complete - version: cdc49b71388c2ab059f57997ef2575c9e8b4f146 + version: e037c22b2fcfa85e74495388f03892ed194bba76 subpackages: - cmd - cmd/install @@ -167,7 +208,7 @@ imports: subpackages: - context - name: github.com/shirou/gopsutil - version: 6a368fb7cd1221fa6ea90facc9447c9a2234c255 + version: 68ff0e299699630e174f9afaebb4b8c99d0520dc subpackages: - cpu - host @@ -178,9 +219,11 @@ imports: - name: github.com/shirou/w32 version: bb4de0191aa41b5507caa14b0650cdbddcd9280b - name: github.com/sirupsen/logrus - version: d682213848ed68c0a260ca37d6dd5ace8423f5ba + version: e4b0c6d7829bcf64435536c4a88f4088a3c76203 subpackages: - hooks/test +- name: github.com/spf13/pflag + version: 583c0c0531f06d5278b7d917446061adc344b5cd - name: github.com/spiffe/go-spiffe version: 2bb3101d62b4bea6371d792e818360e3f056867b subpackages: @@ -188,9 +231,9 @@ imports: - tls - uri - name: github.com/StackExchange/wmi - version: 5d049714c4a64225c3c79a7cf7d02f7fb5b96338 + version: b12b22c5341f0c26d88c4d66176330500e84db68 - name: github.com/stretchr/testify - version: 87b1dfb5b2fa649f52695dd9eae19abe404a4308 + version: c679ae2cc0cb27ec3293fea7e254e47386f05d69 subpackages: - assert - require @@ -198,15 +241,16 @@ imports: - name: github.com/zeebo/errs version: 63dc8634da43f7c647c5921505363588cb833d29 - name: golang.org/x/crypto - version: a6600008915114d9c087fad9f03d75087b1a74df + version: 49796115aa4b964c318aad4f3084fdb41e9aa067 subpackages: - ed25519 - ed25519/internal/edwards25519 + - pbkdf2 - pkcs12 - pkcs12/internal/rc2 - ssh/terminal - name: golang.org/x/net - version: 5ccada7d0a7ba9aeb5d3aca8d3501b4c2a509fec + version: 1c05540f6879653db88113bc4a2b70aec4bd491f subpackages: - context - http2 @@ -216,24 +260,28 @@ imports: - lex/httplex - trace - name: golang.org/x/sys - version: 2c42eef0765b9837fbdab12011af7830f55f88f0 + version: 98c5dad5d1a0e8a73845ecc8897d0bd56586511d subpackages: - unix - windows - name: golang.org/x/text - version: e19ae1496984b1c655b8044a65c0300a3c878dd3 + version: b19bf474d317b857955b12035d2c5acb57ce8b01 subpackages: - secure/bidirule - transform - unicode/bidi - unicode/norm +- name: golang.org/x/time + version: f51c12702a4d776e4c1fa9b0fabab841babae631 + subpackages: + - rate - name: google.golang.org/genproto - version: a8101f21cf983e773d0c1133ebc5424792003214 + version: 383e8b2c3b9e36c4076b235b32537292176bae20 subpackages: - googleapis/api/annotations - googleapis/rpc/status - name: google.golang.org/grpc - version: 168a6198bcb0ef175f7dacec0b8691fc141dc9b8 + version: 32fb0ac620c32ba40a4626ddf94d90d12cce3455 subpackages: - balancer - balancer/base @@ -249,7 +297,9 @@ imports: - internal - internal/backoff - internal/channelz + - internal/envconfig - internal/grpcrand + - internal/transport - keepalive - metadata - naming @@ -260,13 +310,193 @@ imports: - stats - status - tap - - transport +- name: gopkg.in/inf.v0 + version: 3887ee99ecf07df5b447e9b00d9c0b2adaa9f3e4 - name: gopkg.in/square/go-jose.v2 - version: 349dc03548930652802aadc09fb126e2b7cb6d80 + version: 8254d6c783765f38c8675fae4427a1fe73fbd09d subpackages: - cipher - json - jwt - name: gopkg.in/tomb.v2 version: d5d1b5820637886def9eef33e03a27a9f166942c +- name: gopkg.in/yaml.v2 + version: 670d4cfef0544295bc27a114dbac37980d83185a +- name: k8s.io/api + version: 70491ec73e10be2989242c913738f049a37e72c7 + subpackages: + - admissionregistration/v1alpha1 + - admissionregistration/v1beta1 + - apps/v1 + - apps/v1beta1 + - apps/v1beta2 + - authentication/v1 + - authentication/v1beta1 + - authorization/v1 + - authorization/v1beta1 + - autoscaling/v1 + - autoscaling/v2beta1 + - batch/v1 + - batch/v1beta1 + - batch/v2alpha1 + - certificates/v1beta1 + - coordination/v1beta1 + - core/v1 + - events/v1beta1 + - extensions/v1beta1 + - imagepolicy/v1alpha1 + - networking/v1 + - policy/v1beta1 + - rbac/v1 + - rbac/v1alpha1 + - rbac/v1beta1 + - scheduling/v1alpha1 + - scheduling/v1beta1 + - settings/v1alpha1 + - storage/v1 + - storage/v1alpha1 + - storage/v1beta1 +- name: k8s.io/apimachinery + version: cd53e6e3b3a5f792de51f8d95fb17f46f0627abf + subpackages: + - pkg/api/errors + - pkg/api/meta + - pkg/api/resource + - pkg/apis/meta/internalversion + - pkg/apis/meta/v1 + - pkg/apis/meta/v1/unstructured + - pkg/apis/meta/v1beta1 + - pkg/conversion + - pkg/conversion/queryparams + - pkg/fields + - pkg/labels + - pkg/runtime + - pkg/runtime/schema + - pkg/runtime/serializer + - pkg/runtime/serializer/json + - pkg/runtime/serializer/protobuf + - pkg/runtime/serializer/recognizer + - pkg/runtime/serializer/streaming + - pkg/runtime/serializer/versioning + - pkg/selection + - pkg/types + - pkg/util/cache + - pkg/util/clock + - pkg/util/diff + - pkg/util/errors + - pkg/util/framer + - pkg/util/intstr + - pkg/util/json + - pkg/util/mergepatch + - pkg/util/naming + - pkg/util/net + - pkg/util/runtime + - pkg/util/sets + - pkg/util/strategicpatch + - pkg/util/validation + - pkg/util/validation/field + - pkg/util/wait + - pkg/util/yaml + - pkg/version + - pkg/watch + - third_party/forked/golang/json + - third_party/forked/golang/reflect +- name: k8s.io/client-go + version: bdfc4cfc125f556bfe46fff68207767a605b64ed + subpackages: + - discovery + - discovery/fake + - kubernetes + - kubernetes/fake + - kubernetes/scheme + - kubernetes/typed/admissionregistration/v1alpha1 + - kubernetes/typed/admissionregistration/v1alpha1/fake + - kubernetes/typed/admissionregistration/v1beta1 + - kubernetes/typed/admissionregistration/v1beta1/fake + - kubernetes/typed/apps/v1 + - kubernetes/typed/apps/v1/fake + - kubernetes/typed/apps/v1beta1 + - kubernetes/typed/apps/v1beta1/fake + - kubernetes/typed/apps/v1beta2 + - kubernetes/typed/apps/v1beta2/fake + - kubernetes/typed/authentication/v1 + - kubernetes/typed/authentication/v1/fake + - kubernetes/typed/authentication/v1beta1 + - kubernetes/typed/authentication/v1beta1/fake + - kubernetes/typed/authorization/v1 + - kubernetes/typed/authorization/v1/fake + - kubernetes/typed/authorization/v1beta1 + - kubernetes/typed/authorization/v1beta1/fake + - kubernetes/typed/autoscaling/v1 + - kubernetes/typed/autoscaling/v1/fake + - kubernetes/typed/autoscaling/v2beta1 + - kubernetes/typed/autoscaling/v2beta1/fake + - kubernetes/typed/batch/v1 + - kubernetes/typed/batch/v1/fake + - kubernetes/typed/batch/v1beta1 + - kubernetes/typed/batch/v1beta1/fake + - kubernetes/typed/batch/v2alpha1 + - kubernetes/typed/batch/v2alpha1/fake + - kubernetes/typed/certificates/v1beta1 + - kubernetes/typed/certificates/v1beta1/fake + - kubernetes/typed/coordination/v1beta1 + - kubernetes/typed/coordination/v1beta1/fake + - kubernetes/typed/core/v1 + - kubernetes/typed/core/v1/fake + - kubernetes/typed/events/v1beta1 + - kubernetes/typed/events/v1beta1/fake + - kubernetes/typed/extensions/v1beta1 + - kubernetes/typed/extensions/v1beta1/fake + - kubernetes/typed/networking/v1 + - kubernetes/typed/networking/v1/fake + - kubernetes/typed/policy/v1beta1 + - kubernetes/typed/policy/v1beta1/fake + - kubernetes/typed/rbac/v1 + - kubernetes/typed/rbac/v1/fake + - kubernetes/typed/rbac/v1alpha1 + - kubernetes/typed/rbac/v1alpha1/fake + - kubernetes/typed/rbac/v1beta1 + - kubernetes/typed/rbac/v1beta1/fake + - kubernetes/typed/scheduling/v1alpha1 + - kubernetes/typed/scheduling/v1alpha1/fake + - kubernetes/typed/scheduling/v1beta1 + - kubernetes/typed/scheduling/v1beta1/fake + - kubernetes/typed/settings/v1alpha1 + - kubernetes/typed/settings/v1alpha1/fake + - kubernetes/typed/storage/v1 + - kubernetes/typed/storage/v1/fake + - kubernetes/typed/storage/v1alpha1 + - kubernetes/typed/storage/v1alpha1/fake + - kubernetes/typed/storage/v1beta1 + - kubernetes/typed/storage/v1beta1/fake + - pkg/apis/clientauthentication + - pkg/apis/clientauthentication/v1alpha1 + - pkg/apis/clientauthentication/v1beta1 + - pkg/version + - plugin/pkg/client/auth/exec + - rest + - rest/watch + - testing + - tools/auth + - tools/cache + - tools/clientcmd + - tools/clientcmd/api + - tools/clientcmd/api/latest + - tools/clientcmd/api/v1 + - tools/metrics + - tools/pager + - tools/reference + - transport + - util/buffer + - util/cert + - util/certificate/csr + - util/connrotation + - util/flowcontrol + - util/homedir + - util/integer + - util/retry +- name: k8s.io/kube-openapi + version: 0cf8f7e6ed1d2e3d47d02e3b6e559369af24d803 + subpackages: + - pkg/util/proto testImports: [] diff --git a/glide.yaml b/glide.yaml index b420059671..4213b730b2 100644 --- a/glide.yaml +++ b/glide.yaml @@ -11,6 +11,8 @@ import: version: e37881a3f1a07fce82b3d99ce0342a72e53386bc - package: github.com/sirupsen/logrus - package: golang.org/x/net +- package: golang.org/x/sys + version: 98c5dad5d1a0e8a73845ecc8897d0bd56586511d - package: github.com/satori/go.uuid subpackages: - context @@ -32,7 +34,9 @@ import: version: 1.3.0 - package: gopkg.in/tomb.v2 - package: github.com/dgrijalva/jwt-go -testImport: +- package: github.com/hashicorp/go-hclog + version: ca137eb4b4389c9bc6f1a6d887f056bf16c00510 +- package: k8s.io/client-go - package: github.com/stretchr/testify subpackages: - assert diff --git a/pkg/agent/catalog/catalog.go b/pkg/agent/catalog/catalog.go index b85b3ea00f..d7c93567ba 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..0a7384c2fe --- /dev/null +++ b/pkg/agent/plugin/nodeattestor/k8s/k8s.go @@ -0,0 +1,244 @@ +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: 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: failed to calculate challenge response: %v", err) + } + + responseBytes, err := json.Marshal(response) + if err != nil { + return fmt.Errorf("k8s: 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: unable to decode configuration: %v", err) + } + + if config.TrustDomain == "" { + return nil, errors.New("k8s: trust_domain is required") + } + if config.PrivateKeyPath == "" { + return nil, errors.New("k8s: private_key_path is required") + } + if config.CertificatePath == "" { + return nil, errors.New("k8s: certificate_path is required") + } + if config.K8sCACertPath == "" { + return nil, errors.New("k8s: ca_certificate_path is required") + } + + p.setConfig(config) + + 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 +} + +func (p *K8sPlugin) loadConfigData() (*configData, error) { + config := p.getConfig() + if config == nil { + return nil, errors.New("k8s: not configured") + } + + 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 + } + + k8sCert, err := fetchK8sCert(p.kubeClient) + if err != nil { + return nil, fmt.Errorf("k8s: unable to retrieve identity document: %v", err) + } + + parsedCert, err := x509.ParseCertificate(k8sCert.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("k8s: error parsing identity document: %v", err) + } + + attestationDataBytes, err := json.Marshal(x509pop.AttestationData{ + Certificates: k8sCert.Certificate, + }) + if err != nil { + return nil, fmt.Errorf("k8s: 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..d121b352c3 --- /dev/null +++ b/pkg/agent/plugin/nodeattestor/k8s/k8s_test.go @@ -0,0 +1,239 @@ +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" + util "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.kubeClient = fakeClient + + p.setConfig(&K8sConfig{TrustDomain: trustDomain}) + 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", ""}, + } + + 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`, + t.trustDomain, t.privateKeyPath, t.certificatePath, t.caCertificatePath) + + resp, err := p.Configure(context.Background(), &plugin.ConfigureRequest{ + Configuration: config, + }) + if t.expectedErr != "" { + s.errorContains(err, t.expectedErr) + require.Nil(resp) + } else { + require.NoError(err) + require.Equal(resp, &plugin.ConfigureResponse{}) + } + } +} + +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..7184d5c4ed --- /dev/null +++ b/pkg/common/plugin/k8s/k8s.go @@ -0,0 +1,16 @@ +package k8s + +import ( + "crypto/x509" + "net/url" + "path" +) + +func SpiffeID(trustDomain string, cert *x509.Certificate) string { + u := url.URL{ + Scheme: "spiffe", + Host: trustDomain, + Path: path.Join("spire", "agent", "k8s", cert.Subject.CommonName), + } + return u.String() +} diff --git a/pkg/server/catalog/catalog.go b/pkg/server/catalog/catalog.go index f8e4f66825..4feabb375c 100644 --- a/pkg/server/catalog/catalog.go +++ b/pkg/server/catalog/catalog.go @@ -11,6 +11,7 @@ import ( "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" "github.com/spiffe/spire/pkg/server/plugin/noderesolver/noop" @@ -62,6 +63,7 @@ var ( "gcp_iit": nodeattestor.NewBuiltIn(gcp.NewIITAttestorPlugin()), "x509pop": nodeattestor.NewBuiltIn(x509pop.New()), "azure_msi": nodeattestor.NewBuiltIn(azure.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..e0e4e86ea2 --- /dev/null +++ b/pkg/server/plugin/nodeattestor/k8s/k8s.go @@ -0,0 +1,210 @@ +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 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: "+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..ff3a3245f3 --- /dev/null +++ b/pkg/server/plugin/nodeattestor/k8s/k8s_test.go @@ -0,0 +1,259 @@ +package k8s + +import ( + "context" + "crypto" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "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) + require.Equal("spiffe://"+trustDomain+"/spire/agent/k8s/"+s.idDoc.Subject.CommonName, 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: not configured") + + // unexpected data type + attestFails(&common.AttestationData{Type: "foo"}, + "k8s: unexpected attestation data type \"foo\"") + + // malformed data + attestFails(&common.AttestationData{Type: "k8s"}, + "k8s: failed to unmarshal data") + + // no identity doc + attestFails(makeData(&x509pop.AttestationData{}), + "k8s: no certificate to attest") + + // malformed identity doc + attestFails(makeData(&x509pop.AttestationData{Certificates: [][]byte{{0x00}}}), + "k8s: 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: certificate verification failed") + + // malformed challenge response + challengeResponseFails("", "k8s: unable to unmarshal challenge response") + + // invalid response + challengeResponseFails("{}", "k8s: 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-----