Skip to main content
RunBook Academy

← All labs in Secrets, PKI & Certificates

Lab Β· intermediate Β· ~45 min

Lab 10: Inspect a TLS handshake

C Β· SimulationB Β· Nested virtualisation

Objectives

  • Read the certificate chain, negotiated protocol and negotiated cipher from an openssl s_client transcript
  • Explain what a TLS 1.3 cipher suite name does and does not encode, and contrast it with a TLS 1.2 suite name
  • Demonstrate that Server Name Indication selects the certificate and travels in clear text
  • Separate what a server presented from what a client concluded, and recognise a client-side refusal that looks like a server policy

Objective

openssl s_client is the tool most engineers reach for and the tool fewest read carefully. Its output mixes three separate things into one screen: what the server put on the wire, what the two ends negotiated, and what your local client concluded about all of it. Confusing those three is how an afternoon disappears into the wrong hypothesis.

This lab takes them apart. You will run a correctly configured service, which makes it a reference rather than a puzzle, and then read the transcript line by line: the chain and its ordering, the protocol version, the cipher suite and what its name actually encodes, and the verification verdict that depends entirely on your own trust configuration rather than on anything the server did.

Then you will find the seams. Server Name Indication selects which certificate the server offers, and it goes out in clear text before any encryption exists. The certificate does not authenticate anyone by itself; possession of the matching private key does, and you will watch a server refuse to start when that possession is absent. Finally you will meet a refusal that looks like a server policy and is not, which is the most quietly misleading result s_client produces.

Architecture

One nginx container listens on port 443, published to the host on 8450, with two server blocks sharing the socket. Each block holds a different certificate. Which one the server sends is decided by the name the client puts in the clear-text portion of its first message, before any key exchange has happened.

flowchart TD
    C["openssl s_client\nClientHello, cleartext"] -- "server_name: app.lab.example" --> N["rbpki-web-10\none socket, two server blocks"]
    C -- "server_name: api.lab.example" --> N
    C -- "no server_name" --> N
    N -- "app certificate" --> R1["chain, protocol, cipher"]
    N -- "api certificate" --> R2["chain, protocol, cipher"]
    N -- "default_server certificate" --> R3["chain, protocol, cipher"]
    R1 --> V{"client applies\nits own trust"}
    R2 --> V
    R3 --> V

The diagram splits the handshake at the place where responsibility changes hands. Everything to the left of the server is a request in clear text. Everything the server returns is a fact about the server. The verdict at the bottom is a fact about the client, and it is the only part of the picture that changes when you edit your own trust store. Keeping those three regions separate in your head is most of what this lab is for.

Requirements

  • OpenSSL 3.5.x, providing openssl s_client with -showcerts, -servername, -noservername, -tls1_2 and -tls1_3.
  • Docker with permission to run containers and publish two ports. The lab pulls nginx:1.29-alpine.
  • curl built against OpenSSL.
  • Host TCP ports 8450 and 8451 free. The second port is used by a container that is expected to fail to start.
  • No out-of-band access requirement. This lab does not touch SSH, the firewall, the primary interface, or /etc/fstab. It writes nothing to the system trust store and nothing to /etc/hosts.

Labs 4 and 5 built the two-tier certificate authority and issued the leaf for app.lab.example, and labs 6 to 9 deployed and broke it in various ways. This lab issues a second leaf so there is something to choose between. If you still have root.crt, srv-ca.crt and srv-ca.key, copy them into $LAB/pki and skip the authority half of Task 2.

Scenario

A colleague forwards a screenshot of an s_client run and asks whether the server is configured correctly. The screenshot contains a chain, a protocol line, a cipher name and a verification result, and the question cannot be answered from it, because two of those four lines describe the person who ran the command rather than the server they ran it against.

You are going to build the reference that lets you answer questions like that properly: a known-good endpoint, a transcript you have read end to end, and a clear account of which lines would change if the server changed and which would change if only your laptop did.

Tasks

Task 1 β€” Prepare the lab directory and record the starting state

LAB="$HOME/rbpki-lab-10"
rm -rf "$LAB"
mkdir -p "$LAB/pki" "$LAB/site" "$LAB/bad" "$LAB/html"
cd "$LAB"

{
  echo "--- containers before the lab"
  docker ps -a --format '{{.Names}}'
} > "$LAB/state.pre-lab"

docker rm -f rbpki-web-10 rbpki-badkey-10 2>/dev/null || true

echo 'lab ok' > "$LAB/html/index.html"
cat "$LAB/state.pre-lab"

Two container names are removed rather than one, because this lab deliberately starts a second container that will exit with an error. A failed container still exists and still holds its name, so Cleanup has to remove it explicitly.

Task 2 β€” Build two certificates and put them behind one socket

LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out root.key
openssl req -x509 -new -key root.key -sha256 -days 3650 \
  -subj "/O=RunBook Academy Lab/CN=RunBook Lab Root CA" \
  -addext "basicConstraints=critical,CA:TRUE,pathlen:1" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" -out root.crt

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out srv-ca.key
openssl req -new -key srv-ca.key -sha256 \
  -subj "/O=RunBook Academy Lab/CN=RunBook Lab Server Issuing CA" -out srv-ca.csr
cat > srv-ca.ext <<'EOF'
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=critical,keyCertSign,cRLSign
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always
EOF
openssl x509 -req -in srv-ca.csr -CA root.crt -CAkey root.key -CAcreateserial \
  -sha256 -days 1825 -extfile srv-ca.ext -out srv-ca.crt

# Two leaves, each naming exactly one host.
for NAME in app api; do
  openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$NAME.key"
  openssl req -new -key "$NAME.key" -sha256 \
    -subj "/CN=$NAME.lab.example" -out "$NAME.csr"
  {
    echo 'basicConstraints=critical,CA:FALSE'
    echo 'keyUsage=critical,digitalSignature,keyEncipherment'
    echo 'extendedKeyUsage=serverAuth'
    echo "subjectAltName=DNS:$NAME.lab.example"
    echo 'subjectKeyIdentifier=hash'
    echo 'authorityKeyIdentifier=keyid:always'
  } > "$NAME.ext"
  openssl x509 -req -in "$NAME.csr" -CA srv-ca.crt -CAkey srv-ca.key -CAcreateserial \
    -sha256 -days 90 -extfile "$NAME.ext" -out "$NAME.crt"
  cat "$NAME.crt" srv-ca.crt > "$NAME-fullchain.pem"
done

# A spare key belonging to nothing, used in Task 6.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out orphan.key
chmod 600 root.key srv-ca.key app.key api.key orphan.key
ls -1 ./*.crt ./*-fullchain.pem

The orphan.key is generated now and used later. It is a perfectly ordinary private key that happens to correspond to no certificate in the lab, which is exactly what makes it useful for demonstrating what a certificate is worth without its key.

Task 3 β€” Deploy both server blocks and read the presented chain

LAB="$HOME/rbpki-lab-10"

cat > "$LAB/site/default.conf" <<'EOF'
server {
    listen 443 ssl default_server;
    server_name app.lab.example;

    ssl_certificate     /etc/nginx/certs/app-fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/app.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    root /usr/share/nginx/html;
    index index.html;
}

server {
    listen 443 ssl;
    server_name api.lab.example;

    ssl_certificate     /etc/nginx/certs/api-fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/api.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    root /usr/share/nginx/html;
    index index.html;
}
EOF

docker run -d --name rbpki-web-10 -p 8450:443 \
  -v "$LAB/site:/etc/nginx/conf.d:ro" \
  -v "$LAB/pki:/etc/nginx/certs:ro" \
  -v "$LAB/html:/usr/share/nginx/html:ro" \
  nginx:1.29-alpine
sleep 2
docker exec rbpki-web-10 nginx -t

cd "$LAB/pki"
openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example \
  -CAfile root.crt -showcerts </dev/null 2>/dev/null \
  | tee "$LAB/s_client-full.out" \
  | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' > "$LAB/served-chain.pem"

grep -E 'Certificate chain|^ [0-9] s:|^   i:' "$LAB/s_client-full.out"
openssl storeutl -noout -certs "$LAB/served-chain.pem"
Read-only / Safehost - the certificates the server chose to send, in the order it sent them
$ openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example -CAfile root.crt -showcerts
Certificate chain
0 s:CN=app.lab.example
 i:O=RunBook Academy Lab, CN=RunBook Lab Server Issuing CA
1 s:O=RunBook Academy Lab, CN=RunBook Lab Server Issuing CA
 i:O=RunBook Academy Lab, CN=RunBook Lab Root CA

Illustrative output

The s: line is the subject of that certificate and the i: line is the subject of whatever signed it. Reading downward, entry 0 is signed by entry 1, and entry 1 is signed by something called the Root CA which does not appear anywhere in the list. That absence is correct and deliberate: the server sends everything between its own certificate and a trust anchor, and stops.

Position 0 is the server’s own identity. A chain whose entry 0 names a certificate authority means the bundle was concatenated in the wrong order, which is a fault the server will never mention.

Task 4 β€” Read the negotiated protocol and cipher

LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"

grep -E '^New,|^Protocol|Verify return code' "$LAB/s_client-full.out"

# The same endpoint, forced down to TLS 1.2, to compare suite naming.
openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example \
  -CAfile root.crt -tls1_2 </dev/null 2>/dev/null \
  | grep -E '^New,|^Protocol|Verify return code'
Read-only / Safehost - what the two ends agreed on, and what this client concluded
$ openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example -CAfile root.crt
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3
Verify return code: 0 (ok)

Illustrative output

TLS_AES_256_GCM_SHA384 encodes two things and only two: the authenticated encryption algorithm used for record protection, and the hash used by the key schedule. It says nothing about key exchange and nothing about authentication, because in TLS 1.3 those are negotiated separately through the supported groups and signature algorithms extensions. There are five suites in the whole protocol version, and choosing between them is not a security decision of any consequence.

Now compare the TLS 1.2 run. A TLS 1.2 suite name packs four fields into one string: the key exchange, the authentication algorithm, the bulk cipher and the message authentication or hash. That is why TLS 1.2 configuration involved long ordered cipher lists and TLS 1.3 configuration does not, and why the two families of suite names cannot be mixed in one list. Read the suite your own run negotiated and name its four parts before moving on.

Task 5 β€” Watch Server Name Indication choose the certificate

LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"

{
  echo "=== servername app.lab.example"
  openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example </dev/null 2>/dev/null \
    | grep -m1 '^ 0 s:'
  echo "=== servername api.lab.example"
  openssl s_client -connect 127.0.0.1:8450 -servername api.lab.example </dev/null 2>/dev/null \
    | grep -m1 '^ 0 s:'
  echo "=== no servername at all"
  openssl s_client -connect 127.0.0.1:8450 -noservername </dev/null 2>/dev/null \
    | grep -m1 '^ 0 s:'
} | tee "$LAB/sni-comparison.txt"

Three connections to the same address and the same port return two different certificates and a default. The server is not guessing from the destination address; there is only one. It is reading a name the client wrote into its very first message, in clear text, before any key exchange occurred.

Two consequences follow directly. First, omitting -servername when you debug a multi-tenant endpoint gets you the default server’s certificate and a mismatch error that describes a certificate nobody intended you to see. Second, the name is observable: anyone on the network path learns which service you are connecting to even though they cannot read a byte of the exchange.

Task 6 β€” Prove that the certificate alone authenticates nobody

LAB="$HOME/rbpki-lab-10"

# The public certificate anyone can obtain from the wire, paired with a
# private key that does not belong to it.
cat > "$LAB/bad/default.conf" <<'EOF'
server {
    listen 443 ssl default_server;
    server_name app.lab.example;

    ssl_certificate     /etc/nginx/certs/app-fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/orphan.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    root /usr/share/nginx/html;
    index index.html;
}
EOF

docker run -d --name rbpki-badkey-10 -p 8451:443 \
  -v "$LAB/bad:/etc/nginx/conf.d:ro" \
  -v "$LAB/pki:/etc/nginx/certs:ro" \
  -v "$LAB/html:/usr/share/nginx/html:ro" \
  nginx:1.29-alpine
sleep 3

docker ps -a --filter name=rbpki-badkey-10 --format '{{.Names}} {{.Status}}'
docker logs rbpki-badkey-10 2>&1 | tail -3

The container exits immediately. Read the log lines it left: nginx reports a failure loading the private key and names a mismatch between the key and the certificate. The server would not even have reached the point of offering a handshake, because it cannot sign anything with a key that does not correspond to the certificate it is holding.

That is the practical form of the authentication argument. The certificate for app.lab.example is public, you extracted a copy of it from the wire two tasks ago, and holding it gets an attacker precisely nowhere. What matters is the private key, which never appears in any handshake, and the CertificateVerify signature that proves the server has it.

Task 7 β€” A refusal that looks like server policy and is not

LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"

# Ask this client to offer TLS 1.1.
openssl s_client -tls1_1 -connect 127.0.0.1:8450 \
  -servername app.lab.example </dev/null 2>&1 | head -3
Read-only / Safehost - a TLS 1.1 attempt that never reached the network
$ openssl s_client -tls1_1 -connect 127.0.0.1:8450 -servername app.lab.example
error:0A0000BF:SSL routines:tls_setup_handshake:no protocols available

Illustrative output

Read that string carefully, because it is the single most misinterpreted result in TLS debugging. tls_setup_handshake is a local function. A modern OpenSSL build will not offer TLS 1.1 at its default security level, so the request was refused before a packet left the machine. Nothing here tells you anything at all about what the server would have done.

Presenting this output as proof that a server rejects TLS 1.1 is a false claim, and it is a claim that appears in audit evidence regularly. If you need to know a server’s floor, the client must actually be capable of offering the version in question. Lowering the client’s security level with -cipher 'DEFAULT@SECLEVEL=0' alongside -tls1_1 makes this build offer it, and the answer then arrives as a TLS alert from the server naming a protocol version problem, which is a genuine server-side statement. That option weakens only the client process for the duration of one diagnostic command, it must never appear in a server configuration, and what it disables is the minimum-strength policy that normally stops the client negotiating obsolete parameters.

LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"

{
  for VERSION in tls1_2 tls1_3; do
    printf '%s -> ' "$VERSION"
    openssl s_client "-$VERSION" -connect 127.0.0.1:8450 \
      -servername app.lab.example -CAfile root.crt </dev/null 2>/dev/null \
      | grep -m1 '^Protocol' || echo "no handshake"
  done
  echo "tls1_1 -> attempted below, read whether the refusal is local or remote"
  openssl s_client -tls1_1 -connect 127.0.0.1:8450 \
    -servername app.lab.example </dev/null 2>&1 | head -2
} | tee "$LAB/protocol-matrix.txt"

The matrix is the deliverable an auditor should be given, and it is honest about its own limits: two versions this client could offer and one it could not. Recording the distinction is the difference between an evidence file and a misleading one.

Task 8 β€” Capture the handshake report

LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"

{
  echo "=== endpoint"
  echo "127.0.0.1:8450 servername app.lab.example"
  echo
  echo "=== chain presented by the server"
  grep -E 'Certificate chain|^ [0-9] s:|^   i:' "$LAB/s_client-full.out"
  echo
  echo "=== certificates counted on the wire"
  openssl storeutl -noout -certs "$LAB/served-chain.pem" | tail -1
  echo
  echo "=== negotiated, and this client's verdict"
  grep -E '^New,|^Protocol|Verify return code' "$LAB/s_client-full.out"
  echo
  echo "=== the same connection with no trust anchor supplied"
  openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example \
    -no-CAfile -no-CApath -no-CAstore </dev/null 2>/dev/null \
    | grep -E 'Verify return code'
} > "$LAB/handshake-report.txt"

cat "$LAB/handshake-report.txt"
ls -l "$LAB/handshake-report.txt" "$LAB/served-chain.pem" \
      "$LAB/sni-comparison.txt" "$LAB/protocol-matrix.txt"

The last section of the report is the point of the whole lab. The same server, the same certificate and the same negotiation produce a different Verify return code purely because the client was told to use no trust anchors. Every other line in the report is a fact about the server; that one is a fact about whoever ran the command.

Validation

  • openssl storeutl -noout -certs served-chain.pem reports two certificates, and the first entry under Certificate chain names CN=app.lab.example rather than a certificate authority.
  • The transcript contains Protocol: TLSv1.3 and a cipher line naming a suite whose name begins TLS_, and ends with Verify return code: 0 (ok).
  • The -tls1_2 run reports Protocol: TLSv1.2 and a suite name containing a key exchange and an authentication component, confirming the two naming conventions differ.
  • sni-comparison.txt shows CN=app.lab.example for the first name, CN=api.lab.example for the second, and the default server’s certificate when no name is sent. Three connections to one socket must not return one certificate.
  • docker ps -a --filter name=rbpki-badkey-10 shows the container exited, and its logs name a key and certificate mismatch. A pass here is the container failing.
  • The -tls1_1 attempt prints error:0A0000BF:SSL routines:tls_setup_handshake:no protocols available and produces no Protocol: line, and protocol-matrix.txt records it as a local refusal rather than a server rejection.
  • The final section of handshake-report.txt shows a non-zero Verify return code for the run with no anchors, against the same endpoint that returned zero earlier.
  • The four deliverables exist and are non-empty.

A failed validation is usually a missing redirect. If s_client appears to hang, it is waiting on standard input; every command here redirects from /dev/null for that reason. If all three SNI runs return the same certificate, the second server block was not loaded - docker exec rbpki-web-10 nginx -T prints the merged configuration and will show whether both blocks are present.

Expected Outcome

$HOME/rbpki-lab-10/
β”œβ”€β”€ bad/
β”‚   └── default.conf
β”œβ”€β”€ html/
β”‚   └── index.html
β”œβ”€β”€ pki/
β”‚   β”œβ”€β”€ api-fullchain.pem
β”‚   β”œβ”€β”€ api.crt
β”‚   β”œβ”€β”€ api.key
β”‚   β”œβ”€β”€ app-fullchain.pem
β”‚   β”œβ”€β”€ app.crt
β”‚   β”œβ”€β”€ app.key
β”‚   β”œβ”€β”€ orphan.key
β”‚   β”œβ”€β”€ root.crt
β”‚   β”œβ”€β”€ srv-ca.crt
β”‚   └── srv-ca.key
β”œβ”€β”€ site/
β”‚   └── default.conf
β”œβ”€β”€ handshake-report.txt
β”œβ”€β”€ protocol-matrix.txt
β”œβ”€β”€ s_client-full.out
β”œβ”€β”€ served-chain.pem
β”œβ”€β”€ sni-comparison.txt
└── state.pre-lab

You can now read an s_client transcript and say, line by line, which parts describe the server and which describe the machine you ran it on. That distinction is what makes the difference between producing evidence and producing a screenshot.

Troubleshooting

s_client hangs after printing the certificate. It is waiting for input to send to the server. Redirect from /dev/null or press Ctrl-D.

Every SNI run returns the same certificate. Only one server block loaded. Check with docker exec rbpki-web-10 nginx -T, and confirm the second block’s server_name and certificate paths are correct.

Verify return code is 0 even with -no-CAfile. The other two suppression flags are also needed; a default certificate directory or store is still being consulted. Pass -no-CAfile -no-CApath -no-CAstore together.

The -tls1_2 run fails with a handshake error rather than a protocol line. The server’s ssl_protocols no longer lists TLSv1.2. That is a genuine server-side refusal and looks quite different from the local error in Task 7, which is the comparison the task is making.

rbpki-badkey-10 starts successfully. The configuration references a matching key rather than orphan.key. Confirm the path inside bad/default.conf and remove the container before retrying, since the name is already taken by the previous attempt.

Cleanup

LAB="$HOME/rbpki-lab-10"

# 1. Stop and forget both containers, including the one that never ran.
docker rm -f rbpki-web-10 rbpki-badkey-10 2>/dev/null || true

# 2. Compare against the inventory recorded in Task 1.
cat "$LAB/state.pre-lab"
echo "--- containers now"
docker ps -a --format '{{.Names}}'

# 3. Remove the lab directory, keys included.
rm -rf "$LAB"

# 4. Assert the host is as you found it.
docker ps -a --format '{{.Names}}' | grep -cE '^rbpki-(web|badkey)-10$' || echo "containers gone"
test -d "$LAB" && echo "LAB DIRECTORY STILL PRESENT" || echo "lab directory gone"

The two container inventories must differ only by the removal of rbpki-web-10 and rbpki-badkey-10. The exited container is the one most often left behind, because it does not appear in a plain docker ps and is easy to believe was never created. Both assertions in step 4 must report the resource gone.

Production notes

  • Capture s_client transcripts into files rather than screenshots, and always record the flags used. A transcript without its command line cannot be interpreted, because the trust flags change the conclusion.
  • Probe multi-tenant endpoints with the name you care about every single time. An SNI-less probe against shared ingress reports on a certificate nobody deployed for you.
  • Version floors belong in the server configuration and are verified with a client capable of offering the version being tested. Anything else produces evidence about your workstation.
  • Pair every handshake check with a key-possession check at deployment time. The handshake proves possession only for the pair currently loaded, and a mismatched pair sitting on disk is invisible until a restart.

What You Learned

  • An s_client transcript contains three different kinds of statement. What the server sent, what the two ends negotiated, and what your client concluded, and only the first two are facts about the server.
  • A TLS 1.3 suite name encodes the AEAD and the hash, nothing else. Key exchange and authentication are negotiated by separate extensions, which is why there are only five suites.
  • Server Name Indication selects the certificate and is sent in clear text. Debugging without it produces a mismatch against a certificate that was never meant for you.
  • The certificate is public and authenticates nobody on its own. Possession of the private key, demonstrated by the CertificateVerify signature over the handshake transcript, is what authentication means.
  • Session keys come from an ephemeral exchange, not from the certificate. Recovering the server’s long-term key later does not decrypt a recorded session.
  • no protocols available is your own client refusing. Quoting it as proof of a server’s policy is a false claim, and probing a version floor requires a client that can actually offer that version.

Deliverables

  • Β· handshake-report.txt - the chain, protocol, cipher and verification result for one endpoint, in a form a reviewer can read
  • Β· served-chain.pem - the certificates the server presented, extracted from the wire
  • Β· sni-comparison.txt - the certificate returned for two different server names on the same socket, and for no name at all
  • Β· protocol-matrix.txt - which TLS versions this client could offer and what happened for each

Verification status

Last reviewed
2026-08-26
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.