Appendix D: Solutions for Chapter 28
This page collects solutions and editorial notes for the exercises in Chapter 28: TLS 1.3 migration. Compute and derivation exercises have worked solutions; open-ended exercises have an editorial note describing what a strong answer addresses.
The fuller versions of these routines are in the tls_migration package under solutions/ch28-tls-migration. From a clone of the companion repository, pytest tests/ch28 runs its suite. Appendix C has the setup.
Exercise 1
Section titled “Exercise 1”Editorial note. The line to report is Negotiated TLS1.3 group: X25519MLKEM768, printed by s_client after the certificate chain (measured on OpenSSL 3.6.2). The same run also prints New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384, which names the cipher suite rather than the group and is not the answer to this exercise. If the two sides advertise mutually exclusive group lists, the client prints Negotiated TLS1.3 group: <NULL> alongside an ssl/tls alert handshake failure with SSL alert number 40, and the server logs no suitable key share. Note that the failure surfaces at the group level: an error mentioning cipher suites is a different fault and points somewhere other than the group configuration. The exercise reinforces that OpenSSL 3.5+ is the gating dependency for hybrid TLS in 2026, and with this command it does so at configuration time rather than during the handshake. A build that does not know the group name rejects the option before any connection is attempted: openssl s_client -groups X25519MLKEM768 prints Call to SSL_CONF_cmd(-groups, X25519MLKEM768) failed and exits, with no ClientHello sent. Silent fallback is a real failure mode and it belongs to a different configuration. A client left on its default group list, or one offering a mixed list, negotiates whatever both sides support and completes the handshake without surfacing that the post-quantum half was never on the table. Explicit hybrid-only configuration fails loudly. Default or mixed-group configuration is the one to watch, and it is what the rollup in Exercise 2 measures.
Exercise 2
Section titled “Exercise 2”Editorial note. The connection log the rollup reads carries a timestamp and a codepoint, and nothing else, so the discriminator has to be built from what rollup returns: the per-window total and the per-codepoint counts. The hybrid fraction alone cannot separate the two causes. A canary flip serves the same client population differently, so the total holds roughly constant while the classical count falls by however much the hybrid count rises. A traffic-mix shift adds clients, so the classical count holds roughly constant and the total rises with the hybrid count. Both signatures assume the client population is otherwise steady over the window. A client-side change that turns the hybrid on for the same users (a browser release, for instance) moves the counts exactly as a canary flip does, so the discriminator identifies the cause only when the deployment’s own change log rules out the other.
# Two synthetic per-hour rollups shaped like rollup() output:# (hour, total connections, X25519MLKEM768 count). Both jump at hour 4;# the totals and the classical remainder are what say why.flip = [(h, 1000, 100) for h in range(4)] + [(h, 1000, 900) for h in range(4, 8)]shift = [(h, 1000, 100) for h in range(4)] + [(h, 1800, 900) for h in range(4, 8)]
def largest_jump(series): pct = [(h, 100.0 * hy / t, t, hy) for h, t, hy in series] i = max(range(1, len(pct)), key=lambda j: abs(pct[j][1] - pct[j - 1][1])) prev, cur = pct[i - 1], pct[i] return (cur[0], round(cur[1] - prev[1], 1), prev[2], cur[2], prev[2] - prev[3], cur[2] - cur[3])
for name, series in (("flip", flip), ("shift", shift)): hour, jump, t0, t1, c0, c1 = largest_jump(series) print(f"{name}: hour={hour} +{jump}pp total {t0}->{t1} classical {c0}->{c1}")# ==> flip: hour=4 +80.0pp total 1000->1000 classical 900->100# ==> shift: hour=4 +40.0pp total 1000->1800 classical 900->900Both series put the same 900 hybrid connections in hour 4 and both are found by the same largest-jump scan, but the flip moves the fraction twice as far, because it converts existing connections rather than adding new ones. That difference in size is suggestive and not diagnostic; the classical remainder is diagnostic. Report the absolute counts alongside the fraction, or a dashboard showing only the percentage will read a doubling of traffic as a rollout milestone.
Exercise 3
Section titled “Exercise 3”Editorial note. The fourth status sits at the bottom of the readiness ladder, which runs unsupported < blocked < gated < ready. A gated component needs a version bump; a blocked one has no entry in the matrix, so the classifier has not established whether a path exists; an unsupported one has been assessed and has none. The classify function checks the unsupported set first. Any library that appears there returns unsupported regardless of version.
The test cases must cover (a) a component on a library in unsupported_libraries returns unsupported, (b) the same library at a hypothetical future version still returns unsupported until removed from the set, (c) other libraries continue to classify as before. The exercise’s deeper point: a fleet inventory has to distinguish “library doesn’t support PQC at any version” from “library supports PQC but the running version is too old”, because the migration paths differ (the first needs a vendor change; the second needs a version bump).
Exercise 4
Section titled “Exercise 4”Editorial note. The runbook is largely supplied by the exercise prompt. The writing task is filling in the operational specifics. A strong runbook names the on-call engineer’s first action (page the platform team, then the auth team), specifies how to remove the group from the load balancer config (e.g. kubectl edit configmap tls-server-config followed by a rolling restart), and includes a rollback rehearsal step that the team runs in staging before the canary flip. The post-mortem template should distinguish “the cryptographic primitive failed” (rare, and would imply a library bug) from “the negotiation infrastructure failed” (common, e.g. a middlebox rejecting the larger ClientHello). The reported difficulty with deploying hybrid key agreement has been the second kind. Cloudflare attributes it to the ClientHello outgrowing the single packet that middleboxes, load balancers, and other downstream software had tacitly assumed (Westerbaan, 2025). What needed fixing was the incompatible vendor products, not the cryptography.
Exercise 5
Section titled “Exercise 5”Editorial note. Direct action: Ch 26’s “Protocol-level adaptability”. The X25519MLKEM768 rollout is exactly a protocol-level negotiation flip: enabling the new NamedGroup is a configuration change that lives in the TLS protocol layer. Adjacent but not the action: Ch 26’s “Cryptographic observability”. The rollout requires observability to land safely (the canary monitoring relies on per-NamedGroup handshake counters), but observability is not what the rollout itself changes. The observability work is a prerequisite, not a deliverable.