Cryptography · Protocol Construction

Transcript Hashing and Handshake Binding in TLS 1.3

How a running hash over handshake messages commits each flight to the ones before it, which secrets consume the digest, and what breaks when a message is left out of it.

A handshake is a conversation in which the parties agree on secrets before they have any secret with which to authenticate the conversation. Everything exchanged while the channel is still public has to be bound together afterwards, so that an attacker who edits, drops, reorders, or replays one flight cannot make the two endpoints believe they agreed on something they did not. TLS 1.3 does this with a single accumulator: a hash over the concatenation of every handshake message, in order, computed incrementally as the handshake proceeds. That value is called the transcript hash, and it is the spine of the protocol.

The design decision behind it is worth stating before the mechanics. The alternative would be for each party to keep the full byte stream of the handshake and hash it once at the end. That works for a small handshake but not for a general one: the transcript has no a priori bound, it is needed at several intermediate points, and it must be recomputed from a prefix in more than one place. An accumulator that is updated per message turns an unbounded storage problem into a fixed-size state of a few dozen bytes, at the cost of never being able to look backwards.

A digest that accumulates instead of summarizing

Each handshake message begins with a four-byte header: one byte of message type and a three-byte length in network order, so a single message cannot exceed $2^{24}-1$ bytes. The transcript is the concatenation of those headers and their bodies, in the order sent, with no separators and no re-encoding. ChangeCipherSpec records are explicitly excluded: they are compatibility artefacts sent outside the handshake sequence and are not handshake messages, so a middlebox that injects one does not disturb the digest.

The accumulator is therefore H(0) = empty, H(i) = Hash(H(i-1) || message_i). The hash function is fixed by the negotiated cipher suite, and TLS 1.3 permits only SHA-256 and SHA-384 among the suites it defines, so the digest is 32 or 48 bytes. There is exactly one transcript per connection, and both endpoints must compute the identical value at every point where it is consumed. Any disagreement about ordering, about whether a message was included, or about which version’s encoding was used produces divergent digests and the handshake fails at the first check rather than silently proceeding.

One wrinkle breaks the naive append-only model. A HelloRetryRequest is a server message that asks the client to send a second ClientHello with different key share parameters, which means the transcript must not simply contain both ClientHellos. Instead, when a HelloRetryRequest is sent, the running transcript is replaced by a synthetic message: type message_hash (254), a three-byte length equal to the hash output length, and the hash of the first ClientHello. The handshake then continues from that synthetic message. The substitution keeps the transcript finite and, more importantly, keeps the two ClientHellos from being separately meaningful: only the second one is in the final digest, and only through a prefix that already committed to the first.

Where the digest is consumed

The transcript hash appears in five distinct roles, and conflating them is a common source of design error.

Traffic key derivation. Every traffic secret is derived with Derive-Secret(Secret, Label, Messages), which expands HKDF-Expand-Label(Secret, Label, Transcript-Hash(Messages), Hash.length). The client and server handshake traffic secrets both cover ClientHello through ServerHello, so they are fixed the moment the server’s first flight arrives. Application traffic secrets cover the whole handshake up to server Finished.

Finished messages. Each side sends a MAC over the transcript under a dedicated key derived from its traffic secret. This is the point where the digest stops being a public commitment and becomes an authenticator: the MAC key is secret, so only a party that derived the handshake secrets can produce a value that verifies.

CertificateVerify. The certificate holder signs a string consisting of 64 bytes of 0x20, a context string identifying the protocol and role, a single zero byte, and the transcript hash. The zero byte separates the fixed context from the variable-length digest so that no two distinct (context, digest) pairs can produce the same signed byte string, which is the same prefix-freedom argument used in domain separation for key derivation.

Pre-shared key binders. A client offering a PSK proves possession of it by MACing a truncated transcript, which is the subject of PSK binders and early data.

Exporters and resumption. The exporter master secret is derived from the master secret and the transcript through server Finished, and the resumption master secret through client Finished. Any application protocol that later wants to bind to this connection consumes one of these, which is what channel binding builds on.

Because the same digest feeds all five, a single missing message is not a local error. It shifts every derived secret, so the Finished MAC fails and the connection aborts. That failure mode is the design working as intended: the transcript is not an optimisation, it is the mechanism by which agreement is made checkable.

Rewinding, and why hash state has to be copyable

The append-only rule collides with a practical requirement: the protocol needs the digest at a prefix, and then continues appending. A PSK binder must be computed over a ClientHello that is truncated before the binders themselves, because the binder cannot cover itself. A HelloRetryRequest discards the first ClientHello. An implementation that has already fed the full message into the hash cannot subtract it again.

The standard resolution is that the hash object must expose a cheap snapshot. Every mainstream SHA-256 implementation carries a state of eight 32-bit words plus a 64-byte block buffer, so a copy is on the order of a hundred bytes and a few register moves. Protocols that need to fork the transcript at several points can therefore clone the context, feed the speculative bytes, and discard the clone.

import hashlib

class Transcript:
    """Running handshake transcript, forkable at message boundaries."""
    def __init__(self, name="sha256"):
        self._h = hashlib.new(name)

    def add(self, msg_type: int, body: bytes) -> None:
        header = bytes([msg_type]) + len(body).to_bytes(3, "big")
        self._h.update(header + body)

    def fork(self) -> "Transcript":
        t = Transcript.__new__(Transcript)
        t._h = self._h.copy()   # O(1)-sized state copy, not a rehash
        return t

    def digest(self) -> bytes:
        return self._h.digest()

A binder is then produced by forking the transcript before the binders list is appended, hashing the truncated ClientHello on the fork, and discarding the fork. The alternative, buffering the whole handshake and rehashing a prefix, costs $O(n)$ per fork and forces the implementation to retain every byte of a stream whose length the peer controls. The copyable-state design trades a slightly wider hash API for bounded memory.

The same reasoning explains why the transcript is not a Merkle tree. A tree would give $O(\log n)$ prefix proofs and no need for snapshots, but handshake messages arrive in strict sequence, never need independent verification, and the tree’s extra hashes cost more than the snapshot it replaces.

What the digest does not establish

Three limits are worth keeping separate from the mechanism.

First, the transcript hash is public and unkeyed. Anyone who observes the handshake can compute it, including an attacker. It is a commitment, not an authentication tag; the authentication is supplied later by signatures and Finished MACs over that commitment. An implementation that treats a matching transcript hash as evidence of peer identity has confused the two.

Second, the binding strength is that of the hash function against a targeted second-preimage, not against arbitrary collisions found offline. An attacker who must produce a different message sequence hashing to an already-fixed digest is doing second-preimage work, and for SHA-256 that is infeasible. The collision resistance requirement matters for the general claim that two handshakes cannot be made to share a digest, but the practical attack surface is the targeted one.

Third, the transcript says nothing about messages sent after the handshake, and nothing about whether the two endpoints interpreted the same bytes as the same semantics. A protocol layered on top that re-parses handshake-adjacent data, or that treats a negotiated parameter as optional, can still disagree with its peer. That is a parsing problem rather than a hashing one, and it belongs with unambiguous message framing.

One more detail deserves attention because it is easy to get wrong when implementing: the transcript covers handshake messages only. Records carrying application data, alerts, or post-handshake KeyUpdate messages are not handshake messages in this sense, though KeyUpdate is a handshake-typed message and does enter the transcript for the purposes of deriving the next generation of secrets. An implementation that feeds decrypted record payloads into the transcript, instead of the reconstructed handshake message stream, will produce a digest that happens to match for unfragmented flights and diverges for fragmented ones. The handshake layer reassembles first; the transcript sees messages, never records.

References

  1. RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 — IETF
  2. Cryptographic Extraction and Key Derivation: The HKDF Scheme — Hugo Krawczyk, CRYPTO 2010