Lesson 03 / 16
TLS Termination
Comparing three options for where termination happens: the plaintext-leg count, certificate-location count, renewal-touch-point count, and the work the edge loses when terminating at the edge, at the proxy, and at the app; modeling protocol version and cipher suite as an item set, the twenty-eight items the default leaves open, and the device and read counts a restriction drops.
Published Updated
In this lesson
- Compares three termination points by plaintext-leg count, certificate-location count, and renewal-point count
- Counts the jobs termination moving away from the edge takes from the edge proxy
- Models the accepted protocol version and cipher suite as an item set and derives the default's open-item count
- Measures separately how many items the version floor and the suite list each close, and how many devices each drops
Contents
Every measurement in the previous lesson took one thing as given: encryption ends somewhere. The edge proxy decrypts, everything behind it carries plaintext, and the lost information was handed back through headers. “It ends at the edge proxy” is not an assumption, it is a decision, and there are three places it can be made.
This lesson measures that decision with three numbers: how many legs of the chain stay plaintext, how many separate places the certificate sits in, how many points get touched during renewal. The three do not move in the same direction. The option that drops the plaintext legs to zero triples the other two numbers.
Which security headers go into the response and how many do not arrive by default were measured before, in the Security Headers and TLS lesson; that measurement is not repeated here. What is measured here is not the header itself, but where the encryption ends.
The fictional regional measurement network’s request path passes through four components: the client, the edge proxy, the internal proxy that selects the environment, and the measurement app. There are three legs.
- WS13. The component copy counts are fictional: the edge proxy has two copies, the internal proxy three, the app six.
- WS14. The number of points touched during renewal is taken as equal to the number of places the certificate sits in; the renewal flow’s own steps are measured in the next lesson.
- WS15. Version names are fictional:
s1is oldest,s4is newest. Cipher suites are namedt01–t12, and each suite can be used from its own minimum version onward. - WS16. An item is an accepted (version, suite) pair. The default policy sets no floor and accepts every suite.
- WS17. A client population drops if the highest version it supports is below the policy’s floor, or if no suite it can use remains in the accepted list.
- WS18. The model does not measure time; no real handshake is performed, and the item and device counts are derived from the lists.
// termination/point.mjs — model: the three options for TLS termination. The chain is the // fictional measurement network's request path; the copy counts are fictional. The model counts // the plaintext legs, how many places the certificate sits in, and how many points get touched during renewal. const CHAIN = ["client", "edge", "internal-proxy", "app"]; const COPIES = { edge: 2, "internal-proxy": 3, app: 6 }; // instance count per component // What the edge can do: things that need plaintext, and the one job visible during the handshake. const CAPABILITY = [ ["path-based routing", "plain"], ["edge cache", "plain"], ["per-path rate limit", "plain"], ["body-size check", "plain"], ["name-based routing", "handshake"], ]; function measure(point) { const i = CHAIN.indexOf(point); const legs = CHAIN.slice(0, -1).map((_, b) => (b < i ? "encrypted" : "plaintext")); const seesPlain = CHAIN.slice(i); // everything after termination sees plaintext const edgePlain = seesPlain.includes("edge"); return { plaintextLegs: legs.filter((x) => x === "plaintext").length, certLocations: COPIES[point], renewalPoints: COPIES[point], plainViewers: seesPlain.length, edgeCapability: CAPABILITY.filter(([, k]) => k === "handshake" || edgePlain).length, }; } const E = [16, 16, 16, 18, 17, 18]; const write = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](E[i])).join(""); console.log(write(["termination", "plaintext legs", "cert locations", "renewal points", "sees plaintext", "edge capability"])); for (const n of ["edge", "internal-proxy", "app"]) { const o = measure(n); console.log(write([n, o.plaintextLegs, o.certLocations, o.renewalPoints, o.plainViewers, `${o.edgeCapability}/${CAPABILITY.length}`])); } // --- Protocol and cipher suite: item set --------------------------------- // Version names are fictional; s1 is oldest, s4 is newest. A suite can only be used from // its own minimum version onward. const SUITE = [ ["t01", 1, false], ["t02", 1, false], ["t03", 2, false], ["t04", 2, false], ["t05", 2, false], ["t06", 3, true], ["t07", 3, true], ["t08", 3, false], ["t09", 3, true], ["t10", 4, true], ["t11", 4, true], ["t12", 4, true], ]; // Fictional client population: [name, device count, highest version supported, daily reads] const CLIENT = [ ["old-field-handheld", 40, 1, 1200], ["billing-client", 25, 2, 400], ["new-field-handheld", 180, 3, 5400], ["work-order-app", 95, 3, 950], ["collector", 60, 4, 28800], ["browser-dashboard", 300, 4, 900], ]; function policy(minVersion, strongOnly) { const acceptedSuites = SUITE.filter(([, , g]) => (strongOnly ? g : true)); const items = []; for (let s = minVersion; s <= 4; s += 1) for (const [name, min, strong] of acceptedSuites) if (min <= s) items.push([s, name, strong]); const connected = CLIENT.filter(([, , maxV]) => maxV >= minVersion && acceptedSuites.some(([, min]) => min <= maxV)); const dropped = CLIENT.filter((x) => connected.includes(x) === false); return { items: items.length, weak: items.filter((k) => k[2] === false).length, devices: connected.reduce((t, x) => t + x[1], 0), droppedDevices: dropped.reduce((t, x) => t + x[1], 0), droppedReads: dropped.reduce((t, x) => t + x[3], 0), droppedNames: dropped.map((x) => x[0]), }; } const P = [13, 15, 16, 13, 18, 15, 20]; const write2 = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](P[i])).join(""); console.log("\n" + write2(["min version", "suite list", "accepted items", "weak items", "connected dev", "dropped dev", "dropped reads/day"])); for (const [min, strong] of [[1, false], [2, false], [3, false], [3, true], [4, true]]) { const r = policy(min, strong); console.log(write2([`s${min}`, strong ? "strong only" : "all", r.items, r.weak, r.devices, r.droppedDevices, r.droppedReads]) + (r.droppedNames.length ? ` ${r.droppedNames.join(", ")}` : "")); }
termination plaintext legs cert locations renewal points sees plaintext edge capability edge 2 2 2 3 5/5 internal-proxy 1 3 3 2 1/5 app 0 6 6 1 1/5 min version suite list accepted items weak items connected dev dropped dev dropped reads/day s1 all 28 19 700 0 0 s2 all 26 17 660 40 1200 old-field-handheld s3 all 21 12 635 65 1600 old-field-handheld, billing-client s3 strong only 9 0 635 65 1600 old-field-handheld, billing-client s4 strong only 6 0 360 340 7950 old-field-handheld, billing-client, new-field-handheld, work-order-app
Three Options, Three Numbers
The first table lines up the three options side by side, and no row beats the others in every column.
Terminating at the edge is the most common arrangement and the one that asks for the least maintenance: the certificate sits in two places, renewal touches two points. Its cost is two plaintext legs. On the path from the edge proxy to the app, meter readings, subscriber identities, and invoice amounts travel as plaintext. This path is called the “internal network,” and treating the internal network as trustworthy is not a measurement, it is a habit. The number of components that can see plaintext is three.
Terminating at the proxy reduces the edge proxy to a carrier: the encrypted connection passes through it unchanged and ends at the internal proxy. The plaintext leg drops to one, the number of components that see plaintext drops to two. In return, the certificate starts sitting in three places and renewal touches three points.
Terminating at the app drops the plaintext leg to zero. Only the app sees plaintext. The certificate sits in six places and renewal touches six points. The number has tripled, and what is growing is not a security setting, it is a maintenance surface: a key distributed to six places is six separate copies, six separate file permissions, six separate chances for renewal to fail.
The three rows show a single trend: as the plaintext leg shrinks, the number of places the key sits in grows. Zero plaintext legs does not mean zero risk; it moves the risk from the transport path to key distribution.
The reason the two numbers do not move in the same direction is structural. Encrypting a leg requires the component at that leg’s receiving end to be able to decrypt it; being able to decrypt requires holding the key. The deeper encryption reaches into the chain, the more copies the key lands in. The component count also grows toward the end of the chain — the edge has two copies, the internal proxy three, the app six — and this growth is not a coincidence: the layer that scales is the app, not the edge.
What the Edge Loses
The last column shows the decision’s other cost. The edge proxy does five jobs, and four of them require reading the request’s content: path-based routing, the edge cache, the per-path rate limit, the body-size check. The fifth rests on the server name visible during the handshake, and it can be done without decrypting.
When termination happens at the edge, all five jobs can be done. When termination moves away from
the edge, all four jobs drop at once, and the second and third rows give the same value in
this column: 1/5.
This is the course’s “wrong closure” column, and the wrong closure here is large. The step that closes the plaintext leg closes the edge cache — every bit of edge hit rate the next lesson measures drops to zero. It closes the per-path rate limit — an abusive client can now only be stopped at the app, and by the time it gets there it has crossed the entire chain. It closes the body-size check — a large body now travels all the way to one of the six app copies.
The question to ask when deciding among the three options is not “which is more secure”; the numbers do not give that question a single answer. The question is: accept two plaintext legs and do four jobs at the edge, or close the leg and move those four jobs into the app. When the second path is chosen, the app has to carry its own rate limit, its own body check, and its own cache, and that is the new surface closing brings.
The Surface Closing Brings
Closing the plaintext leg spawns new items, and they do not show up in the table.
While the certificate sits in two places, a renewal has either been applied to both copies or to neither. While it sits in six places, a partial state is possible: four copies renewed, two still standing on the old key. This in-between state does not produce a failure — as requests are distributed, they are met with different certificates depending on which copy they land on, and a client watching from outside only sees this when it lands on the copy whose certificate has expired. As the renewal-point count grows, the failure’s probability of showing up does not grow, its time to be found grows longer.
The second new item is key distribution. A file that can be placed by hand into two copies needs a distribution path to reach six copies, and that path is a new authority: some component has to be able to read the key and write it to six places. The key sitting in six places is not one item, it is seven items — six copies and the single path that writes them.
Third is the certificate becoming tied to the app’s version. While termination happens at the app, renewing the certificate requires reloading the app; two separate calendars — the release calendar and the renewal calendar — touch the same process.
Protocol as an Item Set
The second table measures another default. Once the termination point is decided, the terminating component decides which handshakes to accept, and in most setups no one makes that decision.
The default row is the first row: no floor, every suite accepted. Twenty-eight items are open, and nineteen of them are weak. All seven hundred devices can connect, no reads drop. This is exactly the default’s appeal: zero breakage. No one complains, and the nineteen weak items sit in a list no one looks at, because no one complains.
Setting the floor to s2 drops the item count to twenty-six: two items closed. The cost is forty
old handheld terminals and twelve hundred reads a day. The s3 floor drops it to twenty-one
items, sixty-five devices drop, sixteen hundred reads a day are cut — and twelve weak items are
still open.
This is the measure of why the version floor is not enough by itself. The floor closes an item by
looking at its version; a weak suite keeps being accepted under a newer version too. Suite number
eight can be used starting from s3, and it is weak; the version floor never closes it.
Twelve Items Closed for Free
The fourth row is the measurement’s sharpest result. The floor is held fixed at s3, and only the
suite list is restricted to the strong ones.
The accepted item count drops from twenty-one to nine, the weak item count drops from twelve to zero, and the dropped-device count does not change at all: sixty-five. The dropped-read count stays the same too. Twelve items closed with no additional breakage.
This is the difference between the two levers. The version floor is expensive: every step drops a client population. The suite list is cheap: removing weak suites drops no one as long as strong suites remain that clients speaking the same version can use. The hardening order should follow this — the suite list first, the version floor second.
Reversing the order does measurable damage. A team that raises the floor first drops sixty-five devices, the phones ring, the step gets rolled back, and the weak suites stay open too — breakage happened, no item closed. If the same team had narrowed the suite list first, it would have closed twelve items with zero breakage, and would then be having the floor argument without those twelve items in the way.
The fifth row shows where the limit sits. When the floor is raised to s4, the item count drops
to six, but three hundred forty devices drop and seventy-nine hundred fifty reads a day are cut.
The price paid to go from nine items to six is losing half the system. The fourth row closed
twelve items with zero additional devices lost; the fifth row closes three items with two hundred
seventy-five additional devices lost. This difference between two steps in the same table shows
why treating the item count alone as a target is misleading.
The item that cannot be closed appears here by name: forty old handheld terminals. These
devices cannot speak anything above s1, and until they are replaced, the floor cannot rise above
s1. The only way out is opening a separate listener for them — accepting the old version only on
that listener. This is not a fix, it is a relocation: the surface item does not close, it changes
address, and one more item gets added to the listening port list the previous lesson counted.
Summary
- As termination moved from the edge toward the app, the plaintext-leg count dropped from two to zero, while the certificate-location count and the renewal-touch-point count climbed from two to six.
- The number of components able to see plaintext dropped from three to one; zero plaintext legs moved the risk from the transport path to key distribution.
- When termination moved away from the edge, four of the edge proxy’s five jobs dropped: path-based routing, the edge cache, the per-path rate limit, and the body-size check.
- The default policy accepted twenty-eight items and nineteen were weak; because no device dropped, the default was never questioned.
- Setting the version floor to
s3closed seven items, dropped sixty-five devices, and left twelve weak items open; restricting the suite list to the strong ones closed all twelve of the remaining weak items with no additional device loss. - Forty old handheld terminals kept the floor from rising above
s1; opening a separate listener for them does not close the item, it adds one to the listening-port list.
Next Step
This lesson counted how many places the certificate sits in and took the renewal-touch-point count as equal to that number. Renewal itself stayed a black box: it happens some day, it finishes, the certificate is refreshed.
Inside that box is a state machine with two settings: the certificate’s lifetime and how many days
before expiry renewal starts. If the threshold is set too early, unnecessary renewals run; if it
is set too late, renewal may not finish before expiry, raising the question of how many hours of
exposure window are left in between. There is a validation step in the middle of the renewal
flow, and that step can fail; how many attempts are made, on which attempt the certificate
expires, and when the alert should arrive are separate numbers. The next lesson writes the renewal
flow as a state machine in node, sweeps lifetime against threshold, models validation failure
and measures the exposure window in hours, and counts how many items manual renewal gets forgotten
on over a period.
To keep your progress and take notes, Log in
My notes
Log in to take notes.