diff --git a/cmd/app/options/oidc.go b/cmd/app/options/oidc.go index 96255015c..40d98d201 100644 --- a/cmd/app/options/oidc.go +++ b/cmd/app/options/oidc.go @@ -4,6 +4,8 @@ package options import ( "fmt" + "k8s.io/apiserver/pkg/server/options" + "github.com/spf13/pflag" cliflag "k8s.io/component-base/cli/flag" @@ -19,6 +21,7 @@ type OIDCAuthenticationOptions struct { GroupsPrefix string SigningAlgs []string RequiredClaims map[string]string + ClientCertKey options.CertKey } func NewOIDCAuthenticationOptions(nfs *cliflag.NamedFlagSets) *OIDCAuthenticationOptions { @@ -26,10 +29,19 @@ func NewOIDCAuthenticationOptions(nfs *cliflag.NamedFlagSets) *OIDCAuthenticatio } func (o *OIDCAuthenticationOptions) Validate() error { - if o != nil && (len(o.IssuerURL) > 0) != (len(o.ClientID) > 0) { + if o == nil { + return nil + } + + if (len(o.IssuerURL) > 0) != (len(o.ClientID) > 0) { return fmt.Errorf("oidc-issuer-url and oidc-client-id should be specified together") } + if ((o.ClientCertKey.CertFile != "") && (o.ClientCertKey.KeyFile == "")) || + (o.ClientCertKey.CertFile == "" && o.ClientCertKey.KeyFile != "") { + return fmt.Errorf("oidc-tls-client-cert-file and oidc-tls-client-cert-key must be specified together") + } + return nil } @@ -61,6 +73,14 @@ func (o *OIDCAuthenticationOptions) AddFlags(fs *pflag.FlagSet) *OIDCAuthenticat "If provided, all groups will be prefixed with this value to prevent conflicts with "+ "other authentication strategies.") + fs.StringVar(&o.ClientCertKey.CertFile, "oidc-tls-client-cert-file", "", ""+ + "The absolute path to a X.509 client certificate. If provided, HTTPS requests made to the OIDC issuer will "+ + "use mTLS. Also requires --oidc-tls-client-key-file.") + + fs.StringVar(&o.ClientCertKey.KeyFile, "oidc-tls-client-key-file", "", ""+ + "The absolute path to a X.509 private key. If provided, HTTPS requests made to the OIDC issuer will use mTLS."+ + "Also requires --oidc-tls-client-cert-file.") + fs.StringSliceVar(&o.SigningAlgs, "oidc-signing-algs", []string{"RS256"}, ""+ "Comma-separated list of allowed JOSE asymmetric signing algorithms. JWTs with a "+ "'alg' header value not in this list will be rejected. "+ diff --git a/go.mod b/go.mod index 57e6b6d94..5b58ea41d 100644 --- a/go.mod +++ b/go.mod @@ -76,6 +76,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/jstemmer/go-junit-report v1.0.0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 7e5d179d1..0a41b309f 100644 --- a/go.sum +++ b/go.sum @@ -134,6 +134,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v1.0.0 h1:8X1gzZpR+nVQLAht+L/foqOeX2l9DTZoaIPbEQHxsds= +github.com/jstemmer/go-junit-report v1.0.0/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index ff395488b..252c119c1 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -3,6 +3,8 @@ package proxy import ( ctx "context" + "crypto/tls" + "crypto/x509" "errors" "fmt" "io/ioutil" @@ -11,6 +13,9 @@ import ( "net/url" "time" + "k8s.io/apimachinery/pkg/util/net" + "k8s.io/apiserver/pkg/server/dynamiccertificates" + "k8s.io/apiserver/pkg/apis/apiserver" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/request/bearertoken" @@ -60,6 +65,7 @@ type Proxy struct { subjectAccessReviewer *subjectaccessreview.SubjectAccessReview secureServingInfo *server.SecureServingInfo auditor *audit.Audit + dynamicClientCert *DynamicCertificate restConfig *rest.Config clientTransport http.RoundTripper @@ -82,6 +88,52 @@ func (caFromFile CAFromFile) CurrentCABundleContent() []byte { return res } +// DynamicCertificate wraps DynamicCertKeyPairContent so that we can attach a function to it that can be used by +// the TLS client config to load the client certificate dynamically +type DynamicCertificate struct { + *dynamiccertificates.DynamicCertKeyPairContent +} + +// GetClientCertificate returns a client certificate based on the most recent certificate and key data loaded from the +// file system. +func (c *DynamicCertificate) GetClientCertificate(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) { + certBytes, keyBytes := c.CurrentCertKeyContent() + cert, err := tls.X509KeyPair(certBytes, keyBytes) + if err != nil { + return nil, fmt.Errorf("failed to load OIDC client certificate: %w", err) + } + return &cert, nil +} + +// NewDynamicCertificate create a new instance of DynamicCertificate using the supplied certificate and key files +func NewDynamicCertificate(purpose, certFile, keyFile string) (*DynamicCertificate, error) { + content, err := dynamiccertificates.NewDynamicServingContentFromFiles(purpose, certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("failed to create new dynamic serving content for '%s': %w", purpose, err) + } + return &DynamicCertificate{content}, nil +} + +// setupClient creates an HTTP client using the dynamic content provided for the client certificate and the CA bundle. +// This function is based on how the setup would have been set up by the underlying OIDC code had we not passed in our +// own client. +func setupClient(dynamicClientCert *DynamicCertificate, caFromFile oidc.CAContentProvider) (*http.Client, error) { + var roots *x509.CertPool + if caFromFile != nil { + roots = x509.NewCertPool() + if !roots.AppendCertsFromPEM(caFromFile.CurrentCABundleContent()) { + return nil, fmt.Errorf("failed to append OIDC ca bundle to pool") + } + } + + // Copied from http.DefaultTransport. + tr := net.SetTransportDefaults(&http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: roots, GetClientCertificate: dynamicClientCert.GetClientCertificate}, + }) + + return &http.Client{Transport: tr, Timeout: 30 * time.Second}, nil +} + func New(restConfig *rest.Config, oidcOptions *options.OIDCAuthenticationOptions, auditOptions *options.AuditOptions, @@ -89,6 +141,7 @@ func New(restConfig *rest.Config, subjectAccessReviewer *subjectaccessreview.SubjectAccessReview, ssinfo *server.SecureServingInfo, config *Config) (*Proxy, error) { + var err error // load the CA from the file listed in the options caFromFile := CAFromFile{ @@ -115,13 +168,32 @@ func New(restConfig *rest.Config, }, } - // generate tokenAuther from oidc config - tokenAuther, err := oidc.New(ctx.TODO(), oidc.Options{ + tokenAutherOptions := oidc.Options{ CAContentProvider: caFromFile, //RequiredClaims: oidcOptions.RequiredClaims, SupportedSigningAlgs: oidcOptions.SigningAlgs, JWTAuthenticator: jwtConfig, - }) + } + + var dyCert *DynamicCertificate + if oidcOptions.ClientCertKey.CertFile != "" { + // Use the client certificate and key to enable mTLS + dyCert, err = NewDynamicCertificate("oidc-client", oidcOptions.ClientCertKey.CertFile, oidcOptions.ClientCertKey.KeyFile) + if err != nil { + return nil, fmt.Errorf("failed to initialize OIDC client certificate loader: %v", err) + } + + client, err := setupClient(dyCert, caFromFile) + if err != nil { + return nil, err + } + + tokenAutherOptions.Client = client + tokenAutherOptions.CAContentProvider = nil + } + + // generate tokenAuther from oidc config + tokenAuther, err := oidc.New(ctx.TODO(), tokenAutherOptions) if err != nil { return nil, err } @@ -141,6 +213,7 @@ func New(restConfig *rest.Config, oidcRequestAuther: bearertoken.New(tokenAuther), tokenAuther: tokenAuther, auditor: auditor, + dynamicClientCert: dyCert, }, nil } @@ -152,6 +225,19 @@ func (p *Proxy) Run(stopCh <-chan struct{}) (<-chan struct{}, <-chan struct{}, e } p.clientTransport = clientRT + if p.dynamicClientCert != nil { + // Start monitoring the OIDC client TLS certificate + c, cancel := ctx.WithCancel(ctx.Background()) + go func() { + select { + case <-stopCh: + cancel() + case <-c.Done(): + } + }() + go p.dynamicClientCert.Run(c, 1) + } + // No auth round tripper for no impersonation if p.config.DisableImpersonation || p.config.TokenReview { noAuthClientRT, err := p.roundTripperForRestConfig(&rest.Config{ diff --git a/test/e2e/framework/framework.go b/test/e2e/framework/framework.go index aa5750819..71d3da712 100644 --- a/test/e2e/framework/framework.go +++ b/test/e2e/framework/framework.go @@ -75,7 +75,7 @@ func (f *Framework) BeforeEach() { f.helper.KubeClient = f.KubeClientSet By("Deploying mock OIDC Issuer") - issuerKeyBundle, issuerURL, err := f.helper.DeployIssuer(f.Namespace.Name) + issuerKeyBundle, issuerURL, err := f.helper.DeployIssuer(f.Namespace.Name, nil) Expect(err).NotTo(HaveOccurred()) By("Deploying kube-oidc-proxy") @@ -93,11 +93,19 @@ func (f *Framework) BeforeEach() { // AfterEach deletes the namespace, after reading its events. func (f *Framework) AfterEach() { // Output logs from proxy of test case. + By("Gathering kube-oidc-proxy logs") err := f.Helper().Kubectl(f.Namespace.Name).Run("logs", "-lapp=kube-oidc-proxy-e2e") if err != nil { By("Failed to gather logs from kube-oidc-proxy: " + err.Error()) } + // Output logs from the issuer of test case. + By("Gathering oidc-issuer logs") + err = f.Helper().Kubectl(f.Namespace.Name).Run("logs", "-lapp=oidc-issuer-e2e") + if err != nil { + By("Failed to gather logs from oidc-issuer: " + err.Error()) + } + By("Deleting kube-oidc-proxy deployment") err = f.Helper().DeleteProxy(f.Namespace.Name) Expect(err).NotTo(HaveOccurred()) @@ -125,6 +133,19 @@ func (f *Framework) DeployProxyWith(extraVolumes []corev1.Volume, extraArgs ...s Expect(err).NotTo(HaveOccurred()) } +func (f *Framework) DeployIssuerWith(extraVolumes []corev1.Volume, extraArgs ...string) { + By("Deleting oidc-issuer deployment") + err := f.Helper().DeleteIssuer(f.Namespace.Name) + Expect(err).NotTo(HaveOccurred()) + + err = f.Helper().WaitForDeploymentToDelete(f.Namespace.Name, kind.IssuerImageName, time.Second*30) + Expect(err).NotTo(HaveOccurred()) + + By(fmt.Sprintf("Deploying oidc-issuer with extra args %s", extraArgs)) + f.issuerKeyBundle, f.issuerURL, err = f.helper.DeployIssuer(f.Namespace.Name, extraVolumes, extraArgs...) + Expect(err).NotTo(HaveOccurred()) +} + func (f *Framework) Helper() *helper.Helper { return f.helper } diff --git a/test/e2e/framework/helper/deploy.go b/test/e2e/framework/helper/deploy.go index 5bae9cc1f..b141f388c 100644 --- a/test/e2e/framework/helper/deploy.go +++ b/test/e2e/framework/helper/deploy.go @@ -244,18 +244,18 @@ func (h *Helper) DeployProxy(ns *corev1.Namespace, issuerURL *url.URL, clientID return bundle, appURL, nil } -func (h *Helper) DeployIssuer(ns string) (*util.KeyBundle, *url.URL, error) { +func (h *Helper) DeployIssuer(ns string, extraVolumes []corev1.Volume, extraArgs ...string) (*util.KeyBundle, *url.URL, error) { cnt := corev1.Container{ Name: kind.IssuerImageName, Image: kind.IssuerImageName, ImagePullPolicy: corev1.PullNever, - Args: []string{ + Args: append([]string{ "oidc-issuer", "--secure-port=6443", fmt.Sprintf("--issuer-url=https://oidc-issuer-e2e.%s.svc.cluster.local:6443", ns), "--tls-cert-file=/tls/cert.pem", "--tls-private-key-file=/tls/key.pem", - }, + }, extraArgs...), VolumeMounts: []corev1.VolumeMount{ corev1.VolumeMount{ MountPath: "/tls", @@ -270,7 +270,15 @@ func (h *Helper) DeployIssuer(ns string) (*util.KeyBundle, *url.URL, error) { }, } - bundle, appURL, err := h.deployApp(ns, kind.IssuerImageName, corev1.ServiceTypeClusterIP, cnt) + for _, v := range extraVolumes { + cnt.VolumeMounts = append(cnt.VolumeMounts, corev1.VolumeMount{ + MountPath: fmt.Sprintf("/%s", v.Name), + Name: v.Name, + ReadOnly: true, + }) + } + + bundle, appURL, err := h.deployApp(ns, kind.IssuerImageName, corev1.ServiceTypeClusterIP, cnt, extraVolumes...) if err != nil { return nil, nil, err } diff --git a/test/e2e/suite/cases/doc.go b/test/e2e/suite/cases/doc.go index 0ab0dad91..43628653b 100644 --- a/test/e2e/suite/cases/doc.go +++ b/test/e2e/suite/cases/doc.go @@ -5,6 +5,7 @@ import ( _ "github.com/jetstack/kube-oidc-proxy/test/e2e/suite/cases/audit" _ "github.com/jetstack/kube-oidc-proxy/test/e2e/suite/cases/headers" _ "github.com/jetstack/kube-oidc-proxy/test/e2e/suite/cases/impersonation" + _ "github.com/jetstack/kube-oidc-proxy/test/e2e/suite/cases/mtls" _ "github.com/jetstack/kube-oidc-proxy/test/e2e/suite/cases/passthrough" _ "github.com/jetstack/kube-oidc-proxy/test/e2e/suite/cases/probe" _ "github.com/jetstack/kube-oidc-proxy/test/e2e/suite/cases/rbac" diff --git a/test/e2e/suite/cases/mtls/mtls.go b/test/e2e/suite/cases/mtls/mtls.go new file mode 100644 index 000000000..f89548817 --- /dev/null +++ b/test/e2e/suite/cases/mtls/mtls.go @@ -0,0 +1,119 @@ +// Copyright Jetstack Ltd. See LICENSE for details. +package probe + +import ( + "context" + "net" + "time" + + "github.com/jetstack/kube-oidc-proxy/test/util" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/jetstack/kube-oidc-proxy/test/e2e/framework" + "github.com/jetstack/kube-oidc-proxy/test/kind" +) + +var _ = framework.CasesDescribe("mTLS", func() { + f := framework.NewDefaultFramework("mtls") + + It("Should become ready if the issuer accepts our client certificate", func() { + // Create a new cert/key bundle that can be used as a TLS client + clientBundle, err := util.NewTLSSelfSignedCertKey("proxy-oidc-client", []net.IP{net.ParseIP("127.0.0.1")}, nil) + Expect(err).NotTo(HaveOccurred()) + + // Set up a secret to hold the new certificate/key pair + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "oidc-client", + Namespace: f.Namespace.Name, + }, + Data: map[string][]byte{ + "tls.crt": clientBundle.CertBytes, + "tls.key": clientBundle.KeyBytes, + }, + } + + By("Creating a secret to hold the OIDC client certificate/key pair") + _, err = f.KubeClientSet.CoreV1().Secrets(f.Namespace.Name).Create(context.TODO(), secret, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + // Since this is a self-signed certificate pass it to the issuers as our "CA". + volume := corev1.Volume{ + Name: "oidc-client", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "oidc-client", + }, + }, + } + + // Re-deploy the Issuer with the --tls-client-ca-file argument which will force certs to be required and to be + // validated to the certificate provided in that file. Since we've self-signed our own then we just pass it + // here as well. + f.DeployIssuerWith([]corev1.Volume{volume}, "--tls-client-ca-file=/oidc-client/tls.crt") + + // Re-deploy the Proxy with the same volume so that it can use it to find its OIDC client TLS cert/key. + f.DeployProxyWith([]corev1.Volume{volume}, + "--oidc-tls-client-cert-file=/oidc-client/tls.crt", + "--oidc-tls-client-key-file=/oidc-client/tls.key") + + err = f.Helper().WaitForDeploymentReady(f.Namespace.Name, kind.ProxyImageName, time.Second*5) + Expect(err).NotTo(HaveOccurred()) + }) + + It("Should not become ready if the issuer rejects our client certificate", func() { + // Create a new cert/key bundle that can be used as a TLS client + clientBundle, err := util.NewTLSSelfSignedCertKey("proxy-oidc-client", []net.IP{net.ParseIP("127.0.0.1")}, nil) + Expect(err).NotTo(HaveOccurred()) + + // Set up a secret to hold the new certificate/key pair + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "oidc-client", + Namespace: f.Namespace.Name, + }, + Data: map[string][]byte{ + "tls.crt": clientBundle.CertBytes, + "tls.key": clientBundle.KeyBytes, + }, + } + + By("Creating a secret to hold the OIDC client certificate/key pair") + _, err = f.KubeClientSet.CoreV1().Secrets(f.Namespace.Name).Create(context.TODO(), secret, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + // Since this is a self-signed certificate pass it to the issuers as our "CA". + volume := corev1.Volume{ + Name: "oidc-client", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "oidc-client", + }, + }, + } + + // Re-deploy the Issuer with the --tls-client-ca-file argument which will force certs to be required and to be + // validated to the certificate provided in that file. Since we've self-signed our own then we just pass it + // here as the CA certificate. + f.DeployIssuerWith([]corev1.Volume{volume}, "--tls-client-ca-file=/oidc-client/tls.crt") + + // Re-deploy the Proxy without specifying the TLS client arguments. This should prevent it from becoming ready + // since we won't be able to initialize the issuer. + By("Deleting and re-deploying kube-oidc-proxy deployment") + err = f.Helper().DeleteProxy(f.Namespace.Name) + Expect(err).NotTo(HaveOccurred()) + + err = f.Helper().WaitForDeploymentToDelete(f.Namespace.Name, kind.ProxyImageName, time.Second*30) + Expect(err).NotTo(HaveOccurred()) + + By("Re-deploying kube-oidc-proxy") + _, _, err = f.Helper().DeployProxy(f.Namespace, f.IssuerURL(), + f.ClientID(), f.IssuerKeyBundle(), nil) + // Error should occur (not ready) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/test/environment/dev/dev.go b/test/environment/dev/dev.go index fdfdedb69..aeb19c434 100644 --- a/test/environment/dev/dev.go +++ b/test/environment/dev/dev.go @@ -98,7 +98,7 @@ func deploy() { fmt.Printf("> created new namespace %s\n", ns.Name) - issuerKeyBundle, issuerURL, err := helper.DeployIssuer(ns.Name) + issuerKeyBundle, issuerURL, err := helper.DeployIssuer(ns.Name, nil) errExit(err) fmt.Printf("> deployed issuer at url %s\n", issuerURL) diff --git a/test/tools/issuer/Dockerfile b/test/tools/issuer/Dockerfile index e2d2098ad..b900c2cb9 100644 --- a/test/tools/issuer/Dockerfile +++ b/test/tools/issuer/Dockerfile @@ -1,7 +1,7 @@ # Copyright Jetstack Ltd. See LICENSE for details. FROM alpine:3.10 -LABEL description="A basic OIDC issuer that prsents a well-known and certs endpoint." +LABEL description="A basic OIDC issuer that presents a well-known and certs endpoint." RUN apk --no-cache add ca-certificates diff --git a/test/tools/issuer/cmd/main.go b/test/tools/issuer/cmd/main.go index 8039db409..e656be514 100644 --- a/test/tools/issuer/cmd/main.go +++ b/test/tools/issuer/cmd/main.go @@ -21,7 +21,7 @@ func main() { Short: "A very basic OIDC issuer to present a well-known endpoint.", RunE: func(cmd *cobra.Command, args []string) error { - iss, err := issuer.New(opts.IssuerURL, opts.KeyFile, opts.CertFile, stopCh) + iss, err := issuer.New(opts.IssuerURL, opts.KeyFile, opts.CertFile, opts.ClientCACertFile, stopCh) if err != nil { return err } diff --git a/test/tools/issuer/cmd/options/options.go b/test/tools/issuer/cmd/options/options.go index 53ba6c865..8c98e4d75 100644 --- a/test/tools/issuer/cmd/options/options.go +++ b/test/tools/issuer/cmd/options/options.go @@ -11,8 +11,9 @@ type Options struct { IssuerURL string - KeyFile string - CertFile string + KeyFile string + CertFile string + ClientCACertFile string } func (o *Options) AddFlags(cmd *cobra.Command) { @@ -34,6 +35,9 @@ func (o *Options) AddFlags(cmd *cobra.Command) { cmd.PersistentFlags().StringVar(&o.CertFile, "tls-cert-file", "/etc/oidc/key.pem", "File location to certificate for serving.") o.must(cmd.MarkPersistentFlagRequired("tls-cert-file")) + + cmd.PersistentFlags().StringVar(&o.ClientCACertFile, "tls-client-ca-file", + "", "File location to the CA bundle to verify client certificates.") } func (o *Options) must(err error) { diff --git a/test/tools/issuer/pkg/issuer/issuer.go b/test/tools/issuer/pkg/issuer/issuer.go index 1ad27617b..be8cb5d47 100644 --- a/test/tools/issuer/pkg/issuer/issuer.go +++ b/test/tools/issuer/pkg/issuer/issuer.go @@ -3,27 +3,30 @@ package issuer import ( "crypto/rsa" + "crypto/tls" "crypto/x509" "encoding/base64" "encoding/pem" "fmt" + log "github.com/sirupsen/logrus" "io/ioutil" "net" "net/http" - - log "github.com/sirupsen/logrus" + "os" + "time" ) type Issuer struct { issuerURL string keyFile, certFile string + clientCAFile string sk *rsa.PrivateKey stopCh <-chan struct{} } -func New(issuerURL, keyFile, certFile string, stopCh <-chan struct{}) (*Issuer, error) { +func New(issuerURL, keyFile, certFile, clientCAFile string, stopCh <-chan struct{}) (*Issuer, error) { b, err := ioutil.ReadFile(keyFile) if err != nil { return nil, err @@ -41,11 +44,12 @@ func New(issuerURL, keyFile, certFile string, stopCh <-chan struct{}) (*Issuer, } return &Issuer{ - keyFile: keyFile, - certFile: certFile, - issuerURL: issuerURL, - sk: sk, - stopCh: stopCh, + keyFile: keyFile, + certFile: certFile, + clientCAFile: clientCAFile, + issuerURL: issuerURL, + sk: sk, + stopCh: stopCh, }, nil } @@ -68,7 +72,17 @@ func (i *Issuer) Run(bindAddress, listenPort string) (<-chan struct{}, error) { go func() { defer close(compCh) - err := http.ServeTLS(l, i, i.certFile, i.keyFile) + config, err := i.setupTLSConfig() + if err != nil { + log.Errorf("failed to setup TLS config: %v", err) + return + } + + server := http.Server{Handler: i, + TLSConfig: config, + } + + err = server.ServeTLS(l, i.certFile, i.keyFile) if err != nil { log.Errorf("stopped serving TLS (%s): %s", serveAddr, err) } @@ -109,6 +123,62 @@ func (i *Issuer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { } } +// setupTLSConfig sets up a tls.Config object suitable for use with the issuer's +// HTTPS server. If mTLS is not enabled then this returns a simple config +// object. If mTLS is enabled then the TLS config object is set up to verify +// incoming client certificates. +func (i *Issuer) setupTLSConfig() (*tls.Config, error) { + config := &tls.Config{ClientAuth: tls.NoClientCert} + if i.clientCAFile != "" { + log.Infof("mock issuer requiring client certificates") + + pool := x509.NewCertPool() + caBundle, err := os.ReadFile(i.clientCAFile) + if err != nil { + log.Errorf("failed to read CA bundle: %v", err) + return nil, err + } + + if !pool.AppendCertsFromPEM(caBundle) { + log.Errorf("failed to parse CA bundle") + return nil, err + } + + config.ClientCAs = pool + + // Unfortunately, the utility used to generate the self-signed + // certificates used in the test is hardcoded to specify an extended key + // usage of "server auth" therefore we need to override the certificate + // verification to skip checking the certificate's extended key usage + // otherwise the test would fail as the server expects a certificate + // with a usage of "client auth". + // Alternatively, the certificate utility could have been cloned simply + // to override the extended key usage, but for the purpose this test + // customizing the verification handling is far simpler. + config.ClientAuth = tls.RequestClientCert + config.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + opts := x509.VerifyOptions{ + Roots: pool, + CurrentTime: time.Now(), + // As per comment above, ignore key usage since the utility is + // not able to set it to the right value for these tests. + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + if len(rawCerts) == 0 { + return fmt.Errorf("no client certificates provided") + } + + cert, err := x509.ParseCertificate(rawCerts[0]) + log.Infof("verifying certificate for client '%s'", cert.Subject) + _, err = cert.Verify(opts) + return err + } + } + + return config, nil +} + func (i *Issuer) wellKnownResponse() []byte { return []byte(fmt.Sprintf(`{ "issuer": "%s",