Cryptography · lesson 7 of 8
Beginner
2Intermediate
5Also part of Cryptography
9- + Python Security from Python
- + Java Security from Java
- + Data Security and PII: Hashing, Masking, and Tokenization from Data Engineering
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + JWT Authentication in Spring Boot from Java
- + JavaScript Security Best Practices from JavaScript
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
Also part of: Networking
The TLS Handshake, Run Locally with openssl s_server
Run a TLS 1.3 handshake on localhost with openssl s_server and s_client, then watch it fail on an unknown CA, a wrong hostname and a TLS 1.2-only client.
By SkillByExample · Published
TLS is the protocol behind HTTPS. Each handshake does the work of lessons 5 and 6 in one round trip: an
X25519-style key exchange, a signature from the server’s certificate key, and certificate and hostname
verification. This lesson runs a real TLS server and client on localhost, using the lab CA from
lesson 6, and breaks the handshake three ways. Nothing
connects to any other host.
Inside docker run --rm -it -v "$PWD":/work -w /work python:3.12-slim bash, first run lesson 6’s
make_ca.sh and issue_leaf.sh to create ca.crt, server.crt and server.key. Then run these scripts
in the same directory. They were run with OpenSSL 3.5.7 and Python 3.12.15 on Debian 13.7. Each script
starts s_server in the background and waits for its ACCEPT line before connecting. -naccept makes the
server exit after that many connections.
A successful TLS 1.3 handshake
# handshake.sh
rm -f server.log
openssl s_server -accept 8443 -cert server.crt -key server.key \
-www -naccept 1 > server.log 2>&1 &
until grep -q ACCEPT server.log 2>/dev/null; do sleep 0.1; done
echo | openssl s_client -connect localhost:8443 -servername localhost \
-CAfile ca.crt 2>&1 |
grep -E "^(depth|Peer signature type|Negotiated|New,|Verify return code)"
$ sh handshake.sh
depth=1 CN=Demo Lab Root CA
depth=0 CN=app.lab.test
Peer signature type: ecdsa_secp256r1_sha256
Negotiated TLS1.3 group: X25519MLKEM768
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)
Read it bottom-up, and each line is something from an earlier lesson:
Verify return code: 0 (ok): the chain checked out.depth=1is the lab CA,depth=0the server certificate (lesson 6).TLS_AES_256_GCM_SHA384: after the handshake, every record is encrypted with AES-256-GCM, the AEAD from lesson 2, with a fresh nonce per record.Peer signature type: ecdsa_secp256r1_sha256: the server signed the handshake transcript with its P-256 certificate key, which proves it holds that key (lesson 5).Negotiated TLS1.3 group: X25519MLKEM768: this one was a surprise in the run. OpenSSL 3.5 doesn’t use plain X25519 by default. It uses a hybrid: X25519 combined with ML-KEM-768, a post-quantum key encapsulation standardised in FIPS 203. The session key stays safe if either half holds, which protects today’s recorded traffic against a future quantum computer.
-servername localhost sends SNI, the hostname the client wants, so a server hosting several sites can
pick the right certificate.
No trusted CA: the s_client trap
Now the client doesn’t know the lab CA. The script connects twice: once with s_client’s default
behaviour, and once with -verify_return_error.
# no_ca.sh
rm -f server.log
openssl s_server -accept 8443 -cert server.crt -key server.key \
-www -naccept 2 > server.log 2>&1 &
until grep -q ACCEPT server.log 2>/dev/null; do sleep 0.1; done
echo "--- no CA, default behaviour"
echo | openssl s_client -connect localhost:8443 -servername localhost > out.txt 2>&1
echo "client exit: $?"
grep -E "^(verify error|Verify return code)" out.txt
echo "--- no CA, -verify_return_error"
echo | openssl s_client -connect localhost:8443 -servername localhost \
-verify_return_error > out.txt 2>&1
echo "client exit: $?"
grep -oE "verify error.*|certificate verify failed" out.txt
wait
echo "--- server saw"
grep -oE "(tlsv1|ssl/tls) alert [a-z ]+" server.log
$ sh no_ca.sh
--- no CA, default behaviour
client exit: 0
verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate
Verify return code: 21 (unable to verify the first certificate)
--- no CA, -verify_return_error
client exit: 1
verify error:num=20:unable to get local issuer certificate
certificate verify failed
--- server saw
tlsv1 alert unknown ca
The first connection is the trap. s_client logs error 20, the same unable to get local issuer certificate as openssl verify in lesson 6, and a follow-on error 21. Then it finishes the handshake
anyway and exits 0. It’s a diagnostic tool, so it reports problems and carries on. Never read a
successful s_client connection as proof that the certificate is trusted: check the final
Verify return code.
With -verify_return_error, the client behaves like a real one. It aborts with certificate verify failed, exits 1, and sends the server a TLS alert, which the server logs as unknown ca (alert 48).
Browsers, curl and Python’s default context all behave this way.
A trusted certificate for the wrong host
Here the client trusts the CA, but asks for bank.example and checks that name.
# tls_hostname.sh
rm -f server.log
openssl s_server -accept 8443 -cert server.crt -key server.key \
-www -naccept 1 > server.log 2>&1 &
until grep -q ACCEPT server.log 2>/dev/null; do sleep 0.1; done
echo | openssl s_client -connect localhost:8443 -servername bank.example \
-CAfile ca.crt -verify_hostname bank.example -verify_return_error > out.txt 2>&1
echo "client exit: $?"
grep -oE "verify error.*|certificate verify failed" out.txt
wait
echo "--- server saw"
grep -oE "(tlsv1|ssl/tls) alert [a-z ]+" server.log
$ sh tls_hostname.sh
client exit: 1
verify error:num=62:hostname mismatch
certificate verify failed
--- server saw
ssl/tls alert bad certificate
The chain is valid, but bank.example isn’t in the certificate’s SAN, so the client aborts with error 62
and the server sees bad certificate (alert 42). This is the check that stops someone holding a valid
certificate for their site from answering for yours. s_client only does it when asked with
-verify_hostname. Proper clients do it by default, as the Python example below shows.
No shared protocol version
Finally, a server that only accepts TLS 1.3 meets a client limited to TLS 1.2.
# versions.sh
rm -f server.log
openssl s_server -accept 8443 -cert server.crt -key server.key \
-www -tls1_3 -naccept 1 > server.log 2>&1 & # TLS 1.3 only
until grep -q ACCEPT server.log 2>/dev/null; do sleep 0.1; done
echo | openssl s_client -connect localhost:8443 -servername localhost \
-CAfile ca.crt -tls1_2 > out.txt 2>&1 # TLS 1.2 only
echo "client exit: $?"
grep -oE "tlsv1 alert [a-z ]+|SSL alert number [0-9]+" out.txt
wait
echo "--- server saw"
grep -oE "unsupported protocol" server.log
$ sh versions.sh
client exit: 1
tlsv1 alert protocol version
SSL alert number 70
--- server saw
unsupported protocol
The server rejects the client’s first message, ClientHello, because it offers nothing the server accepts,
and sends alert 70, protocol version. No certificate was sent and no keys were agreed. This is how
servers retire old versions. TLS 1.0 and 1.1 are formally deprecated (RFC 8996). A TLS 1.2 minimum is
the common floor today, and TLS 1.3 only is fine when you control every client.
The same checks from application code
Here’s what your code should do. Python’s ssl.create_default_context() verifies the chain and the
hostname, and raises if either fails.
# py_client.py
import socket, ssl, subprocess, time
srv = subprocess.Popen(["openssl", "s_server", "-accept", "8443", "-www", "-quiet",
"-cert", "server.crt", "-key", "server.key"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
while subprocess.run(["openssl", "s_client", "-connect", "localhost:8443"],
input=b"", capture_output=True).returncode:
time.sleep(0.1) # wait until the server answers
def connect(ctx: ssl.SSLContext, host: str) -> str:
try:
with socket.create_connection(("localhost", 8443)) as raw:
with ctx.wrap_socket(raw, server_hostname=host) as tls:
return f"{tls.version()} {tls.cipher()[0]}"
except ssl.SSLCertVerificationError as e:
return f"refused: {e.verify_message}"
print("system CAs, localhost: ", connect(ssl.create_default_context(), "localhost"))
lab = ssl.create_default_context(cafile="ca.crt")
print("lab CA, localhost: ", connect(lab, "localhost"))
print("lab CA, bank.example: ", connect(lab, "bank.example"))
srv.terminate()
$ python py_client.py
system CAs, localhost: refused: unable to get local issuer certificate
lab CA, localhost: TLSv1.3 TLS_AES_256_GCM_SHA384
lab CA, bank.example: refused: Hostname mismatch, certificate is not valid for 'bank.example'.
With only the system CAs, the lab certificate is refused. That’s correct. With the lab CA passed as
cafile, it’s trusted by this one context, and a wrong hostname is still refused. That’s the right way
to trust a private CA: pass it to the client that needs it. Don’t add it to the system store, and never
use ssl._create_unverified_context(), verify=False or CERT_NONE. Those turn every failure above into
a silent success.
Practice
- Measure the cost of post-quantum key exchange. Connect once with the default groups and once with
-groups X25519, and compare the group and the handshake byte counts.
Solution
# groups.sh
rm -f server.log
openssl s_server -accept 8443 -cert server.crt -key server.key \
-www -naccept 2 > server.log 2>&1 &
until grep -q ACCEPT server.log 2>/dev/null; do sleep 0.1; done
for groups in X25519MLKEM768:X25519 X25519; do
echo | openssl s_client -connect localhost:8443 -servername localhost \
-CAfile ca.crt -groups "$groups" 2>&1 |
grep -E "^Negotiated|Temp Key|^SSL handshake has read"
done
$ sh groups.sh
Negotiated TLS1.3 group: X25519MLKEM768
SSL handshake has read 1907 bytes and written 1583 bytes
Peer Temp Key: X25519, 253 bits
SSL handshake has read 840 bytes and written 397 bytes
The hybrid group adds about 1,070 bytes from the server and 1,190 from the client. Those are the ML-KEM
public key and ciphertext, which are much larger than X25519’s 32-byte shares. With plain X25519, OpenSSL
reports Peer Temp Key instead. The extra kilobyte is the price of protecting today’s traffic against
“record now, decrypt later” with a future quantum computer, and browsers have decided it’s worth paying.
- Enforce a minimum version from Python. Start a server that only speaks TLS 1.2, then connect from
Python with
minimum_versionset to TLS 1.2 and then to TLS 1.3.
Solution
# py_min_version.py
import socket, ssl, subprocess, time
srv = subprocess.Popen(["openssl", "s_server", "-accept", "8443", "-www", "-quiet",
"-tls1_2", "-cert", "server.crt", "-key", "server.key"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
while subprocess.run(["openssl", "s_client", "-connect", "localhost:8443"],
input=b"", capture_output=True).returncode:
time.sleep(0.1)
for minimum in [ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3]:
ctx = ssl.create_default_context(cafile="ca.crt")
ctx.minimum_version = minimum
try:
with socket.create_connection(("localhost", 8443)) as raw:
with ctx.wrap_socket(raw, server_hostname="localhost") as tls:
print(f"min {minimum.name}: {tls.version()} {tls.cipher()[0]}")
except ssl.SSLError as e:
print(f"min {minimum.name}: {type(e).__name__} {e.reason}")
srv.terminate()
$ python py_min_version.py
min TLSv1_2: TLSv1.2 ECDHE-ECDSA-AES256-GCM-SHA384
min TLSv1_3: SSLError TLSV1_ALERT_PROTOCOL_VERSION
TLS 1.2 cipher-suite names spell out every part: ECDHE key exchange, an ECDSA certificate, AES-256-GCM and
SHA-384. TLS 1.3 names only the AEAD and hash, because key exchange and signatures are negotiated
separately. With a TLS 1.3 minimum, the server rejects the hello with alert 70, as in versions.sh. This
time it’s the client’s version range that excludes the server’s.
- Require client certificates (mutual TLS). Issue a
clientAuthcertificate forbuild-agent-7from the lab CA, starts_serverwith-Verify 1 -CAfile ca.crt, and connect once without and once with the client certificate.
Solution
# mtls.sh -- the server now demands a client certificate from the lab CA
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out client.key
openssl req -new -key client.key -subj "/CN=build-agent-7" -out client.csr
printf 'extendedKeyUsage=clientAuth\nbasicConstraints=CA:FALSE\n' > client.ext
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -days 7 \
-extfile client.ext -out client.crt 2>/dev/null
rm -f server.log
openssl s_server -accept 8443 -cert server.crt -key server.key -www \
-Verify 1 -CAfile ca.crt -naccept 2 > server.log 2>&1 &
until grep -q ACCEPT server.log 2>/dev/null; do sleep 0.1; done
for args in "" "-cert client.crt -key client.key"; do
echo "--- client args: ${args:-none}"
printf 'GET / HTTP/1.0\r\n\r\n' | openssl s_client -connect localhost:8443 \
-servername localhost -CAfile ca.crt -quiet $args 2>&1 |
grep -oE "tlsv13 alert [a-z ]+|HTTP/1.0 200 ok"
done
$ sh mtls.sh
--- client args: none
tlsv13 alert certificate required
--- client args: -cert client.crt -key client.key
HTTP/1.0 200 ok
Lowercase -verify asks for a client certificate, and capital -Verify requires one. Without one, the
TLS 1.3 server sends the certificate required alert, which is new in TLS 1.3. With a certificate
from the lab CA, the request gets HTTP/1.0 200 ok, the status line from s_server -www. mTLS
authenticates both ends with the same CA machinery, which is why service meshes use it between internal
services.