How to Implement TLS ft. Golang

Harsha Senarath
10 min readMar 13, 2023

--

When you visit a website you might have noticed that some of them have a padlock icon next to the URL in a web browser’s address bar and the URL begins with https:// instead of http://. This is an indicator that the website that you are trying to access using that URL is using a secure connection with Transport Layer Security (TLS) encryption.

TLS is an encryption and authentication protocol designed to establish a secure communication channel between applications, typically a client and a server. It ensures that the data exchanged between applications are encrypted and cannot be intercepted by an attacker.

In this blog post, I will be demonstrating how to implement a simple TLS connection between a client and a server using Golang.

Prerequisites

  1. GoLang: Go is a programming language developed by Google that is designed for building efficient and scalable applications. You will need to have Go installed on your system in order to implement TLS with GoLang.
  2. OpenSSL: OpenSSL is an open-source implementation of the Secure Sockets Layer (SSL) and TLS cryptographic protocols. It is used to generate a self-signed CA and a certificate for our server.
  3. Text editor: You will need a text editor to write code with GoLang. There are many text editors to choose from, such as Visual Studio Code, Sublime Text, Atom, or Vim.
  4. Knowledge of GoLang: You should have a basic understanding of GoLang syntax and concepts. This includes knowledge of variables, functions, packages, and data types.
  5. Knowledge of HTTP: You should have a basic understanding of the HTTP protocol, which is used to communicate between the client and the server.
  6. Knowledge of TLS: You should have a basic understanding of the TLS protocol. This includes knowledge of certificates, private keys, and the handshake process.

Implementation

In a production environment, obtaining a certificate from a trusted Certificate Authority (CA) is a necessary step to enable HTTPS. However, for local development purposes, generating a self-signed certificate is a quick and easy way to set up a TLS connection.

When implementing a TLS connection using a self-signed certificate for the server using Golang, there is a good chance of running into certain errors. The reason for this is when using packages like crypto/tls, it validates the server’s certificate chain and hostname by default. This is to ensure that the server is trusted and not a potential security threat. A work around for this is to set InsecureSkipVerify to true in our TLS configuration which will disable the above verification.

Even though the above solution is acceptable for a dev or test environment, I will be signing the server certificate using a self-signed CA in this implementation which allows for the validation of the server’s certificate chain and hostname, without the need to skip the verification process.

Step 1: Generate a self-signed CA

To generate a self-signed CA, we can use the OpenSSL command-line tool. The process of generating a self-signed CA or a self signed certificate involves three steps.

  1. Generate a private key for the CA or cert.
  2. Create a Certificate Signing Request (CSR) using the private key
  3. Generate the self-signed CA or cert using the CSR and the private key.

These three steps can be achieved using three separate openssl commands which I will demonstrate in Step 2 when generating our server certificate. Alternatively, we can combine the above three steps into a single command that generates a self-signed certificate which we can use to generate our self signed CA.

openssl req -new -newkey rsa:2048 -keyout ca.key -x509 -sha256 -days 365 -out ca.crt

This command generates a self-signed X.509 certificate along with a 2048-bit RSA private key and CSR using the private key, all in one step. The resulting private key will be encrypted with a passphrase and will be saved to a file named ca.key, while the self-signed certificate is saved to a file named ca.crt which will be valid for 365 days. The certificate is signed using the SHA-256 hash algorithm, which provides strong security for the certificate.

When you run the above command it will prompt you to enter a passphrase for the private key. You will also be prompted to enter certificate data where you can use the default values or values of your own. Alternatively, you can add -nodes flag to the above command if you do not want the private key to be encrypted with a passphrase.

Step 2: Create a configuration file for the server certificate

Before generating our server certificate, we will require a configuration file in order to specify the Common Name (CN) and Subject Alternative Name (SAN) for our server certificate as localhost. This is to ensure that the certificate will be accepted by our client when accessing our local development server running on https://localhost.

The CN field in an SSL certificate is used to specify the primary domain name associated with the certificate. However, this field is considered legacy and has been replaced by the SAN field, which allows for multiple domain names to be specified.

Therefore modern browsers and other clients now require the use of the SAN field to properly validate SSL certificates. If our server certificate uses the CN field instead of the SAN field, it can lead to errors in our go code since we are not setting InsecureSkipVerify to true.

In our case the configuration file will look something simple as shown below and we will be saving this into a file named server.cnf . The keyUsage and extendedKeyUsage settings under [v3_ext] section are not mandatory for this implementation.

[req]
default_md = sha256
prompt = no
req_extensions = v3_ext
distinguished_name = req_distinguished_name

[req_distinguished_name]
CN = localhost

[v3_ext]
keyUsage = critical,digitalSignature,keyEncipherment
extendedKeyUsage = critical,serverAuth,clientAuth
subjectAltName = DNS:localhost

Step 3: Generate server certificate using the self-signed CA

  • First, we need to generate a private key for the server. We can use the following command to generate a 2048-bit RSA key and save it to a file named server.key
openssl genrsa -out server.key 2048
  • Next, we need to create a CSR using the private key. We can use the following command to create a CSR and save it to a file named server.csr. We are using -config flag to specify the path to our configuration file which we created earlier.
openssl req -new -key server.key -out server.csr -config server.cnf
  • We can use the below command to view the content of our CSR and verify that we have x509v3 section with the SAN we entered in server.cnf file.
openssl req -noout -text -in server.csr
  • Now that we have a CSR, we can use it to generate our certificate for the server signed with our self-signed CA. For that we can use the below command which will save our server certificate into a file named server.crt . We are using -extfile flag to specify the path to our configuration file followed by -extensions flag to specify the section name from the configuration file containing the SAN extension settings.
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out server.crt -days 365 -sha256 -extfile server.cnf -extensions v3_ext

When you run the above command if you encrypted your CA private key with a passphrase in Step 1, you will be prompted to enter that passphrase. Once the command execution is complete you will also see a ca.srl file, which is used to keep track of the next available serial number for the CA.

Now that we have all the certificates we need, we can proceed to implement our client and server using Golang.

Step 4: Implement the server using Golang

First of all we need to import the required packages.

  • crypto/tls package provides support for secure communication over the network using TLS or SSL.
  • log package provides a simple logging interface.
  • net/http package provides HTTP client and server implementations.
import (
"crypto/tls"
"log"
"net/http"
)

Next we are going to define some constants for the server. These constants define the server port number and the response message that the server sends to clients.

const (
port = ":8443"
responseBody = "Hello, TLS!"
)

Next we will be loading our server certificate and server private key from the server.crt and server.key files. If there is an error while loading the X509 key pair, the program exits with a fatal log message.

cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("Failed to load X509 key pair: %v", err)
}

It is important to note here that if your server private key is encrypted with a passphrase you will need to decrypt it first before trying to load it using this code or else you may run into some errors.

Next we create our TLS configuration by creating a new tls.Config struct and setting the Certificates field to the X509 key pair loaded previously.

config := &tls.Config{
Certificates: []tls.Certificate{cert},
}

Next we are creating a new router using http.NewServeMux() and register a single handler function handleRequest() to handle all incoming requests.

router := http.NewServeMux()
router.HandleFunc("/", handleRequest)

Next we create a new http.Server struct and set the Addr field to the server port, the Handler field to the router created previously, and the TLSConfig field to the TLS configuration created earlier.

server := &http.Server{
Addr: port,
Handler: router,
TLSConfig: config,
}

Next we can start the HTTP server using server.ListenAndServeTLS(). The log.Printf() function is used to log a message indicating that the server is listening on the specified port. If there is an error starting the server, the program exits with a fatal log message.

log.Printf("Listening on %s...", port)
err = server.ListenAndServeTLS("", "")
if err != nil {
log.Fatalf("Failed to start server: %v", err)
}

Finally, we define the handleRequest() function we registered earlier, which handles incoming requests by writing an HTTP status code of 200 and the response message defined earlier to the response writer.

func handleRequest(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(responseBody))
}

Your final code should look like below.

package main

import (
"crypto/tls"
"log"
"net/http"
)

const (
port = ":8443"
responseBody = "Hello, TLS!"
)

func main() {
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("Failed to load X509 key pair: %v", err)
}

config := &tls.Config{
Certificates: []tls.Certificate{cert},
}

router := http.NewServeMux()
router.HandleFunc("/", handleRequest)

server := &http.Server{
Addr: port,
Handler: router,
TLSConfig: config,
}

log.Printf("Listening on %s...", port)
err = server.ListenAndServeTLS("", "")
if err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

func handleRequest(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(responseBody))
}

Step 5: Implement a client using Golang

Same as before lets import the necessary packages first. In addition to the packages used for the server there are two additional packages we need.

  • crypto/x509 package parses X.509-encoded keys and certificates.
  • io/ioutil package implements some I/O utility functions.
import (
"crypto/tls"
"crypto/x509"
"io/ioutil"
"log"
"net/http"
)

Same as before we will define a constant url that specifies the URL to make the HTTPS request to.

const (
url = "https://localhost:8443"
)

Next we can load the contents of our self-signed CA certificate using the ioutil.ReadFile() function. If the file cannot be read, the program exits with a fatal error message.

cert, err := ioutil.ReadFile("ca.crt")
if err != nil {
log.Fatalf("Failed to read certificate file: %v", err)
}

Next we create an empty certificate pool using x509.NewCertPool() and append the self-signed CA certificate to the pool using caCertPool.AppendCertsFromPEM(cert).

caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(cert)

Next we create a TLS configuration object using &tls.Config{RootCAs: caCertPool}. This configuration uses the self-signed CA certificate as the root CA to verify the server's TLS certificate.

tlsConfig := &tls.Config{
RootCAs: caCertPool,
}

Next we create an HTTP client with the TLS configuration. The Transport field of the client specifies the HTTP transport to use for making requests. In this case, we create a new transport with the TLSClientConfig field set to the TLS configuration we created earlier.

tr := &http.Transport{
TLSClientConfig: tlsConfig,
}
client := &http.Client{Transport: tr}

Next we make an HTTPS GET request to the URL specified by the url constant using the HTTP client created earlier. If an error occurs while making the request, we use the log.Fatalf function to log the error and exit the program. We also defer the closing of the response body to ensure that it is always closed after we have finished reading it.

resp, err := client.Get(url)
if err != nil {
log.Fatalf("Failed to get response: %v", err)
}

defer resp.Body.Close()

Next we read the contents of the response body using the ioutil.ReadAll function, which reads all the data from the response body until the end of the stream. If an error occurs while reading the body, we use the log.Fatalf function to log the error and exit the program.

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatalf("Failed to read response body: %v", err)
}

Finally, we use the log.Printf function to print the contents of the response body to the console.

log.Printf("Response body: %s\n", body)

Your final code should look like below.

package main

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

const (
url = "https://localhost:8443"
)

func main() {
cert, err := ioutil.ReadFile("ca.crt")
if err != nil {
log.Fatalf("Failed to read certificate file: %v", err)
}

caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(cert)

tlsConfig := &tls.Config{
RootCAs: caCertPool,
}

tr := &http.Transport{
TLSClientConfig: tlsConfig,
}

client := &http.Client{Transport: tr}
resp, err := client.Get(url)
if err != nil {
log.Fatalf("Failed to get response: %v", err)
}

defer resp.Body.Close()

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatalf("Failed to read response body: %v", err)
}

log.Printf("Response body: %s\n", body)
}

Step 6: Test the TLS connection

  • First you can start your server using go run command and if your server starts successfully, you should be able to see Listening on :8443… on your console.
  • Next you can run your client in another terminal using the same command and if our TLS connection is successful, you should be able to see Response body: Hello, TLS! on your console.

Wrap Up

Well, that’s it. Happy Coding!!!

--

--