web server TLS client/server workflow X25519 HKDF AES-GCM

Adding self-written TLS to a self-written web server

This walkthrough is written for a beginner reader. It explains the exact client/server communication flow before showing how the code joins the TLS layer with the HTTP web server.

Original web-server series: Building Your Own Web Server · source: DmytroHuzz/build_own_webserver
Original TLS series: Rebuilding TLS from Scratch · source: DmytroHuzz/rebuilding_tls

Run the project first

If you like to see the result before reading the explanation, run the one-command demo from the project root:

python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -e ".[dev]"
python3 scripts/demo.py

The plain HTTP path and the HTTP-over-self-written-TLS path should both print HTTP/1.1 200 OK.

Important: this is not browser-compatible HTTPS. The TLS-like wire format is intentionally small for learning, so use python3 client_side.py or python3 scripts/demo.py, not a browser.

Minimum concepts before reading

You do not need to read the previous series first, but these concepts make the walkthrough much easier.

TermBeginner meaning
TCPA reliable stream of bytes. It does not preserve message boundaries.
HTTP parserCode that turns plaintext HTTP bytes into a request object.
TLS recordA framed chunk of bytes. After the handshake, application records are encrypted.
HandshakeThe setup phase where peers agree on keys and the client verifies the server.
Certificate chainA server certificate plus issuer certificates that lead to a trusted root.
Ephemeral keyA temporary key used for one connection, then thrown away.
HKDFA function that turns a shared secret into encryption keys.
AES-GCMEncryption that also detects tampering.

Wrong mental model vs right mental model

Wrong mental model

The HTTP parser receives encrypted bytes and somehow understands TLS.

TCP bytes ──► HTTP parser
              tries to handle TLS too

Right mental model

The TLS layer decrypts records first. The HTTP parser receives only plaintext HTTP bytes.

TCP bytes ──► TLS layer ──► HTTP parser
           encrypted       plaintext

NGINX / OpenSSL analogy

Production NGINX normally relies on a TLS library such as OpenSSL. OpenSSL handles the secure connection state; NGINX then processes plaintext HTTP. This project uses the same boundary with smaller educational pieces.

Production-ish:
TCP → OpenSSL/TLS state → plaintext HTTP → NGINX HTTP processing

This project:
TCP → ToyTLSServerConnection → plaintext HTTP → HTTPParser / route matcher

1. The one idea to keep in mind

A web server normally receives bytes from a TCP socket and gives those bytes to an HTTP parser.

After TLS is added, the bytes from the TCP socket are no longer HTTP. They are TLS records. The server must first decrypt those records. Only then can the HTTP parser run.

Mental model: TCP carries bytes. TLS turns encrypted bytes into plaintext bytes. HTTP parses the plaintext bytes.
Plain HTTP:
client HTTP bytes ──TCP──► server HTTP parser

HTTP over self-written TLS:
client HTTP bytes
  ↓ encrypted by client TLS layer
TLS record bytes ──TCP──► server TLS layer
  ↓ decrypted by server TLS layer
plaintext HTTP bytes ───► server HTTP parser

2. First recap: what the web server already did

The final web-server project had no TLS. It was a non-blocking HTTP file server.

Plain HTTP communication

Client                                      Server
  │                                           │
  │ TCP connect                              │ accept connection
  │                                           │
  ├── GET / HTTP/1.1 ───────────────────────►│ read bytes from socket
  │   Host: localhost                        │ append bytes to DataProvider
  │                                           │ parse HTTP request
  │                                           │ choose matching location /
  │                                           │ read html/index.html
  │                                           │ build HTTP response
  │                                           │
  │◄──────────────────── HTTP/1.1 200 OK ────┤ write response bytes
  │                         body...

The important part is the buffer:

socket.recv(...) → DataProvider → HTTPParser.parse_message(...)

TCP is a stream. One read can contain half a request, exactly one request, or multiple requests. That is why the parser returns both the parsed message and the number of bytes it consumed.

3. Second recap: what the TLS project already did

The final TLS project created an authenticated encrypted channel between a client and a server. The workflow has three phases.

Phase 0 — before the connection

Before any TCP connection exists, both sides already have some long-term information.

SideAlready hasWhy it matters
Server server_key.pem, server_cert.pem, intermediate_cert.pem The private key stays on the server. The certificate chain lets the server prove its identity.
Client root_cert.pem The client trusts this root certificate and uses it to verify the server certificate chain.
Both The protocol rules: X25519, HKDF, AES-GCM, message tags Both sides must interpret the same bytes in the same way.
At this point there are no session keys yet. The client and server have not talked. They only have long-term identity/trust material and agreed protocol rules.

Phase 1 — handshake

The handshake is where the client and server agree on fresh session keys and the client verifies that it is talking to the real server.

Step 1: TCP connection

Client                                      Server
  │                                           │
  │ TCP connect                              │ accept TCP connection
  │                                           │

Nothing is encrypted yet. TCP only gives both sides a byte stream.

Step 2: ClientHello

The client creates a fresh ephemeral X25519 key pair. It keeps the private key in memory and sends only the public key.

Client                                      Server
  │                                           │
  │ generate client_ephemeral_private         │
  │ derive client_ephemeral_public            │
  │                                           │
  ├── ClientHello { client_eph_pub } ───────► │ receives client's public key
QuestionAnswer
What is on the wire?The client's ephemeral public key.
What stays private?The client's ephemeral private key.
Can an observer compute the session key now?No. The observer has only a public key.

Step 3: ServerHello and ServerAuth

The server receives the client's public key. Then it creates its own fresh ephemeral X25519 key pair.

The server sends two messages back:

  1. ServerHello — contains the server's ephemeral public key.
  2. ServerAuth — contains the server certificate chain and a signature.
Client                                      Server
  │                                           │ receives client_eph_pub
  │                                           │ generate server_ephemeral_private
  │                                           │ derive server_ephemeral_public
  │                                           │
  │ ◄──────── ServerHello { server_eph_pub } ─┤
  │ ◄──────── ServerAuth {                    │
  │              server certificate,          │
  │              intermediate certificate,    │
  │              signature                    │
  │          } ───────────────────────────────┤

The signature is the bridge between identity and this connection:

signature = sign_with_server_identity_private_key(
    client_ephemeral_public || server_ephemeral_public
)

This says: “The server that owns the certificate private key also saw these exact ephemeral keys.”

Step 4: The server computes its session keys

The server can already compute the shared secret:

shared_secret = X25519(
    server_ephemeral_private,
    client_ephemeral_public
)

Then HKDF derives two directional keys:

Step 5: The client verifies the server

The client does not trust the server just because it sent a certificate. It checks two things.

CheckWhat the client verifiesWhy
Certificate chain server_cert → intermediate_cert → trusted root_cert Proves that the public key in the server certificate belongs to a trusted identity.
CertificateVerify signature The signature over client_eph_pub || server_eph_pub Proves that this TCP peer owns the private key matching the certificate.

Step 6: The client computes the same session keys

If verification succeeds, the client computes the same shared secret:

shared_secret = X25519(
    client_ephemeral_private,
    server_ephemeral_public
)

Because of Diffie-Hellman, this produces the same bytes that the server computed.

What each side can compute

ParticipantHasCan compute shared secret?
Client client_private + server_public Yes
Server server_private + client_public Yes
Network observer client_public + server_public + certificates No

Client side state after the handshake

Server side state after the handshake

Now HTTP can start

After the handshake, the TLS layer is ready. But HTTP has not happened yet. The next bytes sent by the client will be an HTTP request, encrypted inside an application record.

Phase 2 — encrypted HTTP

Now the client builds an ordinary HTTP request:

GET / HTTP/1.1\r\n
Host: localhost\r\n
Connection: close\r\n
\r\n

But it does not send those bytes directly over TCP. It encrypts them first.

Client                                      Server
  │                                           │
  │ plaintext HTTP request                    │
  │ encrypt with client_write_key, seq=0      │
  │                                           │
  ├── AES-GCM record { encrypted HTTP } ────► │ decrypt with client_write_key, seq=0
  │                                           │ recover plaintext HTTP request
  │                                           │ append to HTTP input buffer
  │                                           │ HTTPParser.parse_message(...)

The server then builds a normal plaintext HTTP response and encrypts it with the server-to-client key.

Client                                      Server
  │                                           │ HTTP/1.1 200 OK + body
  │                                           │ encrypt with server_write_key, seq=0
  │                                           │
  │ ◄──── AES-GCM record { encrypted HTTP } ──┤
  │ decrypt with server_write_key, seq=0       │
  │ recover plaintext HTTP response           │

4. What changes when TLS is inserted into the web server?

The HTTP parser does not change. The route matcher does not change. File serving does not change.

What changes is the connection layer. Each accepted connection gets a context object:

class ConnectionContext:
    def __init__(self, sock, server_block, tls=None):
        self.sock = sock
        self.server_block = server_block
        self.tls = tls
        self.http_input = DataProvider()
        self.output_buffer = bytearray()
        self.closing_after_write = False

The meaning of tls is simple:

ValueMeaning
tls is NoneThis is a plain HTTP connection. Socket bytes are HTTP bytes.
tls is ToyTLSServerConnection(...)This is a TLS connection. Socket bytes must be processed by the TLS layer first.

5. The read path in the integrated server

Plain HTTP listener

data = sock.recv(4096)
context.http_input.append(data)
_handle_http_messages(context)

This is the same path as the original web server.

TLS listener

data = sock.recv(4096)
context.tls.feed_wire_data(data)
_queue_output(context, context.tls.pop_pending_wire_data())
plaintext = context.tls.read_plaintext()

if plaintext:
    context.http_input.append(plaintext)
    _handle_http_messages(context)

The important difference is that data is not directly given to the HTTP parser. Only plaintext is.

6. Why the TLS demo had to become a state machine

The standalone TLS series could be written as a straight-line program:

recv ClientHello
send ServerHello
send ServerAuth
recv encrypted request
send encrypted response

That is easy to read, but it blocks. A non-blocking web server cannot stop the whole event loop while one client is halfway through a handshake.

So the integrated TLS server keeps state:

WAIT_CLIENT_HELLO
  feed TCP chunks into RecordBuffer
  if a complete ClientHello exists:
      process it
      queue ServerHello
      queue ServerAuth
      derive keys
      move to ESTABLISHED

ESTABLISHED
  each incoming record is application data
  decrypt into plaintext HTTP bytes
  each outgoing HTTP response is encrypted into a record

This is why the TLS connection object has this API:

tls.feed_wire_data(data_from_socket)
bytes_to_send = tls.pop_pending_wire_data()
http_bytes = tls.read_plaintext()
record_bytes = tls.protect_application_data(http_response)

7. The full integrated message flow

Client                                      Server
  │                                           │
  │ TCP connect to TLS port                   │ accept socket
  │                                           │ create ConnectionContext(tls=ToyTLSServerConnection)
  │                                           │
  │ generate client ephemeral keypair         │
  ├── ClientHello { client_eph_pub } ───────► │ feed_wire_data(...)
  │                                           │ parse ClientHello
  │                                           │ generate server ephemeral keypair
  │                                           │ sign client_eph_pub || server_eph_pub
  │                                           │ derive session keys
  │                                           │ queue handshake responses
  │                                           │
  │ ◄──────── ServerHello { server_eph_pub } ─┤ pop_pending_wire_data()
  │ ◄──────── ServerAuth { certs, sig } ──────┤
  │                                           │
  │ verify certificate chain                  │
  │ verify signature                          │
  │ derive same session keys                  │
  │                                           │
  │ build plaintext HTTP request              │
  │ encrypt with client_write_key             │
  ├── encrypted application record ─────────► │ decrypt with client_write_key
  │                                           │ read_plaintext() gives HTTP bytes
  │                                           │ HTTPParser.parse_message(...)
  │                                           │ read html/index.html
  │                                           │ build plaintext HTTP response
  │                                           │ encrypt with server_write_key
  │ ◄──────── encrypted application record ───┤
  │ decrypt with server_write_key             │
  │ print HTTP response                       │

8. The configuration that chooses plain vs TLS

http {
    server {
        listen 8080;
        listen 8443 ssl;
        server_name localhost;

        ssl_certificate certs/server_cert.pem;
        ssl_certificate_key certs/server_key.pem;
        ssl_certificate_chain certs/intermediate_cert.pem;

        location / {
            root html;
        }
    }
}

listen 8080; creates a plain listener. listen 8443 ssl; creates a listener that attaches TLS state to accepted connections.

9. What is on the wire?

This project uses a simplified educational wire format from the TLS series. It is not real TLS 1.3.

MomentWire bytes containEncrypted?
ClientHelloClient ephemeral public keyNo
ServerHelloServer ephemeral public keyNo
ServerAuthCertificate chain + signatureNo
Client requestAES-GCM protected HTTP requestYes
Server responseAES-GCM protected HTTP responseYes

10. Files to read in order

  1. server_side.py — prepares the demo site and starts the server.
  2. client_side.py — shows plain HTTP and self-written TLS client workflows.
  3. tls_web_server/tls/connection.py — the explicit client/server TLS state machines.
  4. tls_web_server/server.py — the event loop where plain and TLS connections split.
  5. tls_web_server/http.py — HTTP parser and file response code reused by both paths.
  6. tls_web_server/config.py — config parser for listen 8443 ssl.

11. How to run it

Two-terminal workflow

# Terminal 1
python3 server_side.py

# Terminal 2
python3 client_side.py

One-command workflow

python3 scripts/demo.py

Tests

python3 -m pytest -q

12. What this project proves

13. What this project does not prove

This is not browser-compatible HTTPS. It is a learning protocol with TLS-like concepts.

To work with a browser, the project would need real TLS 1.3 records, real ClientHello parsing, real ServerHello, transcript hashes, encrypted handshake messages, Finished messages, SNI, ALPN, alerts, and a browser-trusted certificate.

14. Final mental model

Before TLS:
TCP bytes are HTTP bytes.

After TLS:
TCP bytes are TLS records.
TLS records decrypt into HTTP bytes.
HTTP parser sees only HTTP bytes.

That is the whole integration boundary.