Securing TLS connections from eavesdropping

It is also important to note that while TLS sessions do offer a secure channel for exchanging data, TLS encryption is not a panacea; it is still possible for a malicious adversary to intercept and decode TLS traffic by using a proxy to perform a man-in-the-middle (MITM) attack:

Figure 1: Using a MITM attack to intercept TLS traffic

The preceding diagram illustrates a scenario where Alice uses her bank's application on her mobile phone to query the balance in her bank account. Eve is a malicious actor trying to intercept the API calls between the application running on Alice's phone and the bank backend servers.

To achieve this, Eve needs to install a MITM proxy that will intercept and record outgoing connection requests from Alice's phone and either proxy them to the intended server or return fake responses. However, as we mentioned previously, the bank's server uses TLS-based encryption, so the bank application will not complete the TLS handshake steps unless the server provides a valid TLS certificate for the bank's domain.

In order for the MITM attack to succeed, the proxy server needs to be able to provide forged TLS certificates to Alice, which not only matches the bank's domain but is also signed by one of the globally trusted Certificate Authorities (CAs) that are preinstalled on Alice's phone.

Given that Eve does not have access to the private keys of any global CA, a prerequisite for forging certificates is for Eve to install a custom certificate authority on Alice's phone. This can be achieved either by exploiting a security hole via social engineering or simply by forcing Alice to do so if Eve happens to be a state actor.

With Eve's CA certificate in place, the interception process works as follows:

  1. Alice tries to connect to a website, for example, https://www.bank.com.
  2. Eve intercepts the request and establishes a TCP socket with Alice.
  3. Alice initiates the TLS handshake. The handshake headers include a Server Name Indication (SNI) entry, which indicates the domain name it is trying to reach.
  4. Eve opens a connection to the real https://www.bank.com server and initiates a TLS handshake, making sure to pass the same SNI entry as Alice.
  5. The bank server responds with its TLS certificate that also includes information about the server Common Name (CN), which in this case would normally be www.bank.com or bank.com. The certificate may also include a Subject Alternative Name (SAN) entry, which enumerates a list of additional domains that are also secured by the same certificate.
  6. Eve forges a new TLS certificate that matches the information from the bank's TLS certificate and signs it with the private keys that correspond to the custom CA cert installed on Alice's phone. The forged certificate is returned to Alice.
  7. Alice successfully verifies the forged TLS certificate, that is, it has the correct SNI and its parent certificate chain can be fully traced back to a trusted root CA. At this point, Alice completes the TLS handshake and sends out an API request to the bank API, which includes her access credentials.
  1. Alice's request is encrypted with the forged TLS certificate. Eve decrypts the request and makes a record of it. Acting as a proxy, she opens a connection to the real bank server and sends Alice's request through.
  2. Eve records the response from the bank server and sends it back to Alice.

Now that we are fully aware of the extent of damage that can be potentially caused by MITM attacks, what steps can we actually take to make our APIs more resistant to attacks like this? One approach to mitigating the issue of forged TLS certificates is to employ a technique known as public key pinning.

Each time we release a new client for our application, we embed the fingerprint of the public key that corresponds to the TLS certificate that's used to secure the API gateway. After completing the TLS handshake, the client calculates the public key fingerprint for the certificates that are presented by the server and compares it to the embedded value. If a mismatch is detected, the client immediately aborts the connection attempt and notifies the user that a potential MITM attack might be in progress.

Now, let's look at how we can implement public key pinning in our Go applications. The full source code for the following example is available in the Chapter09/pincert/dialer package of this book's GitHub repository. Go's http.Transport type is a low-level primitive that is used by http.Client to perform HTTP and HTTPS requests. When creating a new http.Transport instance, we can override its DialTLS field with a custom function that will be invoked each time a new TLS connection needs to be established. This sounds like the perfect spot to implement the public key fingerprint verification logic.

The WithPinnedCertVerification helper, whose listing is shown in the following code, returns a dialer function that can be assigned to the DialTLS field of http.Transport:

func WithPinnedCertVerification(pkFingerprint []byte, tlsConfig *tls.Config) TLSDialer {
return func(network, addr string) (net.Conn, error) {
conn, err := tls.Dial(network, addr, tlsConfig)
if err != nil {
return nil, err
}
if err := verifyPinnedCert(pkFingerprint, conn.ConnectionState().PeerCertificates); err != nil { _ = conn.Close()
return nil, err
}
return conn, nil
}
}

The returned dialer attempts to establish a TLS connection by invoking the tls.Dial function with the caller-provider network, destination address, and tls.Config parameters as arguments. Note that the tls.Dial call will also automatically handle the validation of the TLS certificate chain that's presented by the remote server for us. After successfully establishing a TLS connection, the dialer delegates the verification of the pinned certificate to the verifyPinnedCert helper function, which is shown in the following code snippet:

func verifyPinnedCert(pkFingerprint []byte, peerCerts []*x509.Certificate) error {
for _, cert := range peerCerts {
certDER, err := x509.MarshalPKIXPublicKey(cert.PublicKey)
if err != nil {
return xerrors.Errorf("unable to serialize certificate public key: %w", err)
}
fingerprint := sha256.Sum256(certDER)

// Matched cert PK fingerprint to the one provided.
if bytes.Equal(fingerprint[:], pkFingerprint) {
return nil
}
}
return xerrors.Errorf("remote server presented a certificate which does not match the provided fingerprint")
}

The verifyPinnedCert implementation iterates the list of X509 certificates presented by the remote server and calculates the SHA256 hash for each certificate's public key. Each calculated fingerprint is then compared to the pinned certificate's fingerprint. If a match is found, then verifyPinnedCert returns without an error and the TLS connection can be safely used to make API calls. On the other hand, an error will be returned if no match was found. In the latter case, the dialer will terminate the connection and propagate the error back to the caller.

Using this dialer to improve the security of your API clients is quite easy. All you need to do is create your own http.Client instance, as follows:

client := &http.Client{
Transport: &http.Transport{
DialTLS: dialer.WithPinnedCertVerification(
fingerprint,
new(tls.Config),
),
},
}

You can now use this client instance to perform HTTPS requests to your backend servers, just like you would normally do, but with the added benefit that your code can now detect MITM attack attempts. A complete end-to-end example of using this dialer to perform public key pinning can be found in the Chapter09/pincert package of this book's GitHub repository.