---
title: 'The TLS Handshake'
source: 'https://academia.sh/en/courses/application-protocols/tls-handshake'
course: 'Application Layer Protocols'
language: en
updated: '2026-08-17T18:07:03+00:00'
license: 'CC BY-SA 4.0'
---

# The TLS Handshake

The handshake's round-trip budget, the round session resumption earns, and the 160 decisions the intermediary loses once the transport envelope closes.

Chain verification asked for no round trip, but it was not done in a vacuum. Before the
certificate reached the client, an exchange had to begin, the server had to present its
chain, and the two sides had to arrive at a shared secret. **TLS (Transport Layer Security)**
handshake does these three jobs inside a single setup.

This lesson's question has two parts. The first is budget: how many round trips setup
holds, who pays it, and how repeated payment is prevented. The second is the
course's real subject: once setup completes, the message enters a **transport envelope** and
the intermediary can no longer read what it says. What this does to the decision table
counted through the course is measured here.

## What the Handshake Carries

The handshake gathers everything that needs agreement into a small number of messages.

```text
full setup
  client -> server : hello, supported suites, client's key share
  server -> client: hello, selected suite, server's key share,
                    certificate chain, finished
  client -> server : finished, then application data

session resumption
  client -> server : hello, previous session's resumption identity
  server -> client: hello, finished
  client -> server : finished, then application data
```

This listing is not run; it shows what the steps carry. Four jobs sit side by side: choosing
the suite, exchanging key shares, presenting the certificate chain, and the two sides confirming
setup is done.

The four jobs are not independent: key shares cannot be interpreted before the suite is chosen,
and finished cannot be computed before key shares are exchanged. This dependency sets the order
of the steps; rounds can be merged but not swapped.

The order is a design decision. The certificate chain arrives with the server's
**first** response; the client makes no separate request to verify it. The previous
lesson's measurement is the counterpart: chain verification ran with zero round trips,
because everything it needed was already in hand. Resumption steps carry no
certificate chain — the client has already verified that chain once and stored the result with
the session.

## Choosing the Suite and Finishing

The first of the four jobs is a negotiation: the client declares the **cipher suites** it
supports, the server picks one. The suite decides, after setup, which symmetric
transform and digest function will be used; the division of labor from the first lesson
becomes concrete here.

The negotiation has a problem repeated in every lesson of this
course: the first two messages travel over a channel not yet protected, so a layer on the path
can read the client's list. The specification's answer is the **finished** message at the
last step of setup: it is computed over the digest of **all** handshake messages sent
and received up to that point, using the key that comes out of setup. Both sides
check the other's finished; if the handshake listing one side saw differs from the
other's, finished does not hold and setup does not complete.

This is encountered for the first time in the course. Every decision of the
intermediary rested on a **claim**, and nothing verified it. Finished verifies the claim in the
negotiation, making it checkable afterward. The difference is
who checks it — not the intermediary, the **endpoints**.

One thing finished does not check is the list's **content**: it verifies both sides saw the
same list, not that no weak suite is on it. Keeping weak
suites on the list is therefore a **configuration flaw**: the choice was made, the
negotiation was verified, and setup still lands on a bad suite. *Narrowing:* if the list
itself is narrowed — only acceptable suites are declared — the set the negotiation can land on
also narrows and verification becomes meaningful. This is a symptom, and what is written here
is what is wrong.

## The Budget Is Paid Per Connection

Setup's round is written not to the exchange but to the **connection**: forty exchanges in a
single connection pay setup once; in forty separate connections, forty times.

The measurement below **models** full setup as two round trips and session resumption as one.
These are model values chosen within the lesson; they differ across versions of the
specification family, and the lesson makes no version claim. What is
measured is not the numbers themselves but **how they grow with connection count.**

```python
EXCHANGES = 40
FULL_ROUND = 2          # full setup budget modeled within the lesson
RESUME_ROUND = 1        # session resumption budget modeled within the lesson

print(f"{'connections':>12s} {'setup without resumption':>25s} {'setup with resumption':>22s} "
      f"{'rounds saved':>13s} {'total rounds':>13s} {'per exchange':>13s}")
for connections in (40, 8, 4, 1):
    without = connections * FULL_ROUND
    with_resumption = FULL_ROUND + (connections - 1) * RESUME_ROUND
    total = with_resumption + EXCHANGES
    print(f"{connections:12d} {without:25d} {with_resumption:22d} {without - with_resumption:13d} "
          f"{total:13d} {total / EXCHANGES:13.3f}")
```

```
 connections  setup without resumption  setup with resumption  rounds saved  total rounds  per exchange
          40                        80                     41            39            81         2.025
           8                        16                      9             7            49         1.225
           4                         8                      5             3            45         1.125
           1                         2                      2             0            42         1.050
```

In the regime that opens forty separate connections, setup without resumption holds **80
rounds**; since the forty exchanges themselves hold 40 rounds, opening a connection is **twice**
as expensive as doing the work. **Session resumption** gives back 39 rounds on this row,
bringing the total to 81 — 2.025 rounds per exchange.

Two observations read the table. First, the round resumption earns **depends on
the connection count**: at one persistent connection the gain is zero, because there is nothing
to resume. Second, resumption does not eliminate setup — it only **makes it cheaper**. Even on
the forty-connection row, the per-exchange figure stays above 1.000; the only way to zero
is keeping the connection persistent.

Resumption earns something beyond rounds too, tying back to the previous lesson: in a
resumed session the certificate chain is not re-presented, so chain verification is not redone
either. A decision taken once is not retaken for as long as the session lives. Its cost sits in
the same place: information about the chain **ages**, and the session does not see it.

## Once the Envelope Closes

Once setup finishes, the application message enters the envelope. The intermediary sees the
message arrive, sees its length, sees which host it is going to — but it **cannot see** the
method, the path, the private marker, or the validator.

The decision table counted through the course is now measured in two regimes. In the `open`
regime the intermediary reads all five fields; in the `envelope` regime, only the host name.
The oracle — the truth known because we produced the scenario — does not change. Forty
exchanges and five decisions per exchange is a set of 200 decisions; the smallest difference
measurable in this set is 1/200 = 0.0050.

Assumptions of this lesson's two measurements:

- **GT13** — In the budget table, full setup is **modeled within the lesson** as two round
  trips, session resumption as one; both values are chosen model values and make no
  specification version claim.
- **GT14** — Setup's round is written to the connection, an exchange's round to the exchange:
  forty exchanges hold forty rounds, and in the resumed regime the first connection pays full
  setup, the rest pay resumption.
- **GT15** — In the decision table the course's forty exchanges are reproduced from the same
  seed; five decisions per exchange are counted and the oracle does not change when the regime
  changes.
- **GT16** — Two regimes are compared: in the `open` regime the intermediary reads the method,
  the path, the host name, the private marker, and the validator; in the `envelope` regime,
  only the host name. The host name is outside the envelope because it is declared in setup's
  first message, before a shared secret exists yet.
- **GT17** — The intermediary sits on the path and looks only at what stays outside the
  envelope; every decision it cannot get is a **round debt** — one more round is thrown at the
  server on that exchange.
- **GT18** — The bytes column is a count of the fields the intermediary can read, not the real
  message's length; the measurement is not a network measurement.

```python
SEED = 20260809
SAFE = {"GET", "HEAD"}
IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE"}
METHODS = ["GET", "GET", "GET", "HEAD", "POST", "PUT", "DELETE"]
PATHS = ["/measurement/north", "/measurement/slope", "/summary", "/record", "/session"]
DECISIONS = ("cacheable", "shareable", "fresh", "repeatable", "routable")
VISIBLE = {"open": ("method", "path", "host", "private_marker", "validator"),
           "envelope": ("host",)}


def generator(seed: int):
    d = seed % 2147483646 + 1

    def r(n: int) -> int:
        nonlocal d
        d = (d * 48271) % 2147483647
        return d % n
    return r


def exchanges(count: int = 40) -> list[dict]:
    r, items = generator(SEED), []
    for i in range(count):
        method, path = METHODS[r(7)], PATHS[r(5)]
        private = path == "/session" or r(5) == 0
        changed = r(3) == 0
        items.append({"no": i + 1, "method": method, "path": path,
                       "private": private, "changed": changed,
                       "private_marker": private and r(4) != 0,
                       "stamp_skipped": changed and r(6) == 0})
    return items


def oracle(a: dict) -> dict:
    return {"cacheable": a["method"] in SAFE and not a["private"],
            "shareable": not a["private"],
            "fresh": not a["changed"],
            "repeatable": a["method"] in IDEMPOTENT,
            "routable": True}


def message(a: dict, visible: tuple) -> dict:
    full = {"method": a["method"], "path": a["path"], "host": "station.example",
             "private_marker": a["private_marker"],
             "validator": a["changed"] and not a["stamp_skipped"]}
    return {k: v for k, v in full.items() if k in visible}


def intermediary(i: dict) -> dict:
    k = {}
    if "method" in i:
        k["repeatable"] = i["method"] in IDEMPOTENT
    if "private_marker" in i:
        k["shareable"] = not i["private_marker"]
        if "method" in i:
            k["cacheable"] = i["method"] in SAFE and not i["private_marker"]
    if "validator" in i:
        k["fresh"] = not i["validator"]
    if "host" in i:
        k["routable"] = True
    return k


def bytes_seen(i: dict) -> int:
    return sum(len(k) + len(str(v)) + 4 for k, v in i.items())


def measure(name: str) -> tuple:
    correct = wrong = unavailable = extra_round = total_bytes = 0
    for a in exchanges():
        truth, given = oracle(a), intermediary(message(a, VISIBLE[name]))
        missing = False
        for decision in DECISIONS:
            if decision not in given:
                unavailable += 1
                missing = True
            elif given[decision] == truth[decision]:
                correct += 1
            else:
                wrong += 1
        extra_round += 1 if missing else 0
        total_bytes += bytes_seen(message(a, VISIBLE[name]))
    return correct, wrong, unavailable, extra_round, total_bytes


print(f"{'regime':<8s} {'correct':>7s} {'wrong':>7s} {'unavailable':>11s} "
      f"{'extra round':>13s} {'bytes':>6s}")
for name in ("open", "envelope"):
    print(("{:<8s} {:7d} {:7d} {:11d} {:13d} {:6d}").format(name, *measure(name)))

print()
print(f"{'decision':<20s} {'open':>8s} {'envelope':>10s}")
for decision in DECISIONS:
    counts = [sum(decision in intermediary(message(a, VISIBLE[name])) for a in exchanges())
              for name in ("open", "envelope")]
    print(f"{decision:<20s} {counts[0]:8d} {counts[1]:10d}")
```

```
regime   correct   wrong unavailable   extra round  bytes
open         195       5           0             0   3847
envelope      40       0         160            40    920

decision                 open   envelope
cacheable                  40          0
shareable                  40          0
fresh                      40          0
repeatable                 40          0
routable                   40         40
```

## Where the Hundred Sixty Decisions Went

In the `open` regime the intermediary took 200 of 200 decisions and never returned to the
server; 195 were correct, 5 wrong. In the `envelope` regime **160 decisions become
unavailable.** These decisions did not disappear; because the intermediary could not get them,
they **moved to the endpoints.** The decision-maker was not just the two endpoints before; now
it is.

The second table shows exactly where this 160 was gathered from. Four of the five decisions —
`cacheable`, `shareable`, `fresh`, `repeatable` — are taken in all forty of the forty exchanges
in the open regime and in none of them in the envelope regime: four decisions times forty
exchanges, 160. The one decision still standing is `routable`, because its only support, the
host name, stays outside the envelope. The loss is not spread evenly, it is **sharp**: hiding
one field does not reduce the decision tied to that field, it zeroes it.

The extra-round column shows the price of this: in **all forty** of the 40 exchanges the
intermediary is missing at least one decision, meaning all forty throw one more round at the
server. The visibility the intermediary loses turns directly into a round debt.

The bytes column looks reversed at first glance: it drops from 3847 to 920. The message did not
shrink; only the fields the intermediary can **read** shrank. This is the reverse reading of the
course's second claim — there, adding a field did not earn a decision; here, hiding a field
earns bytes but costs decisions.

The real line is the wrong column: **0 in the envelope regime.** None of the remaining 40
decisions are wrong. The reason is direct: the only decision left is
`routable`, and that decision looks only at the host name; since the host name stays outside
the envelope, it is always correct. The 5 wrong decisions in the open regime came from
the places where the intermediary **believed a claim** — a marker the server forgot to set, and
a change the validator missed. The intermediary cannot believe a field it cannot read.

The sentence that follows is the course's third claim, not a description of a flaw:
**fewer decisions, but none wrong.** The envelope regime lets the intermediary take 40 of 200
decisions; in return it drops the intermediary's error surface to zero. Which is preferable is
not a measurement question but a design decision.

## The Remaining Surface and Its Narrowings

The envelope is a record format: the application message is split into pieces, each wrapped with
its length and carried that way. This draws the boundary of what the intermediary sees: it sees
the record boundaries and lengths — because it cannot forward the message without seeing
them — but not what is inside the record. The zeroing of the 160 in the decision table is the
result of this distinction: what is left in the intermediary's hand is the message's
**container**, not its content.

The envelope does not close everything. What the intermediary still sees, and the narrowing for
each:

- **Host name.** Declared in setup's first message, before a shared secret exists yet; in the
  measurement it is `routable`'s only support. *Narrowing:* mechanisms exist where the name is
  also folded into setup's protected part; in that case the table's last row also moves to
  unavailable and the intermediary loses the routing decision too.
- **Certificate chain.** In full setup the chain is presented in the open and carries the
  subject's name. *Narrowing:* in resumed sessions the chain is never presented; as the
  connection count drops, this surface shrinks too.
- **Message length and timing.** The envelope closes the body, it does not close its length or
  arrival moment. *Narrowing:* padding and merging push the length into a band.

There is exactly one way for an intermediary to regain the 160 decisions, and it is not a
loophole but a placement decision: the intermediary **terminates the envelope itself.** Then
there are two separate setups — one between the client and the intermediary, one between the
intermediary and the server — and the intermediary in the middle returns to the open regime,
regaining the 200 decisions in the table's first row. The price is not read from the table but
from the definition: the endpoints no longer set up with each other, both now
set up with the intermediary, and the chain the client verifies is the intermediary's chain,
not the server's. *Narrowing:* such an intermediary's anchor must be separately placed on the
client; until it is, chain verification falls and the decision loss stays where
it was.

There is also a common **configuration flaw**: completing setup and passing through without
verifying the certificate chain. The gain is local and was counted in the previous lesson's
measurement — not a single round. What is paid is not knowing **whom** the bond the
envelope protects was made with. What this lesson states is what is wrong; how this is done is
not written.

## Summary

- The handshake gathers suite selection, key-share exchange, certificate-chain presentation,
  and mutual finishing into a single setup; because the chain arrives with the first response,
  its verification needs no separate round.
- The suite negotiation happens on a still-unprotected channel; because the finished message
  stands on the digest of the whole handshake, the negotiation is checked by the endpoints, not
  the intermediary.
- Setup's budget is written to the connection, not the exchange: 80 rounds without resumption
  at 40 connections, 41 with resumption; 2 rounds at one persistent connection.
- Session resumption earns 39 rounds in the forty-connection regime and does not repeat chain
  verification; its gain is zero at one connection.
- Once the envelope closes, 160 of 200 decisions move from the intermediary to the endpoints,
  all forty of the forty exchanges incur a round debt, and the bytes the intermediary reads
  drop from 3847 to 920.
- **None of the remaining 40 decisions are wrong**; the 5 wrong decisions in the open regime
  came from where the intermediary believed a claim, and the intermediary cannot believe a
  field it cannot read.

## Next Step

Chain verification ran locally, the handshake paid its budget per connection, and once the
envelope closed 160 decisions moved to the endpoints. One thing alone stayed unmeasured: the
chain the client verified was valid **at the moment** it was verified. A certificate
has a start and an end, it can be revoked between them, and revocation cannot be written into
the document. The next lesson counts the certificate's life cycle and what revocation adds to
the round budget: the chain verified without asking; revocation cannot be known without asking.
