Blog / Go

  • go
  • crypto-tls
  • tls
  • wireshark
  • debugging
  • http2

Go's tls.Config.KeyLogWriter: Decrypting Your Own TLS in Wireshark

Sooner or later a Go service does something odd over HTTPS and the logs stop being enough. The client says it sent a header; the server swears it never arrived. You want to see the bytes on the wire, and the bytes on the wire are, correctly, gibberish.

The old trick was to load the server's RSA private key into Wireshark and let it decrypt. That stopped working for most traffic years ago: with ECDHE key exchange (which is every TLS 1.3 connection, and nearly every sensible TLS 1.2 one) the server's long-term key only signs the handshake. It never touches the session keys, so having it tells Wireshark nothing. That is forward secrecy doing its job.

The replacement is to ask the endpoint that does know the session secrets to write them down. In Go that is one field: tls.Config.KeyLogWriter.

The smallest useful client

The convention, borrowed from NSS and followed by curl, Firefox and Chrome, is an environment variable called SSLKEYLOGFILE. Go's crypto/tls does not read it; you wire it up yourself, which is fine, because you probably want to be deliberate about it anyway.

package main

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

// keyLog returns a writer for $SSLKEYLOGFILE, or nil when it is unset.
func keyLog() io.Writer {
	path := os.Getenv("SSLKEYLOGFILE")
	if path == "" {
		return nil
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
	if err != nil {
		log.Fatalf("open key log: %v", err)
	}
	return f
}

func main() {
	tr := http.DefaultTransport.(*http.Transport).Clone()
	tr.TLSClientConfig = &tls.Config{KeyLogWriter: keyLog()}
	client := &http.Client{Transport: tr}

	resp, err := client.Get("https://example.com/")
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	io.Copy(io.Discard, resp.Body)
	fmt.Println(resp.Proto, resp.Status)
}

Two details in there are deliberate. The function returns a bare nil rather than a nil *os.File stuffed into an io.Writer, because the latter is a non-nil interface holding a nil pointer, and crypto/tls would happily try to call Write on it. And the file is opened 0o600, for reasons that will become obvious in a moment.

I ran that against a real HTTPS site and got a key log with four lines in it (TLS 1.3, HTTP/2, file mode -rw-------):

CLIENT_HANDSHAKE_TRAFFIC_SECRET <client random> <secret>
SERVER_HANDSHAKE_TRAFFIC_SECRET <client random> <secret>
CLIENT_TRAFFIC_SECRET_0 <client random> <secret>
SERVER_TRAFFIC_SECRET_0 <client random> <secret>

Each line is a label, the 32-byte client random from the ClientHello in hex, and the secret in hex. The client random is how Wireshark matches a line to a connection: it reads the ClientHello out of the capture and looks the random up in the file. For a TLS 1.2 connection you get a single CLIENT_RANDOM line carrying the master secret instead.

Capturing and decrypting

Start the capture before the process opens its connection, because Wireshark needs the handshake to find the client random. Then run the program.

sudo tcpdump -i any -w cap.pcap 'tcp port 443'
SSLKEYLOGFILE=$PWD/keys.log go run .

In Wireshark, open the capture and go to Edit, Preferences, Protocols, TLS, and set "(Pre)-Master-Secret log filename" to your keys.log. The application data now shows up as a "Decrypted TLS" tab at the bottom, and HTTP/2 frames get dissected properly, HPACK headers and all.

If you prefer the command line, the preference is tls.keylog_file:

tshark -r cap.pcap -o tls.keylog_file:keys.log -Y http2 -V | less

And here is a bit I like: editcap --inject-secrets tls,keys.log cap.pcap cap-with-keys.pcapng embeds the secrets into the capture file itself. The result decrypts anywhere without the side file, which is very convenient for sending to a colleague. It is also a file that now contains everything needed to read the traffic, so treat it accordingly.

The server side works the same way

The field lives on tls.Config, not on a client type, so a server can log too. Put it on http.Server.TLSConfig, or on the config you hand to tls.Listen. That is often the more useful end: you can watch what a browser or a third-party client really sends without touching it. The same goes for gRPC, mutual TLS between your own services, or anything else that takes a *tls.Config.

Things that will waste your afternoon

Setting TLSClientConfig switches off HTTP/2. If you build an http.Transport from scratch and assign a TLSClientConfig, Go stops negotiating HTTP/2 automatically unless you also set ForceAttemptHTTP2: true. Your debugging setup then quietly changes the protocol you are debugging, which is a spectacular way to "fix" a bug. Cloning http.DefaultTransport, as above, keeps that flag set; the program printed HTTP/2.0 even with my custom config.

Pooled connections mean missing handshakes. A keep-alive connection is one handshake followed by a lot of requests. If your capture started after that handshake, no key log line will help, because Wireshark never saw the ClientHello. Start the capture first, then start the process. Same story for a long-running server: restart it under capture, or wait for connections to be re-established.

Resumed sessions. A resumed connection has its own randoms and its own traffic secrets, and those are logged like any other. If a resumed connection will not decrypt, the usual cause is that the capture missed the first flight rather than that anything is wrong with the log.

A write error fails the handshake. Reading the source, the error from KeyLogWriter.Write is returned up the handshake path, so a full disk or a closed file breaks the connection rather than being ignored. That is good to know before you wire it to something exotic. Each line goes out in one Write call under a package-wide mutex, so a plain *os.File shared by every connection is safe without further locking.

Do not leave it on

The documentation says it plainly: use of KeyLogWriter compromises security and should only be used for debugging. The reason is simple. A key log plus a packet capture is the plaintext of every session in it. Forward secrecy protects you against someone who later steals the server's long-term key; it does nothing against a file where you have written the session keys out yourself.

So gate it on an environment variable as above, keep it off by default, create the file with 0o600, and delete it when you are done. If you are tempted to put a flag for this into a production binary, think about who can set that flag, and what a log shipper or backup job will do with a file that grows next to your other logs.

Wireshark's own TLS page covers the preference names and the log format if you want the full list of labels; the ones above are what a modern Go client or server will write.