TLS 1.3 on the Wire: What an Eavesdropper Can Still See Before Encryption Kicks In
This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.
TCP gave us a reliable byte stream. TLS runs on top of it to add encryption and server authentication. This lab opens one TLS 1.3 connection and asks a precise question: what can an on-path observer see before encryption takes over?
Reading guide: rfc-notes/tls-handshake-certificates.md
Prerequisite: TCP Lab 07: One Connection, From SYN to FIN
Expected time: 55–70 minutes.
The Goal
You will:
- run an
openssl s_serverwith a self-signed certificate and ALPN, - connect with
openssl s_clientusing SNI (-servername), - read the negotiated protocol, cipher, certificate, and ALPN,
- capture the handshake and see that ClientHello (with SNI and the ALPN offer) is in the clear, while in TLS 1.3 the certificate is encrypted.
By the end, you should be able to fill in this table:
| Handshake item | Visible on the wire (TLS 1.3)? | How you saw it |
|---|---|---|
SNI (server_name) |
yes, in ClientHello | capture / tshark |
| ALPN offer | yes, in ClientHello | capture / tshark |
| Chosen cipher / version | yes, in ServerHello | capture |
| Server certificate | no (encrypted) | only via s_client endpoint |
What You Will Learn
- Where TLS sits: on top of TCP, below the application (HTTP).
- What ClientHello and ServerHello carry, and which parts are cleartext.
- What SNI is and why the server needs it before choosing a certificate.
- What ALPN negotiates (e.g.
h2vshttp/1.1). - Why, in TLS 1.3, the certificate is sent encrypted (unlike TLS 1.2).
This lab does not cover:
- A real CA-signed certificate chain or trust-store validation.
- Client certificates / mutual TLS.
- Encrypted Client Hello (ECH), which also hides SNI.
- The cryptographic details of key exchange.
Where to Read in the RFCs
| RFC | Section | What to focus on |
|---|---|---|
| RFC 8446 | 2 | The overall TLS 1.3 handshake (1-RTT) |
| RFC 8446 | 4.1.2 | What's inside ClientHello |
| RFC 8446 | 4.1.3 | What's inside ServerHello |
| RFC 8446 | 4.4.2 | The Certificate message (encrypted in 1.3) |
| RFC 6066 | 3 | Server Name Indication (SNI) |
| RFC 7301 | 3 | Application-Layer Protocol Negotiation (ALPN) |
| RFC 5737 | 3 | Confirming the names/addresses used here are documentation-only |
The Big Picture
Same two-node setup as Labs 07/08: the server runs a TLS listener, and the client connects over TLS.
client (10.0.0.1) ------ eth1/eth1 ------ server (10.0.0.2:4433)
openssl s_server
cert CN=www.example.lab
ALPN: h2, http/1.1
The client connects with SNI=www.example.lab and an ALPN offer of h2,http/1.1, and captures the handshake on its own side.
sequenceDiagram
participant C as client
participant S as server
Note over C,S: TCP already established (Lab 07)
C->>S: ClientHello (SNI=www.example.lab, ALPN=[h2,http/1.1], ciphers, key share)
Note right of C: cleartext — visible on the wire
S->>C: ServerHello (chosen cipher, key share)
Note left of S: cleartext — visible on the wire
Note over C,S: keys derived; rest is encrypted
S-->>C: {EncryptedExtensions (ALPN=h2)}
S-->>C: {Certificate CN=www.example.lab} (encrypted in TLS 1.3)
S-->>C: {CertificateVerify, Finished}
C-->>S: {Finished}
Note over C,S: application data (HTTP) flows encrypted
Both nodes run nicolaka/netshoot (which bundles openssl, tcpdump, and tshark). No additional images are required.
Note: The names and addresses used here are local/documentation-only, so nothing in this lab touches the real internet.
What You Need
Recommended environment:
- Linux / WSL2 / a Linux VM
- Docker
- containerlab
- tcpdump / tshark
Images used:
nicolaka/netshoot:latest
Running the Lab
The quick path, which deploys, generates the certificate, starts s_server, captures the handshake, runs s_client, checks SNI/ALPN/certificate, and tears down for you:
./scripts/labctl.sh run tls-09
Or step through it manually:
1. Move into the working directory
cd protocol-lab/examples/tls-09
2. Deploy and create the certificate
sudo containerlab deploy -t tls-09.clab.yml
docker exec clab-tls-09-server sh -c \
"openssl req -x509 -newkey rsa:2048 -nodes \
-keyout /tmp/server.key -out /tmp/server.crt \
-subj '/CN=www.example.lab' -days 30 -addext 'subjectAltName=DNS:www.example.lab'"
This is a self-signed certificate for a closed lab — no public CA involved.
3. Start the TLS listener on the server
docker exec -d clab-tls-09-server sh -c \
"openssl s_server -accept 4433 -cert /tmp/server.crt -key /tmp/server.key -alpn h2,http/1.1 -www -quiet"
-alpn h2,http/1.1 declares which application protocols the server can speak.
4. Set up the capture on the client
In a separate shell, start the capture (use -s0 so you get the whole handshake):
docker exec -it clab-tls-09-client tcpdump -i eth1 -s0 -n "tcp port 4433"
5. Connect over TLS
docker exec -it clab-tls-09-client sh -c \
"echo Q | openssl s_client -connect 10.0.0.2:4433 -servername www.example.lab -alpn h2,http/1.1 -tls1_3"
What to look for in the s_client output:
subject=CN = www.example.lab
issuer=CN = www.example.lab
...
ALPN protocol: h2
...
Protocol : TLSv1.3
Cipher : TLS_AES_256_GCM_SHA384
Verify return code: 18 (self signed certificate)
subject=CN = www.example.lab: the certificate the server presented.ALPN protocol: h2: client and server agreed onh2(HTTP/2).Protocol : TLSv1.3.Verify return code: 18: verification is treated as failed because the certificate is self-signed (not from a public CA). In this lab, that's expected.
6. Read the cleartext parts out of the capture
If you have tshark, you can extract the SNI and the ALPN offer from inside the ClientHello:
docker exec clab-tls-09-client sh -c \
"tshark -r /tmp/tls-09.pcap -Y 'tls.handshake.type==1' \
-T fields -e tls.handshake.extensions_server_name -e tls.handshake.extensions_alpn_str"
Expected:
www.example.lab h2,http/1.1
In other words, the SNI and the ALPN offer sit in the ClientHello in cleartext. The certificate, on the other hand, is sent after encryption begins in TLS 1.3, so its contents cannot be read from the capture (s_client can decrypt and display it because it is an endpoint of the connection).
Expected Output
s_client:CN = www.example.lab,ALPN protocol: h2,TLSv1.3.tshark(if available): the ClientHello'sserver_name=www.example.lab,alpn=h2,http/1.1.- The capture: ClientHello / ServerHello are readable, but the Certificate is encrypted and is not.
Why It Works
TLS is the layer that adds "encryption" and "verifying the peer is genuine" on top of TCP. A TLS connection first runs a handshake to agree on the cipher suite, the keys, and the application protocol.
- Why SNI is cleartext. A server may host multiple sites on a single IP. To choose which certificate to present, it needs to know "which site is this for?" before any encryption keys exist. That's why SNI rides in the ClientHello in cleartext. (Hiding it is what ECH does — outside this lab's scope.)
- What ALPN decides. It negotiates the upper-layer protocol — HTTP/2 (
h2) vs HTTP/1.1 — inside the handshake, in a single round trip. No extra negotiation round trips are needed. - Why TLS 1.3 encrypts the certificate. Right after ClientHello / ServerHello exchange key material (key shares), the keys are derived, and everything from then on (EncryptedExtensions, Certificate, Finished) is encrypted. In TLS 1.2 the certificate went out in cleartext; in 1.3 it's hidden. So an on-path observer can't see which certificate was served (though the SNI still hints at the site).
The key insight: be able to point at the exact boundary where encryption begins, right there in the capture. Everything up to ClientHello/ServerHello is cleartext; everything after is encrypted.
Common Pitfalls
- Confusing the SNI with the certificate's CN. SNI is the client's cleartext statement of "this is the site I want." CN/SAN is the name in the certificate the server sends back.
- Treating
Verify return code: 18as a bug. It's self-signed, so verification is expected to fail. With a public CA it would be 0. - The TLS 1.2 vs 1.3 difference. In 1.2 the certificate is visible in cleartext. This lab forces 1.3, so it's encrypted. Switch to
-tls1_2and you can confirm the certificate becomes readable in the capture. - Empty ALPN. Forget
-alpnon the server side and no negotiation happens. - Capture size. Without
-s0, packets can be truncated and you may lose parts of the handshake. - ECH. A mechanism to hide SNI exists, but it's not on by default. In this lab, the SNI is visible.
Cleanup
sudo containerlab destroy -t tls-09.clab.yml --cleanup
If you used labctl.sh run tls-09, the script runs destroy for you at the end.
Check Your Understanding
- What layer does TLS sit on top of, and what layer sits above it?
- Who sends the SNI, and why? Why is it cleartext?
- What does ALPN decide? What was chosen in this lab?
- In TLS 1.3, up to which message can you read from a capture? Can you read the certificate? Why or why not?
- What does
Verify return code: 18mean? What would it be with a public CA? - How does the visibility of the certificate differ between TLS 1.2 and 1.3?
References
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
- RFC 6066: TLS Extensions (Server Name Indication)
- RFC 7301: TLS Application-Layer Protocol Negotiation (ALPN)
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
- openssl s_client manual page
Verified Run Log (2026-07-05)
This lab has been confirmed reproducible on real hardware.
Environment:
- Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
- Docker 29.1.3
- containerlab 0.77.0
- client / server:
nicolaka/netshoot:latest(bundlesopenssl/tshark)
Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run tls-09 performed deploy → verify → destroy, and verification.json returned "status": "verified". No image or topology fixes were needed.
The handshake result as seen from openssl s_client
$ echo Q | openssl s_client -connect 10.0.0.2:4433 -servername www.example.lab \
-alpn h2,http/1.1 -tls1_3
Server certificate
subject=CN=www.example.lab
issuer=CN=www.example.lab
Verification error: self-signed certificate
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3
ALPN protocol: h2
How to read it:
- The certificate selected via SNI:
subject=CN=www.example.lab. The server presented the certificate for exactly the name the client requested with-servername www.example.lab(SNI). - Self-signed:
issuerequalssubject, andVerification error: self-signed certificate. The lab CA isn't trusted, so verification failing is the correct outcome here (in production, a CA chain would make it pass). - TLS 1.3:
Protocol: TLSv1.3, cipherTLS_AES_256_GCM_SHA384. - ALPN:
ALPN protocol: h2. The client offeredh2,http/1.1and the server choseh2.
Confirming the ClientHello on the wire (tshark)
Extracting the SNI and ALPN from the ClientHello (tls.handshake.type==1) in the capture:
$ tshark -r tls-09.pcap -Y "tls.handshake.type==1" \
-T fields -e tls.handshake.extensions_server_name -e tls.handshake.extensions_alpn_str
www.example.lab h2,http/1.1
Even in TLS 1.3, the ClientHello is cleartext, so the SNI (the hostname being connected to) and the offered ALPN list (h2,http/1.1) are visible from the network. The ALPN the server chose (h2) travels inside the encrypted EncryptedExtensions, which is why you check it in the s_client output above instead.
Cleanup
containerlab destroy -t tls-09.clab.yml --cleanup
That's the TLS 1.3 handshake on the wire: SNI and the ALPN offer in the clear, keys derived right after ServerHello, and everything from the certificate onward hidden from anyone watching the path.
Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.
Next up, we'll keep climbing the stack and look at what the application layer does with the encrypted channel TLS just handed it.