diff --git a/Gopkg.lock b/Gopkg.lock index 1794437e..8b0340d0 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -298,7 +298,7 @@ "utils", ] pruneopts = "UT" - revision = "a2e2d27fd5eff22ba94b1f2bd2fc946f5bb7f041" + revision = "3e1e2dcfa54298e0fb86e0be86ab36d79f36473e" [[projects]] branch = "master" diff --git a/Makefile b/Makefile index d8242db5..8b116ddd 100644 --- a/Makefile +++ b/Makefile @@ -303,16 +303,20 @@ bundle-darwin: binary-darwin .PHONY: binary-linux binary-darwin bundle-linux bundle-darwin ################################################# -# Targets for creating OS specific artifacts +# Targets for creating OS specific artifacts and archives ################################################# artifacts-linux-tag: bundle-linux debian artifacts-darwin-tag: bundle-darwin -artifacts-tag: artifacts-linux-tag artifacts-darwin-tag +artifacts-archive-tag: + $Q mkdir -p $(RELEASE) + $Q git archive v$(VERSION) | gzip > $(RELEASE)/step-certificates_$(VERSION).tar.gz -.PHONY: artifacts-linux-tag artifacts-darwin-tag artifacts-tag +artifacts-tag: artifacts-linux-tag artifacts-darwin-tag artifacts-archive-tag + +.PHONY: artifacts-linux-tag artifacts-darwin-tag artifacts-archive-tag artifacts-tag ################################################# # Targets for creating step artifacts @@ -321,7 +325,7 @@ artifacts-tag: artifacts-linux-tag artifacts-darwin-tag # For all builds that are not tagged artifacts-master: -# For all build with a release candidate tag +# For all builds with a release-candidate (-rc) tag artifacts-release-candidate: artifacts-tag # For all builds with a release tag diff --git a/README.md b/README.md index 731801bd..f952774a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ mTLS](https://raw.githubusercontent.com/smallstep/certificates/master/images/con There's just one problem: **you need certificates issued by your own certificate authority (CA)**. Building and operating a CA, issuing certificates, and making sure they're renewed before they expire is tricky. -This project provides the infratructure, automations, and workflows you'll +This project provides the infrastructure, automations, and workflows you'll need. `step certificates` is part of smallstep's broader security architecture, which diff --git a/autocert/INSTALL.md b/autocert/INSTALL.md index 0b4b788b..a9fe843e 100644 --- a/autocert/INSTALL.md +++ b/autocert/INSTALL.md @@ -174,3 +174,7 @@ $ kubectl get mutatingwebhookconfiguration NAME CREATED AT autocert-webhook-config 2019-01-17T22:57:57Z ``` + +### Move on to usage instructions + +Make sure to follow the autocert usage steps at https://github.com/smallstep/certificates/tree/master/autocert#usage diff --git a/autocert/controller/main.go b/autocert/controller/main.go index 68cf3bfd..eeb8f393 100644 --- a/autocert/controller/main.go +++ b/autocert/controller/main.go @@ -45,12 +45,24 @@ const ( // Config options for the autocert admission controller. type Config struct { - LogFormat string `yaml:"logFormat"` - CaURL string `yaml:"caUrl"` - CertLifetime string `yaml:"certLifetime"` - Bootstrapper corev1.Container `yaml:"bootstrapper"` - Renewer corev1.Container `yaml:"renewer"` - CertsVolume corev1.Volume `yaml:"certsVolume"` + LogFormat string `yaml:"logFormat"` + CaURL string `yaml:"caUrl"` + CertLifetime string `yaml:"certLifetime"` + Bootstrapper corev1.Container `yaml:"bootstrapper"` + Renewer corev1.Container `yaml:"renewer"` + CertsVolume corev1.Volume `yaml:"certsVolume"` + RestrictCertificatesToNamespace bool `yaml:"restrictCertificatesToNamespace"` + ClusterDomain string `yaml:"clusterDomain"` +} + +// GetClusterDomain returns the Kubernetes cluster domain, defaults to +// "cluster.local" if not specified in the configuration. +func (c Config) GetClusterDomain() string { + if c.ClusterDomain != "" { + return c.ClusterDomain + } + + return "cluster.local" } // PatchOperation represents a RFC6902 JSONPatch Operation @@ -216,6 +228,7 @@ func mkBootstrapper(config *Config, commonName string, namespace string, provisi Name: "COMMON_NAME", Value: commonName, }) + b.Env = append(b.Env, corev1.EnvVar{ Name: "STEP_TOKEN", ValueFrom: &corev1.EnvVarSource{ @@ -357,7 +370,8 @@ func addAnnotations(existing, new map[string]string) (ops []PatchOperation) { func patch(pod *corev1.Pod, namespace string, config *Config, provisioner Provisioner) ([]byte, error) { var ops []PatchOperation - commonName := pod.ObjectMeta.GetAnnotations()[admissionWebhookAnnotationKey] + annotations := pod.ObjectMeta.GetAnnotations() + commonName := annotations[admissionWebhookAnnotationKey] renewer := mkRenewer(config) bootstrapper, err := mkBootstrapper(config, commonName, namespace, provisioner) if err != nil { @@ -376,7 +390,10 @@ func patch(pod *corev1.Pod, namespace string, config *Config, provisioner Provis // shouldMutate checks whether a pod is subject to mutation by this admission controller. A pod // is subject to mutation if it's annotated with the `admissionWebhookAnnotationKey` and if it // has not already been processed (indicated by `admissionWebhookStatusKey` set to `injected`). -func shouldMutate(metadata *metav1.ObjectMeta) bool { +// If the pod requests a certificate with a subject matching a namespace other than its own +// and restrictToNamespace is true, then shouldMutate will return a validation error +// that should be returned to the client. +func shouldMutate(metadata *metav1.ObjectMeta, namespace string, clusterDomain string, restrictToNamespace bool) (bool, error) { annotations := metadata.GetAnnotations() if annotations == nil { annotations = map[string]string{} @@ -385,10 +402,26 @@ func shouldMutate(metadata *metav1.ObjectMeta) bool { // Only mutate if the object is annotated appropriately (annotation key set) and we haven't // mutated already (status key isn't set). if annotations[admissionWebhookAnnotationKey] == "" || annotations[admissionWebhookStatusKey] == "injected" { - return false + return false, nil + } + + if !restrictToNamespace { + return true, nil + } + + subject := strings.Trim(annotations[admissionWebhookAnnotationKey], ".") + + err := fmt.Errorf("subject \"%s\" matches a namespace other than \"%s\" and is not permitted. This check can be disabled by setting restrictCertificatesToNamespace to false in the autocert-config ConfigMap", subject, namespace) + + if strings.HasSuffix(subject, ".svc") && !strings.HasSuffix(subject, fmt.Sprintf(".%s.svc", namespace)) { + return false, err } - return true + if strings.HasSuffix(subject, fmt.Sprintf(".svc.%s", clusterDomain)) && !strings.HasSuffix(subject, fmt.Sprintf(".%s.svc.%s", namespace, clusterDomain)) { + return false, err + } + + return true, nil } // mutate takes an `AdmissionReview`, determines whether it is subject to mutation, and returns @@ -418,7 +451,20 @@ func mutate(review *v1beta1.AdmissionReview, config *Config, provisioner Provisi "user": request.UserInfo, }) - if !shouldMutate(&pod.ObjectMeta) { + mutationAllowed, validationErr := shouldMutate(&pod.ObjectMeta, request.Namespace, config.GetClusterDomain(), config.RestrictCertificatesToNamespace) + + if validationErr != nil { + ctxLog.WithField("error", validationErr).Info("Validation error") + return &v1beta1.AdmissionResponse{ + Allowed: false, + UID: request.UID, + Result: &metav1.Status{ + Message: validationErr.Error(), + }, + } + } + + if !mutationAllowed { ctxLog.WithField("annotations", pod.Annotations).Info("Skipping mutation") return &v1beta1.AdmissionResponse{ Allowed: true, diff --git a/autocert/controller/main_test.go b/autocert/controller/main_test.go new file mode 100644 index 00000000..1f0290eb --- /dev/null +++ b/autocert/controller/main_test.go @@ -0,0 +1,75 @@ +package main + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "testing" +) + +func TestGetClusterDomain(t *testing.T) { + c := Config{} + if c.GetClusterDomain() != "cluster.local" { + t.Errorf("cluster domain should default to cluster.local, not: %s", c.GetClusterDomain()) + } + + c.ClusterDomain = "mydomain.com" + if c.GetClusterDomain() != "mydomain.com" { + t.Errorf("cluster domain should default to cluster.local, not: %s", c.GetClusterDomain()) + } +} + +func TestShouldMutate(t *testing.T) { + testCases := []struct { + description string + subject string + namespace string + expected bool + }{ + {"full cluster domain", "test.default.svc.cluster.local", "default", true}, + {"full cluster domain wrong ns", "test.default.svc.cluster.local", "kube-system", false}, + {"left dots get stripped", ".test.default.svc.cluster.local", "default", true}, + {"left dots get stripped wrong ns", ".test.default.svc.cluster.local", "kube-system", false}, + {"right dots get stripped", "test.default.svc.cluster.local.", "default", true}, + {"right dots get stripped wrong ns", "test.default.svc.cluster.local.", "kube-system", false}, + {"dots get stripped", ".test.default.svc.cluster.local.", "default", true}, + {"dots get stripped wrong ns", ".test.default.svc.cluster.local.", "kube-system", false}, + {"partial cluster domain", "test.default.svc.cluster", "default", true}, + {"partial cluster domain wrong ns is still allowed because not valid hostname", "test.default.svc.cluster", "kube-system", true}, + {"service domain", "test.default.svc", "default", true}, + {"service domain wrong ns", "test.default.svc", "kube-system", false}, + {"two part domain", "test.default", "default", true}, + {"two part domain different ns", "test.default", "kube-system", true}, + {"one hostname", "test", "default", true}, + {"no subject specified", "", "default", false}, + {"three part not cluster", "test.default.com", "kube-system", true}, + {"four part not cluster", "test.default.svc.com", "kube-system", true}, + {"five part not cluster", "test.default.svc.cluster.com", "kube-system", true}, + {"six part not cluster", "test.default.svc.cluster.local.com", "kube-system", true}, + } + + for _, testCase := range testCases { + t.Run(testCase.description, func(t *testing.T) { + mutationAllowed, validationErr := shouldMutate(&metav1.ObjectMeta{ + Annotations: map[string]string{ + admissionWebhookAnnotationKey: testCase.subject, + }, + }, testCase.namespace, "cluster.local", true) + if mutationAllowed != testCase.expected { + t.Errorf("shouldMutate did not return %t for %s", testCase.expected, testCase.description) + } + if testCase.subject != "" && mutationAllowed == false && validationErr == nil { + t.Errorf("shouldMutate should return validation error for invalid hostname") + } + }) + } +} + +func TestShouldMutateNotRestrictToNamespace(t *testing.T) { + mutationAllowed, _ := shouldMutate(&metav1.ObjectMeta{ + Annotations: map[string]string{ + admissionWebhookAnnotationKey: "test.default.svc.cluster.local", + }, + }, "kube-system", "cluster.local", false) + if mutationAllowed == false { + t.Errorf("shouldMutate should return true even with a wrong namespace if restrictToNamespace is false.") + } +} diff --git a/autocert/install/02-autocert.yaml b/autocert/install/02-autocert.yaml index f6453ca2..07f722bf 100644 --- a/autocert/install/02-autocert.yaml +++ b/autocert/install/02-autocert.yaml @@ -21,6 +21,8 @@ metadata: data: config.yaml: | logFormat: json # or text + restrictCertificatesToNamespace: true + clusterDomain: cluster.local caUrl: https://ca.step.svc.cluster.local certLifetime: 24h renewer: diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 6251f73c..e71d4c01 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -421,7 +421,7 @@ Please enter a password to encrypt the provisioner private key? password }, [...] -# launch CA... +## launch CA... $ step-ca $(step path)/config/ca.json Please enter the password to decrypt ~/.step/secrets/intermediate_ca_key: password 2019/02/21 12:09:51 Serving HTTPS on :9443 ... @@ -456,6 +456,79 @@ $ step ca renew site.crt site.key error renewing certificate: Unauthorized ``` +## Use Oauth OIDC to obtain personal certificates + +To authenticate users with the CA you can leverage services that expose OAuth +OpenID Connect identity providers. One of the most common providers, and the +one we'll use in this example, is G-Suite. + +Navigate to the Google APIs developer console and pick a suitable project from the +top navbar's dropdown. + +![Google Dev Console](oidc1.png) + +In the masthead navigation click **Credentials** (key symbol) and then "OAuth +consent screen" from the subnav. Fill out naming details, all mandatory fields, +and decide if your app is of type **Public** or **Internal**. Internal +will make sure the access scope is bound to your G-Suite organization. +**Public** will let anybody with a Google Account log in, incl. +`gmail.com` accounts. + +Move back to **Credentials** on the subnav and choose "OAuth client ID" from the +**Create credentials** dropdown. Since OIDC will be used from the `step CLI` pick **Other** +from the available options and pick a name (e.g. **Step CLI**). + +![Create credential](oidc2.png) + +On successful completion, a confirmation modal with both `clientID` and +`clientSecret` will be presented. Please note that the `clientSecret` will +allow applications access to the configured OAuth consent screen. However, it +will not allow direct authentication of users without their own MfA credentials +per account. + +![OIDC credentials](oidc3.png) + +Now using `clientID` and `clientSecret` run the following command to add +G-Suite as a provisioner to `step certificates`. Please see [`step ca +provisioner add`](https://smallstep.com/docs/cli/ca/provisioner/add/)'s docs +for all available configuration options and descriptions. + +```bash +$ step ca provisioner add Google --type oidc --ca-config $(step path)/config/ca.json \ + --client-id 972437157139-ssiqna0g4ibuhafl3pkrrcb52tbroekt.apps.googleusercontent.com \ + --client-secret RjEk-GwKBvdsFAICiJhn_RiF \ + --configuration-endpoint https://accounts.google.com/.well-known/openid-configuration \ + --domain yourdomain.com --domain gmail.com +``` + +Start up the online CA or send a HUP signal if it's already running to reload +the configuration and pick up the new provisioner. Now users should be able to +obtain certificates using the familiar `step ca certificate` flow: + +```bash +$ step ca certificate sebastian@smallstep.com personal.crt personal.key +Use the arrow keys to navigate: ↓ ↑ → ← +What provisioner key do you want to use? + fYDoiQdYueq_LAXx2kqA4N_Yjf_eybe-wari7Js5iXI (admin) + ▸ 972437157139-ssiqna0g4ibuhafl3pkrrcb52tbroekt.apps.googleusercontent.com (Google) +✔ Key ID: 972437157139-ssiqna0g4ibuhafl3pkrrcb52tbroekt.apps.googleusercontent.com (Google) +✔ CA: https://localhost +✔ Certificate: personal.crt +✔ Private Key: personal.key + +$ step certificate inspect --short personal.crt ⏎ +X.509v3 TLS Certificate (ECDSA P-256) [Serial: 6169...4235] + Subject: 106202051347258973689 + sebastian@smallstep.com + Issuer: Local CA Intermediate CA + Provisioner: Google [ID: 9724....com] + Valid from: 2019-03-26T20:36:28Z + to: 2019-03-27T20:36:28Z +``` + +Now it's easy for anybody in the G-Suite organization to obtain valid personal +certificates! + ## Notes on Securing the Step CA and your PKI. In this section we recommend a few best practices when it comes to diff --git a/docs/distribution.md b/docs/distribution.md index c5858143..3bdef552 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -88,9 +88,10 @@ e.g. `v1.0.2` Travis will build and upload the following artifacts: - * **step-ca_1.0.3_amd64.deb**: debian package for installation on linux. - * **step-ca_1.0.3_linux_amd64.tar.gz**: tarball containing a statically compiled linux binary. - * **step-ca_1.0.3_darwin_amd64.tar.gz**: tarball containing a statically compiled darwin binary. + * **step-certificates_1.0.3_amd64.deb**: debian package for installation on linux. + * **step-certificates_1.0.3_linux_amd64.tar.gz**: tarball containing a statically compiled linux binary. + * **step-certificates_1.0.3_darwin_amd64.tar.gz**: tarball containing a statically compiled darwin binary. + * **step-certificates.tar.gz**: tarball containing a git archive of the full repo. *All Done!* diff --git a/docs/oidc1.png b/docs/oidc1.png new file mode 100644 index 00000000..37b54370 Binary files /dev/null and b/docs/oidc1.png differ diff --git a/docs/oidc2.png b/docs/oidc2.png new file mode 100644 index 00000000..5e12ae9e Binary files /dev/null and b/docs/oidc2.png differ diff --git a/docs/oidc3.png b/docs/oidc3.png new file mode 100644 index 00000000..da46dc20 Binary files /dev/null and b/docs/oidc3.png differ