Lesson 11 / 21
TLS Handshake
The handshake is taken apart along its identity steps: each of six claims ties to a single step, when any of the four identity steps is turned off exactly one more of five fictional presentations gets accepted, and with no identity step at all all five of the five pass; full verification requires 11 items to hold at once, mutual verification requires 15, and at fleet scale it adds 17,552 items.
Published Updated
In this lesson
- Maps the handshake's identity steps to the claim each proves, and counts the items that must hold per step
- Measures, by running it, how many presentations pass unproven when a step is turned off
- Calculates the claim mutual verification adds, and its item cost per connection and at fleet scale
Contents
The previous three lessons measured the certificate’s fields, its chain, and its revocation status separately. None of these checks happens on its own; all of them come together inside a single exchange, while the connection is being established, in under a second. That exchange is the handshake.
The handshake is usually called “where the encrypted channel gets set up.” That definition is incomplete: the encrypted channel can be set up independent of identity, and when it is, what results is a secret channel where who is being talked to is unknown. This lesson takes the handshake apart not by encryption but by identity, and measures three things: which step proves which claim, which claim stays unproven when a step is skipped and how many presentations pass, and how many items the client also presenting an item adds. Configuration items and response headers are outside this lesson; they were measured in the Backend Security and Cloud Security courses.
- PK16. The handshake is modeled as four identity steps: chain verification, name check,
revocation check, item-possession signature. The items are fictional; signature generation, key
exchange, and digest verification really run with
node:crypto. - PK17. The item-possession signature is the other party signing the handshake digest with the private key that matches the certificate’s public key. The certificate is presented publicly; producing the signature requires the private key.
- PK18. Ephemeral keys are regenerated on every handshake, and the shared secret is derived from these ephemeral pairs; the certificate’s key never enters the shared secret.
- PK19. A presentation attempt is the other party presenting an item and signing the digest with a key. One of five fictional presentations is legitimate.
- PK20. A step turned off in a setting never runs at all; the claim that step proves is counted as unproven, and that step’s items drop from the count.
Steps and What They Prove
Each identity step makes a separate statement. The run below maps these statements to steps and counts the items each step requires to hold.
// step.mjs — the handshake's identity steps; items are fictional, signature and key generation are real. import { generateKeyPairSync, sign, verify, diffieHellman, createHmac } from "node:crypto"; const ed = () => generateKeyPairSync("ed25519"); export const ROOT = ed(), INTERMEDIATE = ed(), FOREIGN_INTERMEDIATE = ed(); const SERVER = ed(), BILLING = ed(), FOREIGN_LEAF = ed(), REVOKED = ed(), CLIENT = ed(); let serial = 7300; const register = (subject, altName, issuer, priv, pair, isAuthority = false) => { const k = { subject, altName, issuer, serial: serial++, isAuthority, public: pair.publicKey.export({ type: "spki", format: "der" }).toString("hex") }; k.signature = sign(null, body(k), priv); return k; }; export const body = (k) => Buffer.from(JSON.stringify([k.subject, k.altName, k.issuer, k.serial, k.isAuthority, k.public])); export const INTERMEDIATE_ITEM = register("measurement-intermediate", ["measurement-intermediate"], "org-root", ROOT.privateKey, INTERMEDIATE, true); const FOREIGN_ITEM = register("foreign-intermediate", ["foreign-intermediate"], "foreign-root", FOREIGN_INTERMEDIATE.privateKey, FOREIGN_INTERMEDIATE, true); export const ITEM = { valid: register("measurement-collector", ["measurement-collector"], "measurement-intermediate", INTERMEDIATE.privateKey, SERVER), billing: register("billing-service", ["billing-service"], "measurement-intermediate", INTERMEDIATE.privateKey, BILLING), foreign: register("measurement-collector", ["measurement-collector"], "foreign-intermediate", FOREIGN_INTERMEDIATE.privateKey, FOREIGN_LEAF), revoked: register("measurement-collector", ["measurement-collector"], "measurement-intermediate", INTERMEDIATE.privateKey, REVOKED), client: register("field-terminal-17", ["field-terminal-17"], "measurement-intermediate", INTERMEDIATE.privateKey, CLIENT), }; export const PRIVATE = { valid: SERVER.privateKey, billing: BILLING.privateKey, foreign: FOREIGN_LEAF.privateKey, revoked: REVOKED.privateKey, client: CLIENT.privateKey }; export const REVOKED_LIST = [ITEM.revoked.serial]; export const INTERMEDIATE_REGISTRY = { "measurement-intermediate": INTERMEDIATE_ITEM, "foreign-intermediate": FOREIGN_ITEM }; // A handshake run: ephemeral keys are generated, the shared secret is derived, the digest is signed. export function handshake(presentation, targetName, setting) { const clientEphemeral = generateKeyPairSync("x25519"), serverEphemeral = generateKeyPairSync("x25519"); const digest = Buffer.concat([Buffer.from("client-hello|server-hello|"), clientEphemeral.publicKey.export({ type: "spki", format: "der" }), serverEphemeral.publicKey.export({ type: "spki", format: "der" })]); const presented = ITEM[presentation.item]; const possessionSignature = sign(null, digest, PRIVATE[presentation.signer]); const result = { chain: true, name: true, revocation: true, possession: true }; if (setting.chain) { const intermediate = INTERMEDIATE_REGISTRY[presented.issuer]; result.chain = Boolean(intermediate) && verify(null, body(intermediate), ROOT.publicKey, intermediate.signature) && intermediate.isAuthority && verify(null, body(presented), INTERMEDIATE.publicKey, presented.signature); } if (setting.name) result.name = presented.altName.includes(targetName); if (setting.revocation) result.revocation = !REVOKED_LIST.includes(presented.serial); if (setting.possession) { const pub = { key: Buffer.from(presented.public, "hex"), format: "der", type: "spki" }; result.possession = verify(null, digest, pub, possessionSignature); } const accept = Object.values(result).every(Boolean); // The shared secret is derived only in an accepted handshake. let keysEqual = false, finished = false; if (accept) { const a = diffieHellman({ privateKey: clientEphemeral.privateKey, publicKey: serverEphemeral.publicKey }); const b = diffieHellman({ privateKey: serverEphemeral.privateKey, publicKey: clientEphemeral.publicKey }); keysEqual = a.equals(b); finished = createHmac("sha256", a).update(digest).digest() .equals(createHmac("sha256", b).update(digest).digest()); } return { ...result, accept, keysEqual, finished }; } export const write = (g) => (h) => h.map((v, i) => (i ? String(v).padStart(g[i]) : String(v).padEnd(g[0]))).join("");
// measure.mjs — the claim proven per step, the path a skipped step opens, mutual verification import { handshake, write } from "./step.mjs"; // Which step proves which claim. export const CLAIM = [ ["the item presented chains to a familiar root", "chain"], ["the item was issued for the name being discussed", "name"], ["the item has not been revoked", "revocation"], ["the other party holds the item's private key", "possession"], ["the session key is the same on both sides and unknown to a third party", "key"], ["the step digest is the same on both sides", "finished"], ]; // The items each step requires to hold. const ITEMS = { chain: ["root store record", "intermediate item's validity", "intermediate item's private key"], name: ["item's name list", "client's target name"], revocation: ["revocation source is current", "access to the revocation source"], possession: ["server's private key stays secret"], key: ["the ephemeral key is unique to every handshake"], finished: ["the digest is produced in the same order on both sides"], }; const FIXED = ["verifier's clock"]; const CLIENT_ITEMS = ["client item's chain", "client item's revocation status", "client's private key stays secret", "client item's distribution"]; // Fictional presentation attempts: which item is presented, which key signs the digest. const PRESENTATION = [ ["valid item, valid name", { item: "valid", signer: "valid" }, "measurement-collector", "legit"], ["item issued for a different name", { item: "billing", signer: "billing" }, "measurement-collector", "name"], ["item from an unknown authority", { item: "foreign", signer: "foreign" }, "measurement-collector", "chain"], ["revoked item", { item: "revoked", signer: "revoked" }, "measurement-collector", "revocation"], ["copied item, no private key", { item: "valid", signer: "client" }, "measurement-collector", "possession"], ]; const SETTING = [ ["full verification", { chain: 1, name: 1, revocation: 1, possession: 1 }, false], ["name check off", { chain: 1, name: 0, revocation: 1, possession: 1 }, false], ["chain verification off", { chain: 0, name: 1, revocation: 1, possession: 1 }, false], ["revocation check off", { chain: 1, name: 1, revocation: 0, possession: 1 }, false], ["possession signature skipped", { chain: 1, name: 1, revocation: 1, possession: 0 }, false], ["no identity step at all", { chain: 0, name: 0, revocation: 0, possession: 0 }, false], ["full verification + mutual", { chain: 1, name: 1, revocation: 1, possession: 1 }, true], ]; const first = handshake(PRESENTATION[0][1], PRESENTATION[0][2], { chain: 1, name: 1, revocation: 1, possession: 1 }); console.log(`legit handshake under full verification: accept ${first.accept}, ` + `shared secret equal ${first.keysEqual}, finished verification ${first.finished}`); console.log(`${PRESENTATION.length} fictional presentation attempts, ${SETTING.length} settings\n`); const fmt1 = write([74, 18, 8]); console.log(fmt1(["claim", "proving step", "items"])); for (const [text, step] of CLAIM) console.log(fmt1([text, step, (ITEMS[step] ?? []).length])); const fmt2 = write([32, 12, 10, 11, 18, 8]); console.log("\n" + fmt2(["setting", "proven", "unproven", "accept/5", "unproven passed", "items"])); for (const [name, setting, mutual] of SETTING) { const result = PRESENTATION.map(([, s, target]) => handshake(s, target, setting)); const accept = result.filter((r) => r.accept).length; const proven = CLAIM.filter(([, a]) => setting[a] === undefined || setting[a]).length + (mutual ? 1 : 0); const items = [...Object.entries(ITEMS).filter(([a]) => setting[a] === undefined || setting[a]) .flatMap(([, l]) => l), ...FIXED, ...(mutual ? CLIENT_ITEMS : [])].length; const totalClaims = CLAIM.length + (mutual ? 1 : 0); console.log(fmt2([name, proven, totalClaims - proven, accept, accept - 1, items])); } console.log(`\nclaim mutual verification adds: the client is a subject the organization recognizes`); console.log(`items it adds (per connection ${CLIENT_ITEMS.length}):`); for (const k of CLIENT_ITEMS) console.log(` ${k}`); const MUTUAL = handshake({ item: "client", signer: "client" }, "field-terminal-17", { chain: 1, name: 1, revocation: 1, possession: 1 }); console.log(`the same four steps ran in the client direction: accept ${MUTUAL.accept}`); const FLEET = [["meter endpoint", 4100], ["internal service", 26], ["staff workstation", 262]]; const total = FLEET.reduce((t, [, n]) => t + n, 0); console.log(`items added at fleet scale: ${total} leaf items x ` + `${CLIENT_ITEMS.length} = ${total * CLIENT_ITEMS.length}`);
legit handshake under full verification: accept true, shared secret equal true, finished verification true 5 fictional presentation attempts, 7 settings claim proving step items the item presented chains to a familiar root chain 3 the item was issued for the name being discussed name 2 the item has not been revoked revocation 2 the other party holds the item's private key possession 1 the session key is the same on both sides and unknown to a third party key 1 the step digest is the same on both sides finished 1 setting proven unproven accept/5 unproven passed items full verification 6 0 1 0 11 name check off 5 1 2 1 9 chain verification off 5 1 2 1 8 revocation check off 5 1 2 1 9 possession signature skipped 5 1 2 1 10 no identity step at all 2 4 5 4 3 full verification + mutual 7 0 1 0 15 claim mutual verification adds: the client is a subject the organization recognizes items it adds (per connection 4): client item's chain client item's revocation status client's private key stays secret client item's distribution the same four steps ran in the client direction: accept true items added at fleet scale: 4388 leaf items x 4 = 17552
The first line says the legitimate handshake really ran: the item was accepted, the shared secret the two sides derived matches, and the finished value computed over the digest came out the same on both sides. These three results were verified separately, because each corresponds to a separate claim.
The claim table shows the steps’ division of labor. Chain verification says the item chains to a familiar root, but it does not say who the item identifies. Name check says the item was issued for the name being discussed, but it does not say whether the item is valid. The item-possession signature, though, says something none of the others say: the other party is not just showing this item, it also holds the item’s private key. A certificate is a document presented publicly; copying it requires no obstacle at all. Producing the possession signature requires something the certificate itself does not carry.
The last two claims are independent of the identity steps. The shared secret matching on both sides and the digest staying unchanged hold even if no item is presented at all. This is the lesson’s most commonly confused point, and it shows up in the last setting row.
When a Step Is Skipped
The second table lines up four settings side by side, each one turning off a single step. The same thing happens in all four: the proven claims drop from six to five, and accepted presentations rise from one to two. In other words, each step stops exactly one presentation, and none is a backup for another. When the name check is turned off, the item issued for the billing service passes as the collector; when chain verification is turned off, the item produced by an unknown authority passes; when the revocation check is turned off, the item that had been considered invalid passes.
The fifth row breaks from the other four. When the possession signature is skipped, the number of items that must hold drops from eleven to ten — the smallest drop of all the settings. What passes instead is a party carrying a copy of the item and no private key at all. The skip that saves the fewest items is the most expensive one from a defense standpoint, because the one item it removes is the one thing the certificate does not itself carry.
The sixth row gives the measure’s floor. With no identity step at all, all five of the five presentations are accepted, four pass unproven, and three items must hold. But the number of proven claims is not zero, it is two: the shared secret is still the same on both sides, and the digest still has not changed. In this setting the connection is encrypted, and a listening third party cannot read the content — only who is on the other end is unknown. Encryption and authentication are two separate jobs; the first completes without the second, and the moment it completes, there is a connection sitting there that looks successful.
There is also something none of the four steps say. The name check compares the item against the name the client intends to reach; it does not ask whether that name is correct. If the client has been directed to the wrong name and the other party carries a valid item for that name, all four of the four steps pass. Chain verification carries a similar limit: it says the item chains to a familiar root, not that the party behind that item is trustworthy.
What Mutual Verification Adds
Everything measured up to now was one-directional: the client was asking who the server was, and the server was not asking the client the same question. The last setting adds that question, and the same four steps run in the reverse direction — the run confirms this separately, the client’s item gets accepted too.
The gain is a claim: the client is a subject the organization recognizes. The cost is four items per connection, fifteen total. Three of those four items are the mirror image of the server-side ones; the fourth is new, and it carries the real weight: the client item’s distribution. On the server side there is one item and it sits in one place; on the client side, an item has to be placed separately on every machine that connects.
At fleet scale that means 17,552 items for those 4,388 leaf items. The number gains meaning read alongside the second lesson’s inventory: mutual verification adds one claim on the authentication side while opening a four-thousand-three-hundred-eighty-eight-item load on the lifecycle side. That load is not itself a defense; it is the condition for the defense being sustainable, and when it is not paid, the added claim stays unproven too.
This points to a conclusion that holds for all four steps. Every claim measured at the moment of the handshake rests on work done long before the connection: issuing the item, writing its name correctly, chaining it to the root, and renewing it before it expires. The measurement happens at the moment of connection; the work happens at another time.
Summary
- The handshake’s four identity steps prove four of six claims; the remaining two claims (the shared secret’s equality and the digest staying unchanged) hold independently of the identity steps.
- When any of the four steps is turned off, proven claims go from 6 to 5 and accepted presentations go from 1 to 2: each step stops exactly one presentation, and none is a backup for another.
- Skipping the possession signature reduces the items that must hold by only 1 (11 to 10) but accepts a party carrying a copy of the certificate with no private key. The smallest saving is the most expensive opening.
- With no identity step at all, 5 of 5 presentations pass, but 2 claims are still proven: the connection is encrypted and its content cannot be read — only who is on the other end is unknown. Encryption completes without authentication.
- Mutual verification adds 1 claim, raises items per connection from 11 to 15, and at fleet scale brings a 17,552-item load for 4,388 leaf items; most of that load is distribution and lifecycle work, not verification.
Next Step
This lesson’s last sentence is the next one’s question. Every claim measured in the handshake was the result of work done before the connection, and the most fragile part of that work concerns time: every item has a validity window, and when the window closes, the item stops passing even if nothing else has changed. The next lesson measures that window — how many items go unnoticed when renewal is done manually, how many connections a single expired item drops, which gap automation closes, and how many items’ worth of maintenance work shortening item lifetime adds.
To keep your progress and take notes, Log in
My notes
Log in to take notes.