DEV Community

Jeremy Mill
Jeremy Mill

Posted on

Validating Client Certificate SANs in Go

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,
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    },
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 getClientValidatormethod. 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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 RemoteAddrport 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)

Collapse
 
living_syn profile image
Jeremy Mill

Note: Huge thanks to Filippo Valsorda (github.com/FiloSottile) for his help pointing me in the right direction on how to do this here

Collapse
 
marcmagnin profile image
Marc Magnin • Edited

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!

Collapse
 
cueo profile image
Mohit Mayank

Would this work if the client is behind a NAT?

Collapse
 
living_syn profile image
Jeremy Mill

Sorry this reply is super late, but unless the public IP is what is on the cert, no, it won't.

Collapse
 
kaushaldokania profile image
Kaushal Dokania

Great article.