Go has one of the best TLS libraries available in any programming language, for it's my language of choice for doing networking tasks. So I was a bit surprised to learn that, by default, when you set tls.RequireAndVerifyClientCert
on a tls.Config object, it doesn't verify the SAN/CN field on that client cert. The only thing it will verify is that it is signed by the configured root CA.
Setting go up to perform this validation was not quite as intuitive as I first believed it would be and I hope that I can help you with it if you have the same need as myself.
Starting Point
Lets say you have a starting point of a generic tls config like this:
serverConf := &tls.Config{
Certificates: []tls.Certificate{cer},
MinVersion: tls.VersionTLS12,
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: rootCAs,
}
Here we've defined a server certificate, a minimum TLS version, the root CA's to use and that we need to verify client certificates. Right now, client certificates would be validated as signed by a CA in the rootCA's CertPool, but nothing else.
Custom Client Cert Validation
The tls.Config
object has a callback that looks very promising called VerifyPeerCertificate
that takes in a method with this signature:
func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error
The problem is that this doesn't have any of the client connection information passed it it for us to validate the connecting host! Luckily for us, there's another callback called GetConfigForClient
. GetConfigForClient
is another callback on the tls.Config
object which gives us the tls.ClientHelloInfo as an argument. and returns a per-client tls.Config
(or nil
for no-change) object.
The answer is to use GetConfigForClient
to call a function which returns a closure that matches the VerifyPeerCertificate
signature but makes the ClientHelloInfo
available to it.
Our server tls.Config now looks like:
serverConf := &tls.Config{
Certificates: []tls.Certificate{cer},
GetConfigForClient: func(hi *tls.ClientHelloInfo) (*tls.Config, error) {
serverConf := &tls.Config{
Certificates: []tls.Certificate{cer},
MinVersion: tls.VersionTLS12,
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: rootCAs,
VerifyPeerCertificate: getClientValidator(hi),
}
return serverConf, nil
},
}
and our stubbed out getClientValidator
function looks like:
func getClientValidator(helloInfo *tls.ClientHelloInfo) func([][]byte, [][]*x509.Certificate) error {
return func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
return nil
}
}
At this point there has been essentially no change to the functionality from where we started. Clients still connect, and their certificates are validated, but their SAN's are not.
Validating SANs
To validate the SAN's on the client certificate we need to modify the getClientValidator
method. In order to avoid writing our own validation methods, we can utilize the same validator that's used by default when we specify tls.RequireAndVerifyClientCert
on our config object. All we need to do is add some additional options to its configuration object.
func getClientValidator(helloInfo *tls.ClientHelloInfo) func([][]byte, [][]*x509.Certificate) error {
return func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
//copied from the default options in src/crypto/tls/handshake_server.go, 680 (go 1.11)
//but added DNSName
opts := x509.VerifyOptions{
Roots: rootCAs,
CurrentTime: time.Now(),
Intermediates: x509.NewCertPool(),
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
DNSName: strings.Split(helloInfo.Conn.RemoteAddr().String(), ":")[0],
}
_, err := verifiedChains[0][0].Verify(opts)
return err
}
}
This now validates the client's certificate against the root CA's we specified at our starting point, the client's certificate key usage, and the DNSName of the client connecting. The strings.Split
saves us from including the RemoteAddr
port number in the DNSName field, and won't screw anything up if it's an actual DNS name.
There it is, I hope you find this useful! You can find all of the code in a single, small server.go example here
Top comments (5)
Note: Huge thanks to Filippo Valsorda (github.com/FiloSottile) for his help pointing me in the right direction on how to do this here
I was wondering if we couldn't use tls.VerifyHostname for that check also: golang.org/src/crypto/tls/conn.go?...
Edit: By looking into go code it looks that tls.Verify is broader than tls.VerifyHostname (it actually can call VerifyHostname).
Thanks a lot for such great post!
Would this work if the client is behind a NAT?
Sorry this reply is super late, but unless the public IP is what is on the cert, no, it won't.
Great article.