/ DISTRIBUTED SYSTEMS

A step by step guide to mTLS in Go

Ever wondered what mTLS (mutual TLS) looks like? Come, learn to implement mTLS using Golang and OpenSSL.

Introduction

TLS (Transport Layer Security) provides the necessary encryption for applications when communicating over a network. HTTPS (Hypertext Transfer Protocol Secure) is an extension of HTTP that leverages TLS for security. The TLS technique requires a CA (Certificate Authority) to issue a X.509 digital certificate to a service, which is then handed over to the consumer of the service for it to validate it with the CA itself. mTLS extends the same idea to applications, for example, microservices wherein both the provider and the consumer require to produce their own certificates to the other party. These certificates are validated by both parties with their respective CAs. Once validated, the communication between the server/client or provider/consumer happens securely.

The Implementation

Step 1 - Build a simple HTTP Server and Client

Let’s first create a simple HTTP Server in server.go which responds with Hello, world! when requested for the /hello resource over port 8080.

package main

import (
	"io"
	"log"
	"net/http"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
	// Write "Hello, world!" to the response body
	io.WriteString(w, "Hello, world!\n")
}

func main() {
	// Set up a /hello resource handler
	http.HandleFunc("/hello", helloHandler)

	// Listen to port 8080 and wait
	log.Fatal(http.ListenAndServe(":8080", nil))
}

The Client simply requests for the /hello resource over port 8080 and prints the response body to stdout. Here is what client.go looks like:

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
)

func main() {
	// Request /hello over port 8080 via the GET method
	r, err := http.Get("http://localhost:8080/hello")
	if err != nil {
		log.Fatal(err)
	}

	// Read the response body
	defer r.Body.Close()
	body, err := ioutil.ReadAll(r.Body)
	if err != nil {
		log.Fatal(err)
	}

	// Print the response body to stdout
	fmt.Printf("%s\n", body)
}

Open an instance of the terminal and run the Server like so:

go run -v server.go

Open another instance of the terminal and run the Client:

go run -v client.go

You should see the following output from the Client.

Hello, world!

Step 2 - Generate and use the Certificates with the Server

Use the following command to generate the certificates. The command creates a 2048 bit key certificate which is valid for 10 years. Additionally, the CN=localhost asserts that the certificate is valid for the localhost domain.

openssl req -newkey rsa:2048 \
  -new -nodes -x509 \
  -days 3650 \
  -out cert.pem \
  -keyout key.pem \
  -subj "/C=US/ST=California/L=Mountain View/O=Your Organization/OU=Your Unit/CN=localhost"

You should now have cert.pem and key.pem in your directory.

Let’s now enable TLS over HTTP i.e. HTTPS on the Server. Replace the http.ListenAndServe(":8080", nil) call in server.go with http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", nil) for the Server to listen to HTTPS connections over port 8443 while supplying the certificates generated earlier.

-	// Listen to port 8080 and wait
-	log.Fatal(http.ListenAndServe(":8080", nil))
+	// Listen to HTTPS connections on port 8443 and wait
+	log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", nil))

You can verify the Server’s working by running it and browsing to https://localhost:8443/hello.

Secure Server

Let’s now update client.go to connect to the Server over HTTPS.

-	// Request /hello over port 8080 via the GET method
-	r, err := http.Get("http://localhost:8080/hello")
+	// Request /hello over HTTPS port 8443 via the GET method
+	r, err := http.Get("https://localhost:8443/hello")

Since our Client doesn’t yet know about the certificates, running it should spit out the following error on the Server.

http: TLS handshake error from [::1]:59436: remote error: tls: bad certificate

On the Client, you should observe the following.

x509: certificate is not valid for any names, but wanted to match localhost

Step 3 - Supply the Certificates to the Client

Update the client.go code to read the previously generated certificates, like so:

-	// Request /hello over HTTPS port 8443 via the GET method
-	r, err := http.Get("https://localhost:8443/hello")

+	// Create a CA certificate pool and add cert.pem to it
+	caCert, err := ioutil.ReadFile("cert.pem")
+	if err != nil {
+		log.Fatal(err)
+	}
+	caCertPool := x509.NewCertPool()
+	caCertPool.AppendCertsFromPEM(caCert)
+
+	// Create a HTTPS client and supply the created CA pool
+	client := &http.Client{
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{
+				RootCAs: caCertPool,
+			},
+		},
+	}
+
+	// Request /hello via the created HTTPS client over port 8443 via GET
+	r, err := client.Get("https://localhost:8443/hello")

Here, we read the cert.pem file and supply it as the root CA when creating the Client. Running the Client should now successfully display the following.

Hello, world!

Final Step - Enable mTLS

On the Client, read and supply the key pair as the client certificate.

+	// Read the key pair to create certificate
+	cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
+	if err != nil {
+		log.Fatal(err)
+	}

...

-	// Create a HTTPS client and supply the created CA pool
+	// Create a HTTPS client and supply the created CA pool and certificate
	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{
				RootCAs: caCertPool,
+				Certificates: []tls.Certificate{cert},
			},
		},
	}

On the Server, we create a similar CA pool and supply it to the TLS config to serve as the authority to validate Client certificates. We also use the same key pair for the Server certificate.

-	// Listen to HTTPS connections on port 8443 and wait
-	log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", nil))

+	// Create a CA certificate pool and add cert.pem to it
+	caCert, err := ioutil.ReadFile("cert.pem")
+	if err != nil {
+		log.Fatal(err)
+	}
+	caCertPool := x509.NewCertPool()
+	caCertPool.AppendCertsFromPEM(caCert)
+
+	// Create the TLS Config with the CA pool and enable Client certificate validation
+	tlsConfig := &tls.Config{
+		ClientCAs: caCertPool,
+		ClientAuth: tls.RequireAndVerifyClientCert,
+	}
+	tlsConfig.BuildNameToCertificate()
+
+	// Create a Server instance to listen on port 8443 with the TLS config
+	server := &http.Server{
+		Addr:      ":8443",
+		TLSConfig: tlsConfig,
+	}
+
+	// Listen to HTTPS connections with the server certificate and wait
+	log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))

Run server.go and then client.go and you should see a success message on the Client, like so:

Hello, world!

All Together

Finally, the server.go looks like the following.

package main

import (
	"crypto/tls"
	"crypto/x509"
	"io"
	"io/ioutil"
	"log"
	"net/http"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
	// Write "Hello, world!" to the response body
	io.WriteString(w, "Hello, world!\n")
}

func main() {
	// Set up a /hello resource handler
	http.HandleFunc("/hello", helloHandler)

	// Create a CA certificate pool and add cert.pem to it
	caCert, err := ioutil.ReadFile("cert.pem")
	if err != nil {
		log.Fatal(err)
	}
	caCertPool := x509.NewCertPool()
	caCertPool.AppendCertsFromPEM(caCert)

	// Create the TLS Config with the CA pool and enable Client certificate validation
	tlsConfig := &tls.Config{
		ClientCAs: caCertPool,
		ClientAuth: tls.RequireAndVerifyClientCert,
	}
	tlsConfig.BuildNameToCertificate()

	// Create a Server instance to listen on port 8443 with the TLS config
	server := &http.Server{
		Addr:      ":8443",
		TLSConfig: tlsConfig,
	}

	// Listen to HTTPS connections with the server certificate and wait
	log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}

The client.go file looks like so:

package main

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
)

func main() {
	// Read the key pair to create certificate
	cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
	if err != nil {
		log.Fatal(err)
	}

	// Create a CA certificate pool and add cert.pem to it
	caCert, err := ioutil.ReadFile("cert.pem")
	if err != nil {
		log.Fatal(err)
	}
	caCertPool := x509.NewCertPool()
	caCertPool.AppendCertsFromPEM(caCert)

	// Create a HTTPS client and supply the created CA pool and certificate
	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{
				RootCAs: caCertPool,
				Certificates: []tls.Certificate{cert},
			},
		},
	}

	// Request /hello via the created HTTPS client over port 8443 via GET
	r, err := client.Get("https://localhost:8443/hello")
	if err != nil {
		log.Fatal(err)
	}

	// Read the response body
	defer r.Body.Close()
	body, err := ioutil.ReadAll(r.Body)
	if err != nil {
		log.Fatal(err)
	}

	// Print the response body to stdout
	fmt.Printf("%s\n", body)
}

Conclusion

Golang makes it really easy to implement mTLS, and this one’s just ~100 LOC.


I’d love to hear what you think.