A Toy TLS 1.3 Client: What It Takes to Speak HTTPS

Implementing a real network protocol from scratch is one of the better ways to understand how it actually works. After tackling toy versions of traceroute, TCP, and DNS, the next challenge was TLS. The goal was narrow: successfully download a single website's homepage over HTTPS. That meant no full implementation, no certificate verification, and support for exactly one cipher suite—just enough to talk to one specific TLS server.

All code for this experiment is available on GitHub. The language of choice was Go, mainly for its solid crypto standard library.

The Scaffolding: One Suite, No Verification

To keep the project within a few days of work, several major simplifications were necessary:

  • Only one cipher suite is supported
  • The server's certificate is received but never parsed or verified
  • Message parsing and formatting are intentionally fragile, since they only need to work against one TLS implementation

The single most valuable resource was The New Illustrated TLS Connection, which documents every byte of a real TLS 1.3 handshake with working code examples. It was referenced constantly; the RFC itself was barely needed.

The Public Key Is Two Lines of Code

The first step is sending a Client Hello message. It carries four pieces of information: a randomly generated public key, 32 bytes of client random data, the target domain name, and the cipher suite and signature algorithm preferences (copied directly from the illustrated TLS site, since only one of each is supported).

The most interesting part is generating that public key. After some confusion, it turns out to be just two lines:

privateKey := random(32)
publicKey, err := curve25519.X25519(privateKey, curve25519.Basepoint)

The rest of the Client Hello is pure bit fiddling—boring but necessary.

Elliptic Curve Cryptography, Appreciated

The elegance of elliptic curve cryptography (ECC) here is hard to miss. Generate a random 32-byte string as a private key, then "multiply" it by the curve's base point to get the public key. That multiplication is scalar multiplication, not point-by-point multiplication.

The X25519 function signature makes this clear—one argument is the scalar, the other is the point, and order matters:

func X25519(scalar, point []byte) ([]byte, error)

Whether any 32-byte string works as a private key for all elliptic curves is unclear, but it holds true for Curve25519.

From Shared Secret to Key Schedule

After the server responds with its own public key, the client can compute a shared secret via elliptic curve Diffie-Hellman (ECDH)—essentially multiplying the server's public point by the client's private scalar:

sharedSecret, err := curve25519.X25519(session.Keys.Private, session.ServerHello.PublicKey)

That yields a 32-byte shared secret. But TLS needs more than that. In total, there are at least four distinct symmetric keys and IVs: separate handshake and application keys for both client and server directions. That's 96 bytes of key material (plus another set for session resumption, which was skipped).

The expansion from a 32-byte secret to all these keys uses HKDF, an algorithm that alternates calls to hkdf.Expand and hkdf.Extract repeatedly:

func (session *Session) MakeHandshakeKeys() {
	zeros := make([]byte, 32)
	psk := make([]byte, 32)
	// ok so far
	if err != nil {
		panic(err)
	}
	earlySecret := hkdf.Extract(sha256.New, psk, zeros) // TODO: psk might be wrong
	derivedSecret := deriveSecret(earlySecret, "derived", []byte{})
	session.Keys.HandshakeSecret = hkdf.Extract(sha256.New, sharedSecret, derivedSecret)
	handshakeMessages := concatenate(session.Messages.ClientHello.Contents(), session.Messages.ServerHello.Contents())

	cHsSecret := deriveSecret(session.Keys.HandshakeSecret, "c hs traffic", handshakeMessages)
	session.Keys.ClientHandshakeSecret = cHsSecret
	session.Keys.ClientHandshakeKey = hkdfExpandLabel(cHsSecret, "key", []byte{}, 16)
	session.Keys.ClientHandshakeIV = hkdfExpandLabel(cHsSecret, "iv", []byte{}, 12)

	sHsSecret := deriveSecret(session.Keys.HandshakeSecret, "s hs traffic", handshakeMessages)
	session.Keys.ServerHandshakeKey = hkdfExpandLabel(sHsSecret, "key", []byte{}, 16)
	session.Keys.ServerHandshakeIV = hkdfExpandLabel(sHsSecret, "iv", []byte{}, 12)
}

Getting the arguments right took a while. The only reason it worked at all was the pre-computed example inputs and outputs on the illustrated TLS site, which allowed for proper unit testing along the way.

Authenticated Encryption and Record Boundaries

TLS doesn't just use plain AES. It relies on authenticated encryption, specifically AES-GCM, which can detect improperly constructed ciphertexts and refuse to decrypt them. This protects against chosen-ciphertext attacks, where an adversary feeds crafted ciphertexts to a decryption oracle to learn about the secret key.

The decryption logic looks like this:

func decrypt(key, iv, wrapper []byte) []byte {

	block, err := aes.NewCipher(key)
	if err != nil {
		panic(err.Error())
	}

	aesgcm, err := cipher.NewGCM(block)
	if err != nil {
		panic(err.Error())
	}

	additional := wrapper[:5]
	ciphertext := wrapper[5:]

	plaintext, err := aesgcm.Open(nil, iv, ciphertext, additional)
	if err != nil {
		panic(err.Error())
	}
	return plaintext
}

Each key is paired with an IV (initialization vector). To ensure a unique IV per message, TLS xors the base IV with the number of records sent or received so far.

The Handshake Continues

Once the key schedule is established, the server sends its encrypted handshake data—the certificate and other parameters. Since the certificate isn't being verified, that data is never parsed. It only needs to be saved for hashing purposes, which is required for the next step:

record := readRecord(session.Conn)
if record.Type() != 0x17 {
    panic("expected wrapper")
}
session.Messages.ServerHandshake = decrypt(session.Keys.ServerHandshakeKey, session.Keys.ServerHandshakeIV, record)

That hash feeds into another round of key derivation to produce the application keys. The process runs almost identically to the earlier extraction:

func (session *Session) MakeApplicationKeys() {
    handshakeMessages := concatenate(
        session.Messages.ClientHello.Contents(),
        session.Messages.ServerHello.Contents(),
        session.Messages.ServerHandshake.Contents())

    zeros := make([]byte, 32)
    derivedSecret := deriveSecret(session.Keys.HandshakeSecret, "derived", []byte{})
    masterSecret := hkdf.Extract(sha256.New, zeros, derivedSecret)

    cApSecret := deriveSecret(masterSecret, "c ap traffic", handshakeMessages)
    session.Keys.ClientApplicationKey = hkdfExpandLabel(cApSecret, "key", []byte{}, 16)
    session.Keys.ClientApplicationIV = hkdfExpandLabel(cApSecret, "iv", []byte{}, 12)

    sApSecret := deriveSecret(masterSecret, "s ap traffic", handshakeMessages)
    session.Keys.ServerApplicationKey = hkdfExpandLabel(sApSecret, "key", []byte{}, 16)
    session.Keys.ServerApplicationIV = hkdfExpandLabel(sApSecret, "iv", []byte{}, 12)
}

After deriving the application keys, the client sends a "finished" message to confirm the handshake. With that done, the real work can begin: making an HTTP request.

Sending and Receiving Application Data

With the application keys in hand, sending data is straightforward. A SendData function encrypts and transmits bytes over the established connection:

req := fmt.Sprintf("GET / HTTP/1.1\r\nHost: %s\r\n\r\n", domain)
session.SendData([]byte(req))

The response, however, makes one thing clear: TLS data does not flow as a continuous stream. It arrives in distinct blocks, each up to 65,535 bytes theoretically, but typically around 1,400 bytes in practice. Each block requires a fresh ReceiveData call:

func (session *Session) ReceiveData() []byte {
	record := readRecord(session.Conn)
	iv := make([]byte, 12)
	copy(iv, session.Keys.ServerApplicationIV)
	iv[11] ^= session.RecordsReceived
	plaintext := decrypt(session.Keys.ServerApplicationKey, iv, record)
	session.RecordsReceived += 1
	return plaintext
}

Every received block needs a recomputed IV (old_iv xor num_records_received), followed by decryption and an incremented record count. This simplified implementation assumes fewer than 255 blocks total, since only the 11th byte of the IV is XORed. That held true for this experiment—82 blocks were enough.

TCP added another wrinkle: a TLS block doesn't always arrive in a single TCP segment. A naive fix loops and polls until the expected number of bytes are available:

func read(length int, reader io.Reader) []byte {
	var buf []byte
	for len(buf) != length {
		buf = append(buf, readUpto(length-len(buf), reader)...)
	}
	return buf
}

Knowing When to Stop

The HTTP response ends with a specific byte sequence ([]byte{48, 13, 10, 13, 10, 23}) because the server uses chunked transfer encoding instead of a Content-Length header. A loop reads blocks until those bytes are spotted:

func (session *Session) ReceiveHTTPResponse() []byte {
	var response []byte
	for {
		pt := session.ReceiveData()
		if string(pt) == string([]byte{48, 13, 10, 13, 10, 23}) {
			break
		}
		response = append(response, pt...)
	}
	return response
}

And it worked. The program successfully downloaded a homepage over TLS, producing output much like curl -i—but the result was genuinely exciting:

$ go build; ./tiny-tls
HTTP/1.1 200 OK
Date: Wed, 23 Mar 2022 19:37:47 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
... lots more headers and HTML follow...

Lessons from a Toy Implementation

Unit testing made the entire project manageable. Copying example data from the illustrated TLS site into test cases sped up debugging immensely compared to trial-and-error against a live server.

The experience drove home several practical lessons:

  • ECDH with Curve25519 is deceptively simple—any 32-byte value works as a private key
  • Proper TLS uses a surprisingly large set of symmetric keys, requiring a multi-step key derivation process
  • AES is always accompanied by an authenticated encryption mode like GCM
  • TLS data travels as discrete records, not as a continuous stream

The resulting code is terrible in every way that matters for production: it only connects to a single site, skips certificate validation entirely, and makes a variety of assumptions about message sizes and record counts. But as an exercise in understanding the protocol's mechanics, it was worth every bit of effort. For anyone interested in practical cryptography, the cryptopals challenges come highly recommended as a complementary way to learn about real-world attacks.