TLS (previously known as SSL) is best known for enabling HTTPS, a secure version of HTTP. However, as TLS's name (Transport Layer Security) suggests, it actually goes deeper than HTTP. TLS is best thought of as a secure version of TCP; in other words, it provides an ability to encrypt and sign arbitrary communications going over sockets [1]. For example, protocols like gRPC build on top of TLS for security.

In an earlier post we've seen how to use TLS to set up HTTPS servers and clients in Go. Here, I want to show how to create an encrypted socket server that could serve as a basis for other network protocols. All the code for this post is available on GitHub.

TLS socket server

Here's a basic echo server using TLS:

func main() {
  port := flag.String("port", "4040", "listening port")
  certFile := flag.String("cert", "cert.pem", "certificate PEM file")
  keyFile := flag.String("key", "key.pem", "key PEM file")
  flag.Parse()

  cert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
  if err != nil {
    log.Fatal(err)
  }
  config := &tls.Config{Certificates: []tls.Certificate{cert}}

  log.Printf("listening on port %s\n", *port)
  l, err := tls.Listen("tcp", ":"+*port, config)
  if err != nil {
    log.Fatal(err)
  }
  defer l.Close()

  for {
    conn, err := l.Accept()
    if err != nil {
      log.Fatal(err)
    }
    log.Printf("accepted connection from %s\n", conn.RemoteAddr())

    go func(c net.Conn) {
      io.Copy(c, c)
      c.Close()
      log.Printf("closing connection from %s\n", conn.RemoteAddr())
    }(conn)
  }
}

It accepts multiple (concurrent) connections from clients, and mirrors back all data the clients send, until the client's socket is closed. It's very similar to how a non-TLS server would look, except that net.Listen is replaced with tls.Listen, and the latter needs a tls.Config (which we've already encountered in the previous post). We can use the certificate generating tool shown in that post, or the mkcert tool to generate a certificate/key pair for this server.

TLS socket client

And now the companion client:

func main() {
  port := flag.String("port", "4040", "port to connect")
  certFile := flag.String("certfile", "cert.pem", "trusted CA certificate")
  flag.Parse()

  cert, err := os.ReadFile(*certFile)
  if err != nil {
    log.Fatal(err)
  }
  certPool := x509.NewCertPool()
  if ok := certPool.AppendCertsFromPEM(cert); !ok {
    log.Fatalf("unable to parse cert from %s", *certFile)
  }
  config := &tls.Config{RootCAs: certPool}

  conn, err := tls.Dial("tcp", "localhost:"+*port, config)
  if err != nil {
    log.Fatal(err)
  }

  _, err = io.WriteString(conn, "Hello simple secure Server\n")
  if err != nil {
    log.Fatal("client write error:", err)
  }
  if err = conn.CloseWrite(); err != nil {
    log.Fatal(err)
  }

  buf := make([]byte, 256)
  n, err := conn.Read(buf)
  if err != nil && err != io.EOF {
    log.Fatal(err)
  }

  fmt.Println("client read:", string(buf[:n]))
  conn.Close()
}

Again, the difference from a non-TLS client is just replacing net.Dial by tls.Dial and the accompanying tls.Config filled in with a certificate the client can trust (this can be either the server's own certificate, or the certificate of the CA signing the server's certificate, and so on).

Examining server certificate chains

Here's a simple program (tls-dial-port) we can use to examine the certificate chain of any server:

func main() {
  addr := flag.String("addr", "localhost:4040", "dial address")
  flag.Parse()

  cfg := tls.Config{}
  conn, err := tls.Dial("tcp", *addr, &cfg)
  if err != nil {
    log.Fatal("TLS connection failed: " + err.Error())
  }
  defer conn.Close()

  certChain := conn.ConnectionState().PeerCertificates
  for i, cert := range certChain {
    fmt.Println(i)
    fmt.Println("Issuer:", cert.Issuer)
    fmt.Println("Subject:", cert.Subject)
    fmt.Println("Version:", cert.Version)
    fmt.Println("NotAfter:", cert.NotAfter)
    fmt.Println("DNS names:", cert.DNSNames)
    fmt.Println("")
  }
}

Given an IP address, this program opens up a TLS connection to the server and reports the certificates it uses. We can try it with our own TLS socket server first; note that this program doesn't have any pre-trusted certificates beyond the system defaults, so it will reject self-signed certificates. However, if we use mkcert to generate a certificate for our server, it will work.

In one terminal, let's run our TLS socket server with a mkcert-generated certificate:

$ mkcert localhost

Created a new certificate valid for the following names 📜
 - "localhost"

The certificate is at "./localhost.pem" and the key at "./localhost-key.pem" ✅

It will expire on 7 July 2023 🗓

$ go run tls-socket-server.go -cert localhost.pem -key localhost-key.pem
2021/04/07 06:27:20 listening on port 4040

In a separate terminal, run tls-dial-port:

$ go run tls-dial-port.go -addr localhost:4040
0
Issuer: CN=mkcert eliben@salami (Eli Bendersky),OU=eliben@salami (Eli Bendersky),O=mkcert development CA
Subject: OU=eliben@salami (Eli Bendersky),O=mkcert development certificate
Version: 3
NotAfter: 2023-07-07 13:27:12 +0000 UTC
DNS names: [localhost]

We see the certificate mkcert generated. Since mkcert added this certificate to my machine's system root store, it will be trusted by the default settings of tls.Dial.

We can also point this program at port 443 (the default port for HTTPS) of public websites; for example:

$ go run tls-dial-port.go -addr reddit.com:443
0
Issuer: CN=DigiCert TLS RSA SHA256 2020 CA1,O=DigiCert Inc,C=US
Subject: CN=*.reddit.com,O=Reddit Inc.,L=San Francisco,ST=California,C=US
Version: 3
NotAfter: 2021-07-06 23:59:59 +0000 UTC
DNS names: [reddit.com *.reddit.com]

1
Issuer: CN=DigiCert Global Root CA,OU=www.digicert.com,O=DigiCert Inc,C=US
Subject: CN=DigiCert TLS RSA SHA256 2020 CA1,O=DigiCert Inc,C=US
Version: 3
NotAfter: 2030-09-23 23:59:59 +0000 UTC
DNS names: []

[1]Prior to the TLS standards, an older version of the protocol was called SSL - Secure Socket Layer, which is even more suggestive of this use.
[2]Note that here I show the machinery for a client to trust the server, not the other way around. In the HTTPS post we've also seen how to set up mTLS for mutual authentication, and the same technique can easily be applied here.