Skip to main content
Cryptography · lesson 7 of 8
Cryptography intermediate Lesson 7 of 8

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

cryptography tls openssl https certificates python

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=1 is the lab CA, depth=0 the 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

  1. 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.

  1. Enforce a minimum version from Python. Start a server that only speaks TLS 1.2, then connect from Python with minimum_version set 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.

  1. Require client certificates (mutual TLS). Issue a clientAuth certificate for build-agent-7 from the lab CA, start s_server with -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.

Frequently Asked Questions

How do I test a TLS handshake locally?
Start a test server with openssl s_server -accept 8443 -cert server.crt -key server.key -www, then connect with openssl s_client -connect localhost:8443 -CAfile ca.crt. s_client prints the certificate chain, the negotiated protocol version, cipher suite and key-exchange group, and a final Verify return code where 0 means the certificate checked out.
Why does openssl s_client connect even when certificate verification fails?
s_client is a diagnostic tool, so by default it reports a verification error such as code 20 or 21 and then carries on with the connection anyway. Add -verify_return_error to make it abort like a real client. Browsers, curl and Python's ssl.create_default_context() abort on verification failure by default.
What does 'tlsv1 alert protocol version' mean?
It is TLS alert 70: the server and client have no protocol version in common, for example a client limited to TLS 1.2 talking to a server that only accepts TLS 1.3. The handshake stops before any certificate or key is exchanged. Fix it by enabling a shared version, preferably TLS 1.3, rather than re-enabling old ones like TLS 1.0.
What is X25519MLKEM768 in a TLS handshake?
X25519MLKEM768 is a hybrid key-exchange group that combines classical X25519 Diffie-Hellman with ML-KEM-768, a post-quantum key encapsulation mechanism standardised by NIST in FIPS 203. The session key stays safe as long as either one holds. OpenSSL 3.5 and current browsers offer it by default, and it adds roughly a kilobyte to each direction of the handshake.
What is mutual TLS (mTLS)?
In ordinary TLS only the server proves its identity with a certificate. In mutual TLS the server also requests a client certificate and verifies it against a CA it trusts, so both ends are authenticated. It is common between internal services and in service meshes. A TLS 1.3 server that requires a certificate rejects a client without one with the certificate required alert.