Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Implement client certificate validation (mTLS) #1226

Closed
wants to merge 12 commits into from
11 changes: 11 additions & 0 deletions apis/contour/v1beta1/ingressroute.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ type TLS struct {
// and the encrypted handshake will be passed through to the
// backing cluster.
Passthrough bool `json:"passthrough,omitempty"`
// If specified client-certificate is required and validated (mTLS).
ClientValidation *ClientValidation `json:"clientValidation,omitempty"`
}

type ClientValidation struct {
// The name of a secret in the current namespace used to validate the client certificate
SecretName string `json:"secretName,omitempty"`
// SPKIs used to validate the client certificate
Spkis []string `json:"spkis,omitempty"`
// Hashes used to validate the client certificate
Hashes []string `json:"hashes,omitempty"`
}

// Route contains the set of routes for a virtual host
Expand Down
75 changes: 75 additions & 0 deletions docs/ingressroute.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,81 @@ spec:

In this example, the permission for Contour to reference the Secret `example-com-wildcard` in the `admin` namespace has been delegated to IngressRoute objects in the `example-com` namespace.

#### Mutual TLS (client validation)

Mutual TLS (mTLS) means that not only the server but also the client must present a certificate for validation.

```yaml
apiVersion: contour.heptio.com/v1beta1
kind: IngressRoute
metadata:
name: www
namespace: default
spec:
virtualhost:
fqdn: foo.com
tls:
secretName: server-secret
clientValidation:
secretName: client-secret
routes:
- match: /
services:
- name: s1
port: 80
```

With this configuration clients accessing `foo.com` must provide a certificate that can be validated by the CA stored in the `client-secret`. If the client validation fails the access will be rejected.


##### Other ways of client certificate validation

Envoy allows two other ways for client certificate validation;

* Subject Public Key Information (SPKI)
* Certificate hash

These can also be specified in `clientValidation`;

```yaml
apiVersion: contour.heptio.com/v1beta1
kind: IngressRoute
metadata:
name: www
namespace: default
spec:
virtualhost:
fqdn: foo.com
tls:
secretName: server-secret
clientValidation:
spkis:
- 2IEpPESU/mmC30tPsnOfbGKdwKdQfN/wZw1QWpjGlmk=
hashes:
- c49c6930b9fbfb72a0d9d07504133d26c87588aa2d32b2919475f647a62f6fec
routes:
- match: /
services:
- name: s1
port: 80
```

Please read the [Envoy documentation](https://www.envoyproxy.io/docs/envoy/latest/api-v2/api/v2/auth/cert.proto.html#auth-certificatevalidationcontext) for details.


##### Forward client certificate details

The backend application may need information of the certificate used (by envoy) to validate the client. Contour forwards client certificate details in the `X-Forwarded-Client-Cert` http header;

```
X-Forwarded-Client-Cert: [Hash=c49c6930b9fbfb72a0d9d07504133d26c87588aa2d32b2919475f647a62f6fec]
```

The hash uniquely identifies the used client certificate.

Contour uses the [SANITIZE_SET](https://www.envoyproxy.io/docs/envoy/latest/api-v2/config/filter/network/http_connection_manager/v2/http_connection_manager.proto#envoy-api-enum-config-filter-network-http-connection-manager-v2-httpconnectionmanager-forwardclientcertdetails) option in `envoy`.


### Routing

Each route entry in an IngressRoute must start with a prefix match.
Expand Down
10 changes: 9 additions & 1 deletion internal/contour/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,15 @@ func (v *listenerVisitor) visit(vertex dag.Vertex) {

// attach certificate data to this listener if provided.
if vh.Secret != nil {
fc.TlsContext = envoy.DownstreamTLSContext(envoy.Secretname(vh.Secret), vh.MinProtoVersion, alpnProtos...)
if vh.ClientValidation != nil {
clientValidation := &envoy.ClientValidation{
Spkis: vh.ClientValidation.Spkis,
Hashes: vh.ClientValidation.Hashes,
}
fc.TlsContext = envoy.DownstreamTLSContext(envoy.Secretname(vh.Secret), clientValidation, vh.MinProtoVersion, alpnProtos...)
} else {
fc.TlsContext = envoy.DownstreamTLSContext(envoy.Secretname(vh.Secret), nil, vh.MinProtoVersion, alpnProtos...)
}
}

v.listeners[ENVOY_HTTPS_LISTENER].FilterChains = append(v.listeners[ENVOY_HTTPS_LISTENER].FilterChains, fc)
Expand Down
2 changes: 1 addition & 1 deletion internal/contour/listener_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ func filterchain(filters ...listener.Filter) []listener.FilterChain {
}

func tlscontext(tlsMinProtoVersion auth.TlsParameters_TlsProtocol, alpnprotos ...string) *auth.DownstreamTlsContext {
return envoy.DownstreamTLSContext("default/secret/735ad571c1", tlsMinProtoVersion, alpnprotos...)
return envoy.DownstreamTLSContext("default/secret/735ad571c1", nil, tlsMinProtoVersion, alpnprotos...)
}

func secretdata(cert, key string) map[string][]byte {
Expand Down
13 changes: 13 additions & 0 deletions internal/dag/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,19 @@ func (b *builder) computeIngressRoutes() {
svhost.Secret = sec
svhost.MinProtoVersion = minProtoVersion(ir.Spec.VirtualHost.TLS.MinimumProtocolVersion)
enforceTLS = true
if tls.ClientValidation != nil {
svhost.ClientValidation = &ClientValidation{
Spkis: tls.ClientValidation.Spkis,
Hashes: tls.ClientValidation.Hashes,
}
if tls.ClientValidation.SecretName != "" {
m := splitSecret(tls.ClientValidation.SecretName, ir.Namespace)
sec := b.lookupSecret(m, validSecret)
if sec != nil {
svhost.ClientValidation.Secret = sec
}
}
}
}
// passthrough is true if tls.secretName is not present, and
// tls.passthrough is set to true.
Expand Down
12 changes: 12 additions & 0 deletions internal/dag/dag.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,18 @@ type SecureVirtualHost struct {

// The cert and key for this host.
*Secret

// The client certificate validation for this host (mTLS)
*ClientValidation
}

type ClientValidation struct {
// The CA for client validation.
*Secret
// SPKIs used to validate the client certificate
Spkis []string
// Hashes used to validate the client certificate
Hashes []string
}

func (s *SecureVirtualHost) Visit(f func(Vertex)) {
Expand Down
2 changes: 1 addition & 1 deletion internal/e2e/lds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1435,7 +1435,7 @@ func filterchaintls(domain string, secret *v1.Secret, filter listener.Filter, al
ServerNames: []string{domain},
}
secretName := envoy.Secretname(&dag.Secret{Object: secret})
fc.TlsContext = envoy.DownstreamTLSContext(secretName, auth.TlsParameters_TLSv1_1, alpn...)
fc.TlsContext = envoy.DownstreamTLSContext(secretName, nil, auth.TlsParameters_TLSv1_1, alpn...)
return []listener.FilterChain{fc}
}

Expand Down
21 changes: 19 additions & 2 deletions internal/envoy/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package envoy
import (
"github.com/envoyproxy/go-control-plane/envoy/api/v2/auth"
"github.com/envoyproxy/go-control-plane/envoy/api/v2/core"
"github.com/gogo/protobuf/types"
)

var (
Expand Down Expand Up @@ -91,9 +92,15 @@ func validationContext(ca []byte, subjectName string) *auth.CommonTlsContext_Val
}
}

type ClientValidation struct {
Secret *auth.Secret
Spkis []string
Hashes []string
}

// DownstreamTLSContext creates a new DownstreamTlsContext.
func DownstreamTLSContext(secretName string, tlsMinProtoVersion auth.TlsParameters_TlsProtocol, alpnProtos ...string) *auth.DownstreamTlsContext {
return &auth.DownstreamTlsContext{
func DownstreamTLSContext(secretName string, clientValidation *ClientValidation, tlsMinProtoVersion auth.TlsParameters_TlsProtocol, alpnProtos ...string) *auth.DownstreamTlsContext {
context := auth.DownstreamTlsContext{
CommonTlsContext: &auth.CommonTlsContext{
TlsParams: &auth.TlsParameters{
TlsMinimumProtocolVersion: tlsMinProtoVersion,
Expand All @@ -107,4 +114,14 @@ func DownstreamTLSContext(secretName string, tlsMinProtoVersion auth.TlsParamete
AlpnProtocols: alpnProtos,
},
}
if clientValidation != nil {
context.RequireClientCertificate = &types.BoolValue{Value: true}
context.CommonTlsContext.ValidationContextType = &auth.CommonTlsContext_ValidationContext{
ValidationContext: &auth.CertificateValidationContext{
VerifyCertificateSpki: clientValidation.Spkis,
VerifyCertificateHash: clientValidation.Hashes,
},
}
}
return &context
}
9 changes: 5 additions & 4 deletions internal/envoy/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,11 @@ func HTTPConnectionManager(routename, accessLogPath string) listener.Filter {
// a Host: header. See #537.
AcceptHttp_10: true,
},
AccessLog: FileAccessLog(accessLogPath),
UseRemoteAddress: &types.BoolValue{Value: true}, // TODO(jbeda) should this ever be false?
NormalizePath: &types.BoolValue{Value: true},
IdleTimeout: idleTimeout(HTTPDefaultIdleTimeout),
AccessLog: FileAccessLog(accessLogPath),
UseRemoteAddress: &types.BoolValue{Value: true}, // TODO(jbeda) should this ever be false?
NormalizePath: &types.BoolValue{Value: true},
IdleTimeout: idleTimeout(HTTPDefaultIdleTimeout),
ForwardClientCertDetails: http.SANITIZE_SET,
}),
},
}
Expand Down
11 changes: 6 additions & 5 deletions internal/envoy/listener_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func TestSocketAddress(t *testing.T) {
func TestDownstreamTLSContext(t *testing.T) {
const secretName = "default/tls-cert"

got := DownstreamTLSContext(secretName, auth.TlsParameters_TLSv1_1, "h2", "http/1.1")
got := DownstreamTLSContext(secretName, nil, auth.TlsParameters_TLSv1_1, "h2", "http/1.1")
want := &auth.DownstreamTlsContext{
CommonTlsContext: &auth.CommonTlsContext{
TlsParams: &auth.TlsParameters{
Expand Down Expand Up @@ -254,10 +254,11 @@ func TestHTTPConnectionManager(t *testing.T) {
// a Host: header. See #537.
AcceptHttp_10: true,
},
AccessLog: FileAccessLog("/dev/stdout"),
UseRemoteAddress: &types.BoolValue{Value: true},
NormalizePath: &types.BoolValue{Value: true},
IdleTimeout: duration(HTTPDefaultIdleTimeout),
AccessLog: FileAccessLog("/dev/stdout"),
UseRemoteAddress: &types.BoolValue{Value: true},
NormalizePath: &types.BoolValue{Value: true},
IdleTimeout: duration(HTTPDefaultIdleTimeout),
ForwardClientCertDetails: http.SANITIZE_SET,
}),
},
},
Expand Down