<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: asif naeem</title>
    <description>The latest articles on DEV Community by asif naeem (@asif_naeem_bd5842be56bfcd).</description>
    <link>https://dev.to/asif_naeem_bd5842be56bfcd</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4145821%2Ff4f7c43a-a0dc-459b-9bb2-f05dd6d6a755.jpg</url>
      <title>DEV Community: asif naeem</title>
      <link>https://dev.to/asif_naeem_bd5842be56bfcd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/asif_naeem_bd5842be56bfcd"/>
    <language>en</language>
    <item>
      <title>72% of the Top 1,000 Sites Already Speak Post-Quantum TLS. None of Them Can Prove Who They Are With It.</title>
      <dc:creator>asif naeem</dc:creator>
      <pubDate>Sat, 07 Nov 2026 18:20:11 +0000</pubDate>
      <link>https://dev.to/asif_naeem_bd5842be56bfcd/72-of-the-top-1000-sites-already-speak-post-quantum-tls-none-of-them-can-prove-who-they-are-with-5458</link>
      <guid>https://dev.to/asif_naeem_bd5842be56bfcd/72-of-the-top-1000-sites-already-speak-post-quantum-tls-none-of-them-can-prove-who-they-are-with-5458</guid>
      <description>&lt;h3&gt;
  
  
  TL;DR
&lt;/h3&gt;

&lt;p&gt;I probed the Tranco top 1,000 domains for post-quantum key exchange. 504 of the 695 that completed a TLS 1.3 handshake (72.5%) accept X25519MLKEM768. That's 50.4% of the full list.&lt;br&gt;
Zero of 695 present a post-quantum certificate. Every leaf is RSA or ECDSA.&lt;br&gt;
Key exchange has migrated; authentication hasn't started. This is the same gap Phase 4 hit from the server side, now visible from the client side across the public web.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this post isn't the one I promised
&lt;/h2&gt;

&lt;p&gt;Part 5 ended with "Part 6: the SLH-DSA measurements, whenever the ecosystem catches up." The monitor is still reporting "not yet," so that post is still waiting.&lt;/p&gt;

&lt;p&gt;The question I kept getting in the meantime was simpler: where is everyone else? Every migration-planning conversation starts there. So I pointed &lt;a href="https://github.com/asifmahbubnaeem/pqc-probe" rel="noopener noreferrer"&gt;PQC-Probe&lt;/a&gt;, the scanner from Project 1 of this trilogy, at the top 1,000 sites on the public web.&lt;/p&gt;
&lt;h2&gt;
  
  
  The headline numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Domains&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Completed a TLS 1.3 handshake&lt;/td&gt;
&lt;td&gt;695&lt;/td&gt;
&lt;td&gt;69.5% of 1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Accept X25519MLKEM768&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;504&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;72.5% of reachable&lt;/strong&gt; · 50.4% of 1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Classical-only TLS 1.3&lt;/td&gt;
&lt;td&gt;191&lt;/td&gt;
&lt;td&gt;27.5% of reachable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Present a PQ (ML-DSA / SLH-DSA) certificate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flo1yflraj4l6c9o3l1s2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flo1yflraj4l6c9o3l1s2.png" alt="PQ TLS outcome per domain and leaf certificate signature algorithm, Tranco top 1000, list K9YYW, 2026-11-06" width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two numbers, two different stories. Hybrid ML-KEM is already on most of the reachable web, largely because the big edge operators turned it on: google.com, cloudflare.com, facebook.com, apple.com and amazonaws.com all accept it, and a huge share of the top 1,000 sits behind one of them. Certificates are a different supply chain: a CA has to issue them, browsers have to trust them, and per Phase 4, server TLS stacks have to be able to serve them. None of that has happened yet for the public web.&lt;/p&gt;
&lt;h2&gt;
  
  
  Five things in the data I didn't expect
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The very top lags behind.&lt;/strong&gt; Ranks 1–100: 66.2% (45 of 68 reachable). Ranks 101–500: 72.7%. Ranks 501–1,000: 73.6%. Being bigger doesn't make a site earlier. The classical-only names in the top 100 include github.com, wikipedia.org, netflix.com, amazon.com, azure.com and yahoo.com. These are large organisations running their own TLS termination, and they move on their own schedule rather than inheriting a CDN default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Microsoft is split down the middle.&lt;/strong&gt; microsoft.com accepts X25519MLKEM768. office.com, live.com, bing.com, outlook.com and sharepoint.com don't. Most of that second group also rejects &lt;em&gt;plain X25519&lt;/em&gt; and only offers the NIST curves (P-256/P-384/P-521). Across the list, 31 sites refuse plain X25519, and they cluster around Azure Front Door (azurefd.net, azureedge.net) and US government domains (nist.gov, epa.gov, ca.gov), which is consistent with stacks configured for NIST-curve-only compliance. Twelve of those 31 still accept X25519MLKEM768. So "no X25519" and "PQ hybrid" aren't mutually exclusive, which is a fun one for anyone writing compliance policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. There's exactly one PQ group that matters.&lt;/strong&gt; Every site that accepts any ML-KEM hybrid accepts X25519MLKEM768. Not one site supports another ML-KEM group without it. SecP256r1MLKEM768 shows up on 102 sites (14.7%) and SecP384r1MLKEM1024 on 8 (1.2%), always alongside X25519MLKEM768. If you're picking one group to support client-side, the web has already picked for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. 54 sites in the top 1,000 don't speak TLS 1.3 at all.&lt;/strong&gt; Among them: baidu.com, mail.ru, mit.edu, linktr.ee and bsky.app. I re-checked the last three by hand, and each negotiated TLS 1.2 with no version forced. Post-quantum key exchange in TLS requires 1.3, so these sites are a full protocol upgrade away from even starting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The failures are mostly not websites.&lt;/strong&gt; Tranco ranks domains by DNS popularity, so the top 1,000 is full of infrastructure: akamai.net, gtld-servers.net, cloudfront.net, apple-dns.net. Of the 305 domains with no handshake, 197 have no IPv4 address at the bare domain, 39 have port 443 closed, 54 are TLS 1.2 or older, and 15 had TLS errors or were unstable under the probe. Any survey that reports "X% of the top 1,000" without saying what the denominator is will be off by about a third.&lt;/p&gt;
&lt;h2&gt;
  
  
  The scanner bugs were the most useful part of the method
&lt;/h2&gt;

&lt;p&gt;This scan was a reminder of the series' recurring lesson: &lt;strong&gt;the measuring instrument hits its limits before the system under test does.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IPv6 that half-works.&lt;/strong&gt; My first pilot had 4 of 5 top domains "failing" at exactly the 5-second timeout. The handshake was fine. My ISP hands out IPv6 addresses but doesn't route them, so &lt;code&gt;openssl s_client&lt;/code&gt; tried the AAAA record first and stalled about 5 seconds before falling back to IPv4. With &lt;code&gt;-4&lt;/code&gt; the same handshake took 0.11 s. The scan is IPv4-only as a result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tool that won't hang up.&lt;/strong&gt; After a successful TLS 1.3 handshake, &lt;code&gt;s_client&lt;/code&gt; can wait seconds for the server to close the connection. The probe waited for the process to exit, so short timeouts threw away handshakes that had already succeeded. The fix was to stream the output and kill the process the moment the handshake summary is printed. A side effect: timed-out probes no longer leave orphaned &lt;code&gt;openssl&lt;/code&gt; processes behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-inflicted load.&lt;/strong&gt; Pass 1 ran 50 domains in parallel, each trying 7 key-exchange groups. That's up to 350 concurrent &lt;code&gt;openssl&lt;/code&gt; processes on a laptop on home Wi-Fi. Median probe time rose from about 130 ms in the pilot to 843 ms, and 183 domains timed out, including amazon.com and linkedin.com. Worse, it produced a believable false result: youtube.com showed up as "X25519MLKEM768 only, no plain X25519." Re-probed alone, it accepts X25519, P-256 and X25519MLKEM768 like the rest of Google.&lt;/p&gt;

&lt;p&gt;The fix was a retry pipeline. Failures and suspicious rows were re-probed at concurrency 10 with a 10 s timeout, then still-failing domains were tried as &lt;code&gt;www.&amp;lt;domain&amp;gt;&lt;/code&gt;, and the last 12 suspect rows were probed one at a time. A retry could only replace an earlier row if it succeeded, and it could never downgrade a PQ result. Every row in the published CSV records which pass produced it. Without this step the headline would have read 70.2% over 551 reachable sites instead of 72.5% over 695. That's a small difference in the percentage but a large one in coverage, and coverage determines whether the number means anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  Method
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Target list:&lt;/strong&gt; &lt;a href="https://tranco-list.eu/" rel="noopener noreferrer"&gt;Tranco&lt;/a&gt; list &lt;strong&gt;K9YYW&lt;/strong&gt;, top 1,000, downloaded and scanned &lt;strong&gt;6 November 2026&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probe:&lt;/strong&gt; PQC-Probe &lt;code&gt;scan&lt;/code&gt; subcommand, commit &lt;code&gt;274f475&lt;/code&gt;, shelling out to &lt;strong&gt;OpenSSL 3.5.2&lt;/strong&gt; (&lt;code&gt;s_client -4 -tls1_3 -groups &amp;lt;group&amp;gt;&lt;/code&gt;). Each domain was offered each of seven groups on its own: X25519, P-256, P-384, P-521, X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vantage point:&lt;/strong&gt; a single home ISP connection in Dhaka, IPv4 only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passes:&lt;/strong&gt; pass 1 (concurrency 50, 5 s timeout) → retry (concurrency 10, 10 s) → &lt;code&gt;www.&lt;/code&gt; fallback → targeted re-probe (concurrency 1, 15 s). Final rows by pass: 840 / 73 / 75 / 12.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure classification:&lt;/strong&gt; IPv4 DNS lookup → TCP connect to :443 → handshake with no version forced.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What the numbers do and don't mean:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Accepts X25519MLKEM768" means &lt;em&gt;the handshake succeeds when that's the only group offered.&lt;/em&gt; It doesn't tell you what a browser ends up negotiating. That depends on client preference and on the server's group order.&lt;/li&gt;
&lt;li&gt;"Certificate signature algorithm" is the CA's signature on the leaf certificate. The chain above it wasn't inspected. That's the next PQC-Probe feature.&lt;/li&gt;
&lt;li&gt;Bare domains first, &lt;code&gt;www.&lt;/code&gt; only as a fallback. One vantage point, one day. CDN edges can behave differently by region.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you run a site behind a major CDN,&lt;/strong&gt; you probably already accept hybrid PQ key exchange. Check it rather than assuming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; yoursite.com:443 &lt;span class="nt"&gt;-tls1_3&lt;/span&gt; &lt;span class="nt"&gt;-groups&lt;/span&gt; X25519MLKEM768 &amp;lt;/dev/null 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"negotiated"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(This needs OpenSSL 3.5+. If it errors with "unknown group," your client is too old to test.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you terminate TLS yourself,&lt;/strong&gt; you're probably in the 27.5%. Phase 1 measured the cost of switching on X25519MLKEM768 at 0.3 ms per handshake, and Phase 3 found no measurable difference at WAN latency. The cost is a config line and an OpenSSL upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're planning certificate migration,&lt;/strong&gt; this data confirms the ecosystem hasn't moved. That's not a reason to wait. Inventory now, so you know which of your certificates, chains and TLS terminators will need to change when CAs start issuing ML-DSA. That inventory is Project 2 of this trilogy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data
&lt;/h2&gt;

&lt;p&gt;Raw CSV (1,000 rows, one per domain, with the pass that produced each row and a failure classification), the scanner, the retry pipeline and the analysis script are all in &lt;a href="https://github.com/asifmahbubnaeem/pqc-probe" rel="noopener noreferrer"&gt;pqc-probe&lt;/a&gt; under &lt;code&gt;results/&lt;/code&gt;. If you scan from a different vantage point or a later Tranco list, I'd like to compare.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next in the series: whichever comes first, the SLH-DSA measurements when the &lt;a href="https://github.com/asifmahbubnaeem/pqc-bench-slh-dsa/issues/1" rel="noopener noreferrer"&gt;monitor&lt;/a&gt; turns green, or the operational cost of running nginx with PQ TLS under live load.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>tls</category>
      <category>postquantim</category>
      <category>cryptography</category>
    </item>
    <item>
      <title>Retro : What I Measured, What I Can't, and What You Can Deploy Today</title>
      <dc:creator>asif naeem</dc:creator>
      <pubDate>Fri, 23 Oct 2026 17:57:12 +0000</pubDate>
      <link>https://dev.to/asif_naeem_bd5842be56bfcd/retro-what-i-measured-what-i-cant-and-what-you-can-deploy-today-7c8</link>
      <guid>https://dev.to/asif_naeem_bd5842be56bfcd/retro-what-i-measured-what-i-cant-and-what-you-can-deploy-today-7c8</guid>
      <description>&lt;h3&gt;
  
  
  TL;DR
&lt;/h3&gt;

&lt;p&gt;ML-DSA-65 at TLS 1.3 is production-ready today. Hybrid KEM (X25519MLKEM768) too. Both have no measurable penalty over classical at WAN RTT.&lt;br&gt;
SLH-DSA cannot be served over TLS yet with any public stack I tested. The crypto and X.509 layers work; the TLS integration layer isn't shipping.&lt;br&gt;
When ecosystem support has layers, "shipping" the algorithm doesn't mean "shipping" the deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I set out to do
&lt;/h3&gt;

&lt;p&gt;Six weeks ago I started pqc-bench to answer a specific question: how much does post-quantum TLS 1.3 cost me, in milliseconds, at scales and distances that match a real deployment? The project was going to be five phases of measurement ending with a cert-size × WAN RTT scenario built around SLH-DSA's 17 kB signature forcing an extra round-trip through TCP's initial congestion window.&lt;/p&gt;

&lt;p&gt;The last phase didn't produce measurements. It produced a different kind of finding. This post ties it to the four that worked.&lt;/p&gt;

&lt;h3&gt;
  
  
  The four phases that worked
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-1-measuring-x25519mlkem768-costs-on-a-004hour-graviton3-2b2d"&gt;Phase 1&lt;/a&gt; — fresh handshake latency, same-AZ. X25519MLKEM768 vs X25519. Delta: 0.3 ms per handshake. Below the noise floor of ±1 ms RTT variance on a c7g.large same-AZ. The hybrid KEM extension is cheap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-2-where-the-x25519mlkem768-overhead-actually-matters-5hli"&gt;Phase 2&lt;/a&gt; — session resumption plus payload sweep (100 B / 10 kB / 100 kB). Classical and PQ statistically identical at every percentile post-resumption. The 1 MB arm had to be dropped — not because of crypto, because the instance's NIC saturated at 200 req/s × 1 MB ≈ 1.6 Gbps. I noted it for Phase 3's methodology and moved on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-3-ml-dsa-65-certs-are-10x-bigger-than-ecdsa-and-dont-cost-a-thing-on-real-2ead"&gt;Phase 3&lt;/a&gt; — cert chain size × WAN RTT across three regions. ECDSA, RSA-2048, ML-DSA-65 × us-east-1, us-west-2, ap-northeast-1. Headline: cert-chain-size differences are statistically invisible at WAN RTT because TCP's initial congestion window (14.6 kB) absorbs the whole ML-DSA-65 chain in one round-trip. The only measurable delta — 1.5 ms — appeared at same-AZ ~1 ms RTT, where every tenth of a millisecond is visible. I also reran the 1 MB payload at 20 req/s and confirmed Phase 2's 1 MB failure was 100% NIC saturation, no crypto effect.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-4-slh-dsa-over-tls-failed-5466"&gt;Phase 4&lt;/a&gt; Part 1 — SLH-DSA at WAN RTT. Planned as the clean counterexample: 17 kB signature → ~20 kB cert chain → one extra round-trip. Tested the premise; the ecosystem couldn't run the experiment.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three layers
&lt;/h3&gt;

&lt;p&gt;Phase 4's finding fits in one picture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbboz205cqeo1tkyc2g48.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbboz205cqeo1tkyc2g48.png" alt=" " width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;TLS support for a signature algorithm has three layers, and all three have to arrive together before a deployment is possible:&lt;/p&gt;

&lt;p&gt;Cryptographic primitives. openssl genpkey, sign, verify.&lt;br&gt;
X.509 encoding. OIDs recognised when parsing cert ASN.1.&lt;br&gt;
TLS protocol integration. SSL_CTX_use_certificate accepts the cert, ClientHello's signature_algorithms extension advertises the type, CertificateVerify is signed with it.&lt;/p&gt;

&lt;p&gt;OpenSSL 3.5.2 has all three for ML-DSA. For SLH-DSA, it has layers 1 and 2 only. oqs-provider 0.12.0-dev doesn't fill the TLS gap — it deferred SLH-DSA to OpenSSL's default provider now that 3.5.2 has native support, and the default provider doesn't reach layer 3. The hand-off left a hole nobody owns right now.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you can deploy today
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want to…&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Serve ML-DSA-65 cert over TLS 1.3&lt;/td&gt;
&lt;td&gt;✓ Production-ready, OpenSSL 3.5.0+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serve SLH-DSA cert over TLS 1.3&lt;/td&gt;
&lt;td&gt;✗ Not with any public stack I tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sign JWT / firmware / packages with SLH-DSA&lt;/td&gt;
&lt;td&gt;✓ OpenSSL 3.5.2 keygen + sign work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use hybrid KEM (X25519MLKEM768) in TLS 1.3&lt;/td&gt;
&lt;td&gt;✓ OpenSSL 3.5.0+, both ends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store an SLH-DSA cert in a certificate store&lt;/td&gt;
&lt;td&gt;✓ X.509 layer accepts it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Negotiate PQ cert selection via SNI&lt;/td&gt;
&lt;td&gt;✓ If the cert is ML-DSA&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the series taught me
&lt;/h2&gt;

&lt;p&gt;Three things I'll carry into the next two projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Measurable" is a hypothesis.&lt;/strong&gt; Phase 1 and Phase 3 both predicted measurable PQ overhead at the chosen scale, and both got noise. That's a less-sexy finding, but it's the useful one for the people who have to decide whether to deploy: ML-DSA is cheap enough that the decision is "yes, when you're ready," not "yes, if you can afford the latency."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure limits dominate crypto costs more often than crypto costs dominate anything.&lt;/strong&gt; Phase 2's 1 MB payload failure wasn't about PQ. It was about a $0.14/hr instance pegging its NIC at 1.6 Gbps. The methodology note that caught it was more informative than any signature-algorithm observation in the whole series. Pick instance sizes that make the crypto, not the network, the bottleneck — otherwise you're measuring the wrong thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Supported" has layers.&lt;/strong&gt; Phase 4's lesson is the one I'll keep closest. OIDs, TLS codepoints, keygen implementations, and TLS handshake integrations are four different things that have to ship together before a deployment is possible. "SLH-DSA is in OpenSSL 3.5" is correct at the first two layers and misleading at the third.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;A GitHub Actions cron builds the latest OpenSSL 3.5.x and oqs-provider main on the 1st of each month and checks &lt;code&gt;openssl list -tls-signature-algorithms&lt;/code&gt; for SLH-DSA entries. Running log at &lt;strong&gt;&lt;a href="https://github.com/asifmahbubnaeem/pqc-bench-slh-dsa/issues/1" rel="noopener noreferrer"&gt;the tracking issue&lt;/a&gt;&lt;/strong&gt;. The day it reports "yes," Phase 6 of pqc-bench measures what Phase 4 was supposed to.&lt;/p&gt;

&lt;p&gt;The series' underlying lesson — that "support" has layers that can arrive separately — becomes the core of Project 1 of the trilogy: &lt;strong&gt;PQC-Probe&lt;/strong&gt;, a scanner that reports what a given host supports across SSH and TLS, in both vanilla and experimental PQ stacks. When those answers differ, that gap is actionable.&lt;/p&gt;

&lt;p&gt;Thanks for following along, five posts in. The repo is at &lt;a href="https://github.com/asifmahbubnaeem/pqc-bench" rel="noopener noreferrer"&gt;pqc-bench&lt;/a&gt; and &lt;a href="https://github.com/asifmahbubnaeem/pqc-bench-slh-dsa" rel="noopener noreferrer"&gt;pqc-bench-slh-dsa&lt;/a&gt;. The data, the terraform, the user-data scripts, and the full logs of every failure mode are all there. Fork what's useful.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for Part 6 — the SLH-DSA measurements, whenever the ecosystem catches up.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tls</category>
      <category>performance</category>
      <category>cryptography</category>
      <category>postquantum</category>
    </item>
    <item>
      <title>PQC-Bench Part 4: SLH-DSA over TLS failed</title>
      <dc:creator>asif naeem</dc:creator>
      <pubDate>Sun, 18 Oct 2026 14:05:36 +0000</pubDate>
      <link>https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-4-slh-dsa-over-tls-failed-5466</link>
      <guid>https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-4-slh-dsa-over-tls-failed-5466</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;SLH-DSA signatures are ~17 kB. That’s bigger than TCP’s initial congestion window (14.6 kB), so a cert chain with SLH-DSA would need an extra round-trip to deliver. I wanted to measure that effect at WAN RTT.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;We can generate SLH-DSA keys and sign things with them in OpenSSL 3.5.2. We cannot serve an SLH-DSA cert over TLS 1.3 — not with vanilla OpenSSL, and not with oqs-provider 0.12.0 either.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The TLS layer is a gap. SLH-DSA is production-ready for our signing API. It is not production-ready for our TLS terminator. Here’s exactly where the gap lives.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;In Phase 3 of pqc-bench (&lt;a href="https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-3-ml-dsa-65-certs-are-10x-bigger-than-ecdsa-and-dont-cost-a-thing-on-real-2ead"&gt;Part 3 here&lt;/a&gt;) we measured the handshake latency of ML-DSA-65, ECDSA, and RSA-2048 across three AWS regions. Recap: cert-chain-size differences are statistically invisible at WAN RTT when the whole chain fits inside TCP’s initial congestion window of 14.6 kB. ML-DSA-65’s 3.3 kB signature → 5–6 kB chain → fits comfortably in one round-trip. No measurable penalty.&lt;/p&gt;

&lt;p&gt;Phase 4 was going to extend that: pick the one PQ signature algorithm whose chain doesn’t fit, measure the extra round-trip explicitly, and close the story. SLH-DSA-SHA2-128f was the obvious choice. Its 17,088-byte signature puts the chain around 20 kB — the first PQ signature at NIST level 1 that would force a second RTT.&lt;/p&gt;

&lt;p&gt;What was supposed to be an evening of config bumping turned into three distinct failures that each taught me something about how PQ TLS support actually ships in the real world.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 1:
&lt;/h3&gt;

&lt;p&gt;OpenSSL 3.5.0 lists SLH-DSA but can’t generate keys&lt;/p&gt;

&lt;p&gt;My Phase 1–3 AMIs were built on OpenSSL 3.5.0, released May 2026. The algorithm registry looked promising:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$ /opt/openssl-3.5/bin/openssl list -public-key-algorithms | grep SLH&lt;br&gt;
  Name: OpenSSL SLH-DSA-SHA2-128f implementation&lt;br&gt;
    IDs: { 2.16.840.1.101.3.4.3.21, id-slh-dsa-sha2-128f, SLH-DSA-SHA2-128f }&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
But:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$ openssl genpkey -algorithm SLH-DSA-SHA2-128f -out slh.key&lt;br&gt;
Error: unsupported algorithm&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;OIDs registered, operations not implemented. OpenSSL 3.5.2 (Aug 2026) fills this in. &lt;/p&gt;
&lt;h3&gt;
  
  
  Next attempt: Rebuild the AMI, bump the pin. ~20 minutes.
&lt;/h3&gt;
&lt;h4&gt;
  
  
  Attempt 2:
&lt;/h4&gt;

&lt;p&gt;3.5.2 keygen works, nginx still can’t serve the cert&lt;/p&gt;

&lt;p&gt;With 3.5.2 baked, keygen succeeds:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$ openssl genpkey -algorithm SLH-DSA-SHA2-128f -out /tmp/t.key &amp;amp;&amp;amp; echo OK&lt;br&gt;
OK&lt;br&gt;
$ openssl req -new -x509 -key /tmp/t.key -out /tmp/t.crt -days 365 -subj "/CN=test"&lt;br&gt;
$ wc -c /tmp/t.crt&lt;br&gt;
23556 /tmp/t.crt&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;~23 kB cert chain — exactly as predicted, well past &lt;code&gt;initcwnd&lt;/code&gt;. The crypto all works. Hand this cert to nginx:&lt;/p&gt;

&lt;p&gt;nginx&lt;br&gt;
&lt;code&gt;ssl_certificate /tmp/t.crt;&lt;br&gt;
ssl_certificate_key /tmp/t.key;&lt;br&gt;
ssl_protocols TLSv1.3;&lt;br&gt;
ssl_conf_command SignatureAlgorithms SLH-DSA-SHA2-128f;&lt;br&gt;
$ sudo nginx-pq -t&lt;br&gt;
nginx: [emerg] SSL_CTX_use_certificate("/tmp/t.crt") failed&lt;br&gt;
  (SSL: error:0A0000F7:SSL routines::unknown certificate type)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;nginx can’t load an SLH-DSA cert at all. The error comes from OpenSSL’s TLS layer, not nginx. Let's dig into why:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$ openssl list -tls-signature-algorithms | tr ':' '\n' | grep -i slh&lt;br&gt;
(empty)&lt;br&gt;
$ openssl list -tls-signature-algorithms | tr ':' '\n' | grep -i dsa&lt;br&gt;
mldsa44&lt;br&gt;
mldsa65&lt;br&gt;
mldsa87&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;OpenSSL 3.5.2 has SLH-DSA at the X.509 and signing layers, but not at the TLS layer. ML-DSA shows up as a TLS 1.3 signature algorithm. SLH-DSA doesn’t. The TLS code path has no idea what to do with an SLH-DSA cert.&lt;/p&gt;

&lt;p&gt;The pattern recognition kicks in: this is what oqs-provider from the Open Quantum Safe project was built for. Install it alongside OpenSSL, get the missing TLS integration -&amp;gt; measure -&amp;gt; ship.&lt;br&gt;
That was my next plan of action.&lt;/p&gt;
&lt;h3&gt;
  
  
  Attempt 3: oqs-provider doesn’t fix it either
&lt;/h3&gt;

&lt;p&gt;Rebuilt from source: liboqs (shared), oqs-provider v0.12.0-dev linked against OpenSSL 3.5.2, both installed into /opt/openssl-3.5.2/. openssl.cnf activates it at init:&lt;/p&gt;

&lt;p&gt;`[provider_sect]&lt;br&gt;
default = default_sect&lt;br&gt;
oqsprovider = oqsprovider_sect&lt;/p&gt;

&lt;p&gt;[default_sect]&lt;br&gt;
activate = 1&lt;/p&gt;

&lt;p&gt;[oqsprovider_sect]&lt;br&gt;
activate = 1`&lt;/p&gt;

&lt;p&gt;Smoke test:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$ openssl list -providers&lt;br&gt;
Providers:&lt;br&gt;
  default&lt;br&gt;
    name: OpenSSL Default Provider&lt;br&gt;
    version: 3.5.2&lt;br&gt;
    status: active&lt;br&gt;
  oqsprovider&lt;br&gt;
    name: OpenSSL OQS Provider&lt;br&gt;
    version: 0.12.0-dev&lt;br&gt;
    status: active&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Both active. Good start. Then:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$ openssl list -signature-algorithms | grep -i slh&lt;br&gt;
  { 2.16.840.1.101.3.4.3.21, SLH-DSA-SHA2-128f } @ default&lt;br&gt;
  (... all other SLH-DSA variants, all @ default ...)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Every SLH-DSA entry shows @ default. Zero @ oqsprovider. Which means oqs-provider v0.12 has stopped claiming SLH-DSA — it defers to OpenSSL’s default provider now that 3.5.2 has native SLH-DSA keygen and signing.&lt;/p&gt;

&lt;p&gt;OK, the registration moved. Does the TLS layer work now? Let's try to serve the cert:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$ openssl s_server -key /tmp/t.key -cert /tmp/t.crt -accept 127.0.0.1:8443 -tls1_3&lt;br&gt;
Using default temp DH parameters&lt;br&gt;
error setting certificate&lt;br&gt;
error:0A0000F7:SSL routines:ssl_set_cert:unknown certificate type:ssl/ssl_rsa.c:269&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Identical error to attempt 1. oqs-provider ceded SLH-DSA to the default provider, but OpenSSL’s default provider still has only two of the three layers it needs. Nobody is wiring up SLH-DSA at the TLS layer in October 2026.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three layers
&lt;/h2&gt;

&lt;p&gt;TLS support for a signature algorithm isn’t one thing. It’s three layers that have to arrive together:&lt;/p&gt;

&lt;p&gt;Cryptographic primitives — keygen, sign, verify. openssl genpkey -algorithm X works.&lt;br&gt;
X.509 encoding — the algorithm’s OID is understood when parsing cert ASN.1. openssl req -new -x509 -key X works.&lt;br&gt;
TLS protocol integration — the algorithm is advertised in ClientHello’s signature_algorithms extension, accepted by SSL_CTX_use_certificate, selected during negotiation, carried in CertificateVerify.&lt;/p&gt;

&lt;p&gt;For ML-DSA, all three are in OpenSSL 3.5.2’s default provider. You can genkey, you can sign a cert, nginx can serve it, clients negotiate it. Full stack.&lt;/p&gt;

&lt;p&gt;For SLH-DSA, OpenSSL 3.5.2 has layers 1 and 2. Nobody has layer 3 yet. Not OpenSSL’s default. Not oqs-provider 0.12. The error isn’t an obscure config nit — it’s ssl_set_cert saying “I don’t know what this is.”&lt;/p&gt;
&lt;h2&gt;
  
  
  What this means if you’re planning PQ TLS
&lt;/h2&gt;

&lt;p&gt;Use ML-DSA if your TLS stack is OpenSSL ≥ 3.5.0 or anything built on it (nginx, Envoy, HAProxy with modern crypto, Go ≥ 1.24). It works today, chain fits in initcwnd for the common security levels (ML-DSA-65 at NIST level 3), no RTT penalty vs classical.&lt;/p&gt;

&lt;p&gt;Do not plan an SLH-DSA deployment at the TLS termination layer today. The ecosystem gap is real and precise: X.509 cert works, signing API works, TLS handshake fails. If you have a signing use case that fits (JWT, firmware, package signatures, code signing), SLH-DSA is production-ready for that right now — just not through your TLS listener.&lt;/p&gt;

&lt;p&gt;Monitoring the gap close&lt;/p&gt;

&lt;p&gt;The simplest check from any machine with OpenSSL 3.5.2 and oqs-provider installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash
openssl list &lt;span class="nt"&gt;-tls-signature-algorithms&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;slh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The day this returns slhdsasha2128f (or similar), SLH-DSA at TLS has arrived, probably in a new oqs-provider release that re-registers the TLS codepoints after OpenSSL added the keygen. I’ll be checking monthly. When it changes, Phase 5 of pqc-bench measures what I set out to measure tonight.&lt;/p&gt;

&lt;p&gt;What’s in the repo&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/asifmahbubnaeem/pqc-bench-slh-dsa" rel="noopener noreferrer"&gt;pqc-bench-slh-dsa&lt;/a&gt; on GitHub has all the exercise frozen in a branch: the user-data templates that build the full oqs-provider stack, the acceptance tests that fail cleanly, and the full logs of all three failure modes. If you’re staring at a 0A0000F7 error from your own PQ experiment, you’ll find the exact same thing in those logs.&lt;/p&gt;

</description>
      <category>tl</category>
      <category>performance</category>
      <category>cryptography</category>
      <category>postquantum</category>
    </item>
    <item>
      <title>pqc-bench Part 3: ML-DSA-65 certs are 10 bigger than ECDSA — and don't cost a thing on real networks</title>
      <dc:creator>asif naeem</dc:creator>
      <pubDate>Sun, 11 Oct 2026 11:39:49 +0000</pubDate>
      <link>https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-3-ml-dsa-65-certs-are-10x-bigger-than-ecdsa-and-dont-cost-a-thing-on-real-2ead</link>
      <guid>https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-3-ml-dsa-65-certs-are-10x-bigger-than-ecdsa-and-dont-cost-a-thing-on-real-2ead</guid>
      <description>&lt;p&gt;Part 1 measured post-quantum TLS handshake cost at same-AZ latency and found about 1 ms added per handshake. Part 2 showed that session resumption makes that cost disappear entirely for ongoing connections. That left one PQ overhead lever unmeasured: &lt;strong&gt;cert chain size&lt;/strong&gt;. ML-DSA-65 signatures are around 3.3 kB and the public key is another ~1.9 kB. Combined, the whole cert file is about &lt;strong&gt;10× the size of an ECDSA P-256 cert&lt;/strong&gt;. Common intuition says that would hurt the handshake at high RTT, because every extra TCP segment to deliver the cert costs an extra round-trip.&lt;/p&gt;

&lt;p&gt;The data says the opposite. At realistic WAN RTT, cert size is &lt;strong&gt;statistically invisible&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Same c7g.large Graviton3 target in us-east-1 as Phase 1 and 2. New dimension: a single loadgen deployed in &lt;strong&gt;three different AWS regions&lt;/strong&gt; per night, hitting the same target through its public IP. That gave three natural RTT points:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Loadgen region&lt;/th&gt;
&lt;th&gt;Measured RTT to target&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;us-east-1 (same-AZ)&lt;/td&gt;
&lt;td&gt;~1 ms&lt;/td&gt;
&lt;td&gt;baseline, matches Part 1/2 conditions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;us-west-2 (Oregon)&lt;/td&gt;
&lt;td&gt;~70 ms&lt;/td&gt;
&lt;td&gt;typical North-America east↔west&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ap-northeast-1 (Tokyo)&lt;/td&gt;
&lt;td&gt;~148 ms&lt;/td&gt;
&lt;td&gt;trans-Pacific&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Target serves one of three cert types per run, picked by a &lt;code&gt;cert_type&lt;/code&gt; terraform variable (ECDSA P-256 / RSA-2048 / ML-DSA-65). Each cert type is tested with 3 trials × 60 s per trial. 27 trials total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cert file sizes on disk&lt;/strong&gt; (the input to this experiment):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cert type&lt;/th&gt;
&lt;th&gt;File size&lt;/th&gt;
&lt;th&gt;Signature size&lt;/th&gt;
&lt;th&gt;Public key&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ECDSA P-256&lt;/td&gt;
&lt;td&gt;741 B&lt;/td&gt;
&lt;td&gt;~70 B&lt;/td&gt;
&lt;td&gt;~65 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSA-2048&lt;/td&gt;
&lt;td&gt;1,277 B&lt;/td&gt;
&lt;td&gt;256 B&lt;/td&gt;
&lt;td&gt;~270 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML-DSA-65&lt;/td&gt;
&lt;td&gt;7,680 B&lt;/td&gt;
&lt;td&gt;3,309 B&lt;/td&gt;
&lt;td&gt;1,952 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ratio 1 : 1.7 : 10.4. ML-DSA-65 is unambiguously the big one. The question Phase 3 asks: does that size translate into measurable handshake time?&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology detour: Gatling can't parse ML-DSA certs
&lt;/h2&gt;

&lt;p&gt;The first attempt used Gatling for consistency with Phase 1/2. It failed immediately on the ML-DSA arm: 100% handshake failures. JDK 21's X509 parser rejects the ML-DSA-65 public key OID (&lt;code&gt;2.16.840.1.101.3.4.3.17&lt;/code&gt;) during certificate deserialization, which happens &lt;strong&gt;before&lt;/strong&gt; any trust-all client config can take effect. Even with &lt;code&gt;-Dgatling.http.ssl.trustAll=true&lt;/code&gt;, the handshake never gets past the Certificate message.&lt;/p&gt;

&lt;p&gt;OpenSSL 3.5 handles all three sig algorithms natively, so Phase 3 switched the measurement tool from Gatling to &lt;code&gt;openssl s_time&lt;/code&gt;. Trade-off: no per-handshake percentile data, just aggregate mean (count × wall time → average). For a cert-size experiment that's acceptable — the signal shows up as a mean delta, not a tail distribution. Switching tools mid-phase was ugly but is itself a real-world finding worth noting: &lt;strong&gt;the client-side tooling that will dominate real-world PQ TLS adoption is still catching up to the server side.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Mean handshake time per trial, across all 27 trials:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Region&lt;/th&gt;
&lt;th&gt;ecdsa&lt;/th&gt;
&lt;th&gt;rsa-2048&lt;/th&gt;
&lt;th&gt;ml-dsa-65&lt;/th&gt;
&lt;th&gt;Max spread&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;us-east-1 (~1 ms)&lt;/td&gt;
&lt;td&gt;1.67 ms&lt;/td&gt;
&lt;td&gt;2.86 ms&lt;/td&gt;
&lt;td&gt;3.16 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.49 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;us-west-2 (~70 ms)&lt;/td&gt;
&lt;td&gt;209.9 ms&lt;/td&gt;
&lt;td&gt;211.6 ms&lt;/td&gt;
&lt;td&gt;211.3 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.7 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ap-northeast-1 (~148 ms)&lt;/td&gt;
&lt;td&gt;445.3 ms&lt;/td&gt;
&lt;td&gt;444.2 ms&lt;/td&gt;
&lt;td&gt;445.3 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.1 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At same-AZ, there is a measurable delta: ML-DSA-65 adds about 1.5 ms over ECDSA (+89%). That matches intuition — the extra cert bytes and the extra signature parse/verify CPU time cost about 1.5 ms when everything is sub-millisecond.&lt;/p&gt;

&lt;p&gt;At 70 ms RTT, the delta collapses to &lt;strong&gt;0.7%&lt;/strong&gt;. At 148 ms RTT, it is &lt;strong&gt;statistically zero&lt;/strong&gt; — ECDSA and ML-DSA-65 numbers are identical to one decimal place.&lt;/p&gt;

&lt;p&gt;Trial-to-trial standard deviation was &amp;lt;3 ms at every region, so these aren't noisy numbers hiding a signal. The signal just isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why cert size doesn't matter at WAN RTT
&lt;/h2&gt;

&lt;p&gt;TCP's initial congestion window (&lt;code&gt;initcwnd&lt;/code&gt;) on modern Linux kernels is &lt;strong&gt;10 segments ≈ 14.6 kB&lt;/strong&gt; (10 × ~1460-byte MSS). That's the amount of data a server can send in the very first flight after the handshake completes without waiting for the client to ACK anything.&lt;/p&gt;

&lt;p&gt;A full TLS 1.3 ServerHello carrying the entire ML-DSA-65 cert chain is around 7.7 kB. &lt;strong&gt;Smaller than &lt;code&gt;initcwnd&lt;/code&gt;.&lt;/strong&gt; Which means the server delivers the whole thing in a single flight, in a single round-trip, regardless of whether it's a 700 B ECDSA cert or a 7.7 kB ML-DSA cert. No extra round-trip to pay for.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;would&lt;/em&gt; break this: a cert bigger than &lt;code&gt;initcwnd&lt;/code&gt;. SLH-DSA signatures are ~17 kB — comfortably past the limit. That's the one PQ sig algorithm where cert size &lt;em&gt;should&lt;/em&gt; measurably hurt at WAN RTT. That’s on the Phase 4 agenda.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the Phase 2 1M-payload note
&lt;/h2&gt;

&lt;p&gt;Part 2 flagged that the 1 MB payload arm failed because of NIC (network interface card) saturation — 200 users/sec × 10 reqs × 1 MB ≈ 16 Gbps is well above the c7g.large’s sustained throughput. The measurement was bandwidth-bound, not crypto-bound. As promised, here’s the rerun at a low rate.&lt;/p&gt;

&lt;p&gt;Rerun done at 20 users/sec × 10 reqs × 1 MB ≈ 1.6 Gbps (within instance burst envelope):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Arm&lt;/th&gt;
&lt;th&gt;req_total&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;th&gt;max&lt;/th&gt;
&lt;th&gt;% success&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;classical-1M&lt;/td&gt;
&lt;td&gt;60,000&lt;/td&gt;
&lt;td&gt;2 ms&lt;/td&gt;
&lt;td&gt;2.33 ms&lt;/td&gt;
&lt;td&gt;5.33 ms&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pq-1M&lt;/td&gt;
&lt;td&gt;60,000&lt;/td&gt;
&lt;td&gt;2 ms&lt;/td&gt;
&lt;td&gt;2.00 ms&lt;/td&gt;
&lt;td&gt;5.33 ms&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Classical and PQ are statistically identical at every percentile, confirming the Phase 2 failure was 100% NIC saturation. Once the handshake completes, every byte of application data is encrypted with AES-256-GCM regardless of which KEM negotiated the key — so the KEM choice stops mattering the moment the handshake amortizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes
&lt;/h2&gt;

&lt;p&gt;Three places PQ TLS overhead could show up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Handshake KEM&lt;/strong&gt;: ~1 ms at same-AZ (Part 1), &lt;strong&gt;0 ms when resumed&lt;/strong&gt; (Part 2)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cert chain size&lt;/strong&gt;: 1.5 ms at same-AZ, &lt;strong&gt;0 ms at any WAN RTT&lt;/strong&gt; (Part 3)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record layer&lt;/strong&gt;: identical across KEMs regardless of payload size (Part 2 + 3)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Across all three levers in all three parts, &lt;strong&gt;the only consistently measurable PQ cost is Part 1's +1 ms fresh handshake at low RTT&lt;/strong&gt;. Everywhere else, PQ hides inside the regular protocol overhead.&lt;/p&gt;

&lt;p&gt;If you’re planning the migration to X25519MLKEM768 + ML-DSA-65 and bracing for a user-perceived latency hit at WAN RTT because of certificate size, the data suggests you can stop worrying about that specific lever.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few honest caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This finding breaks if the cert gets bigger than ~15 kB.&lt;/strong&gt; TCP ships the first ~15 kB of data in one round-trip; ML-DSA’s 7.7 kB fits comfortably. SLH-DSA signatures are around 17 kB — big enough to need a second round-trip, and that would show up at WAN RTT. Different signature, different story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signing takes CPU time too, not just bandwidth.&lt;/strong&gt; ML-DSA signing on c7g.large is a few hundred microseconds — too small to notice next to a 70 ms round-trip. On smaller machines (think Raspberry Pi–class hardware or t4g.small), the CPU cost would be a bigger slice of the total and might become visible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three trials per arm is enough for a solid mean,&lt;/strong&gt; but tight for tail behavior. For p99-level claims I’d want 10+ trials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This measures the TLS handshake only.&lt;/strong&gt; The rest of the PQ cert story — longer chains when an intermediate CA is also signed with ML-DSA, bigger OCSP/CRL fetches to check revocation — is a different experiment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Phase 4 candidates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SLH-DSA cert (~17 kB) at WAN RTT&lt;/strong&gt; — the one PQ sig that should break the &lt;code&gt;initcwnd&lt;/code&gt; boundary and finally show the cert-size effect people expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-architecture&lt;/strong&gt;: c7g (Graviton3) vs c7i (Sapphire Rapids) vs c6a (AMD). Does the ML-DSA signing-side CPU cost change the picture on x86?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realistic client mix&lt;/strong&gt;: 80% resumed + 20% fresh across varying RTT, weighted average.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code and raw results on &lt;a href="https://github.com/asifmahbubnaeem/pqc-bench" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. To reproduce the cert-size scenario: see &lt;code&gt;scenarios/04-cert-chain-size/&lt;/code&gt; and &lt;code&gt;docs/03-phase-3-walkthrough-night1.md&lt;/code&gt;. Cost for all 27 trials across 3 regions: about $0.75 on on-demand c7g.large.&lt;/p&gt;

&lt;p&gt;Full series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-1-measuring-x25519mlkem768-costs-on-a-004hour-graviton3-2b2d"&gt;Part 1 — Fresh-handshake latency&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-2-where-the-x25519mlkem768-overhead-actually-matters-5hli"&gt;Part 2 — Session resumption + payload sweep&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Part 3 — this post&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cryptography</category>
      <category>performance</category>
      <category>postquantum</category>
      <category>tls</category>
    </item>
    <item>
      <title>PQC-Bench Part 2: Where the X25519MLKEM768 overhead actually matters</title>
      <dc:creator>asif naeem</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:22:41 +0000</pubDate>
      <link>https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-2-where-the-x25519mlkem768-overhead-actually-matters-5hli</link>
      <guid>https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-2-where-the-x25519mlkem768-overhead-actually-matters-5hli</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-1-measuring-x25519mlkem768-costs-on-a-004hour-graviton3-2b2d"&gt;Part 1&lt;/a&gt; ran a fresh-handshake load test on a Graviton3 and measured what the ML-KEM half of X25519MLKEM768 adds to TLS 1.3 handshake latency. The headline was that hybrid PQ costs about an order of magnitude more at p95 under a stream of brand-new connections.&lt;/p&gt;

&lt;p&gt;That's a correct number for a benchmark almost nobody runs in production.&lt;/p&gt;

&lt;p&gt;Real HTTPS traffic doesn't handshake once per request. Browsers pool connections. Load balancers hold them open. TLS session tickets let returning clients skip the key exchange entirely. So the question Part 1 left open is: once we measure in a shape that looks like production, what does the PQ cost actually become?&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed from Phase 1
&lt;/h2&gt;

&lt;p&gt;Same infra, two toggles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server (&lt;code&gt;terraform/target/user-data-fast.sh.tftpl&lt;/code&gt;):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;ssl_session_cache&lt;/span&gt;   &lt;span class="s"&gt;shared:SSL:10m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ssl_session_timeout&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ssl_session_tickets&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Client (Gatling):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shareConnections&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;// ← the key change&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;disableCaching&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;shareConnections()&lt;/code&gt;, each virtual user does N requests over one TLS session instead of opening a new connection per request. That's how production clients behave.&lt;/p&gt;

&lt;p&gt;Everything else is identical to Phase 1: c7g.large Graviton3 target running nginx-pq against the same c7g.large loadgen, OpenSSL 3.5, TLS 1.3, hybrid X25519MLKEM768 KEM, ML-DSA-65 server cert for PQ-capable clients and an ECDSA P-256 cert for BoringSSL-based ones. Full Phase 2 code is on &lt;a href="https://github.com/asifmahbubnaeem/pqc-bench" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two scenarios on top of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 02 — session resumption
&lt;/h2&gt;

&lt;p&gt;Four arms: &lt;code&gt;classical-fresh&lt;/code&gt;, &lt;code&gt;classical-resumed&lt;/code&gt;, &lt;code&gt;pq-fresh&lt;/code&gt;, &lt;code&gt;pq-resumed&lt;/code&gt;. 300 user-connections per second, 10 requests per user, 5-minute measurement, 3 trials per arm, 12 trials total.&lt;/p&gt;

&lt;p&gt;The p95 for each arm, averaged across trials:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;p95 (ms)&lt;/th&gt;
&lt;th&gt;p99 (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;classical-fresh&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;~11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;classical-resumed&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pq-fresh&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;~6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pq-resumed&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;p99 is noisy with 3 trials — the apparent PQ-faster-than-classical result at fresh p99 is within trial variance, not a real effect&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fresh PQ adds &lt;strong&gt;1 ms at p95&lt;/strong&gt;. That's smaller than Part 1's number, mostly because Phase 2 uses hybrid session-cache-enabled nginx-pq with a lighter config path for the sub-ms responses. The &lt;em&gt;relative&lt;/em&gt; cost (+25% vs classical fresh) is still real.&lt;/p&gt;

&lt;p&gt;Resumed PQ adds &lt;strong&gt;nothing&lt;/strong&gt;. The resumed numbers for classical and PQ are byte-for-byte identical: 1 ms at p95, 1 ms at p99.&lt;/p&gt;

&lt;p&gt;Scenario 02 — response time by percentile, PQ overhead fresh vs resumed&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiwg4yy9lx7mqcmz6ow4k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiwg4yy9lx7mqcmz6ow4k.png" alt="response time by percentile, PQ overhead fresh vs resumed" width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This makes sense once we remember what resumption does. A resumed TLS 1.3 session skips the key-exchange exchange entirely: the client proves it has the master secret via a PSK and the connection picks up where it left off. ML-KEM, X25519, ECDH — all irrelevant after the first handshake. From there every record is wrapped in AES-256-GCM, which doesn't care which KEM negotiated the keys that derived the GCM key.&lt;/p&gt;

&lt;p&gt;The resumption gain itself (3-4 ms off the p95) is bigger than the PQ cost (1 ms added). Any production fleet already running session resumption gets PQ migration essentially for free at this percentile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 03 — payload sweep
&lt;/h2&gt;

&lt;p&gt;Six arms: &lt;code&gt;classical&lt;/code&gt; and &lt;code&gt;pq&lt;/code&gt; × response sizes 100B, 10K, 100K. All connections resumed (&lt;code&gt;shareConnections&lt;/code&gt; on, 10 requests per user). 200 users per second, 5-minute measurement, 3 trials per arm.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;p95 (ms)&lt;/th&gt;
&lt;th&gt;max (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;classical-100B&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;classical-10K&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5.33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;classical-100K&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7.33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pq-100B&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;6.33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pq-10K&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5.67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pq-100K&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;6.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Identical at every percentile. Max response time wobbles 5.33 → 7.33 ms but the spread is network jitter, not a classical-vs-PQ signal.&lt;/p&gt;

&lt;p&gt;Scenario 03 — p95 across payload sizes, PQ overhead flat at zero&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7oolgisw5qz8lmn63fwr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7oolgisw5qz8lmn63fwr.png" alt="p95 across payload sizes, PQ overhead" width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same reason as before. Once the handshake amortizes over 10 requests, what is left is pure record-layer throughput, and the record layer is identical for each group. The AES-GCM path through c7g.large is the same whether the master secret came from X25519 or X25519MLKEM768.&lt;/p&gt;

&lt;p&gt;The 1M arm I wanted to add didn't produce usable data: 200 users/sec × 10 requests × 1 MB = ~16 Gbps steady aggregate throughput, and c7g.large has a 0.75 Gbps NIC baseline (12.5 Gbps burst, but nothing like 16 Gbps sustained). The target hit NIC saturation and the trials filled the 60s-timeout bucket. That measurement was bandwidth-bound, not crypto-bound. 1M belongs in its own low-rate scenario - targeting for next phase.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes
&lt;/h2&gt;

&lt;p&gt;Three places PQ overhead can show up in a TLS deployment: handshake, cert chain, record layer. In Phase 2, we have measured two of them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Handshake:&lt;/strong&gt; 1 ms added at p95 for fresh connections. Zero for resumed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record layer:&lt;/strong&gt; no measurable difference at any payload size up to 100 KB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which leaves cert-chain size — ML-DSA-65 signatures (size around ~3.3 kB ) vs ECDSA (~70 B) — as the remaining lever, and that one lands on cold or infrequently-resumed clients where the bigger &lt;code&gt;ServerHello&lt;/code&gt; matters. That could be our next part (phase 3) area.&lt;/p&gt;

&lt;p&gt;If we believe our production traffic looks more like scenario 02 and scenario 03 than like Phase 1 — and for most HTTPS services behind a connection pool, it does — then the practical cost of migrating to X25519MLKEM768 on current ARM server hardware is on the order of 1 ms at p95, paid once per client per session cache lifetime.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few honest caveats
&lt;/h2&gt;

&lt;p&gt;Gatling 3.15 reports response times in integer milliseconds, so sub-millisecond latencies round down to zero. The mean, p50, and p75 columns in both scenarios read as 0 ms for most arms — that is a measurement limitation, not real floor behavior. At p95 and above the numbers are above 1 ms and real, which is what the comparison relies on.&lt;/p&gt;

&lt;p&gt;Three trials per arm is enough for the clean comparisons made here but tight for tail percentiles. The p99 column has noise comparable to the signal; I'd want 10 trials before saying anything confidently about p99 behavior.&lt;/p&gt;

&lt;p&gt;NIC saturation at 1M payloads means Phase 2 is silent on how PQ scales under high-bandwidth sustained workloads. That is a different question to answer with different setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Phase 3 candidates, roughly ordered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cert-chain size impact&lt;/strong&gt;: how much does the ~3 kB ML-DSA-65 signature cost at the first-byte percentile across varying RTT? ML-DSA-65 vs ECDSA, same target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1M payload at a sane rate&lt;/strong&gt; (~20 rps) — bandwidth characterization, not primarily crypto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realistic client mix&lt;/strong&gt;: 80% resumed + 20% fresh, measuring aggregate p95 as a weighted average the way CDN telemetry would see it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code and raw results for Phase 2 are on &lt;a href="https://github.com/asifmahbubnaeem/pqc-bench" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. To reproduce: &lt;code&gt;./scenarios/02-resumption/run.sh 300 3 10&lt;/code&gt; and &lt;code&gt;./scenarios/03-payload-sweep/run.sh 200 3 10&lt;/code&gt;. Total cost on on-demand c7g.large is about $0.50. Based on my experience it can and will interrupt a multi-hour run.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>performance</category>
      <category>tls</category>
      <category>postquantum</category>
    </item>
    <item>
      <title>PQC-Bench Part 1 — measuring X25519MLKEM768 costs on a $0.04/hour Graviton3</title>
      <dc:creator>asif naeem</dc:creator>
      <pubDate>Mon, 28 Sep 2026 19:46:30 +0000</pubDate>
      <link>https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-1-measuring-x25519mlkem768-costs-on-a-004hour-graviton3-2b2d</link>
      <guid>https://dev.to/asif_naeem_bd5842be56bfcd/pqc-bench-part-1-measuring-x25519mlkem768-costs-on-a-004hour-graviton3-2b2d</guid>
      <description>&lt;p&gt;There's a lot of discussion going on about post-quantum TLS. Both AWS and Cloudflare claim the overhead is minimal — but both are vendor-published, on infrastructure most people don't have. This is the starting post of a different attempt: an independent, reproducible benchmark of &lt;code&gt;X25519MLKEM768&lt;/code&gt; (the NIST-standardized hybrid PQ key exchange, FIPS 203) versus classical &lt;code&gt;X25519&lt;/code&gt;. I ran it on two &lt;code&gt;c7g.large&lt;/code&gt; EC2 instances in the same AWS zone. The full run cost less than a dollar.&lt;/p&gt;

&lt;p&gt;Complete setup — Terraform, Gatling simulation, driver script, raw Gatling result files — is &lt;a href="https://github.com/asifmahbubnaeem/pqc-bench/tree/main" rel="noopener noreferrer"&gt;in the repo&lt;/a&gt;. This can be reproduced in around ~50 minutes for under $0.30 (based on my experience, accounting for the occasional disruption during a test).&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Target&lt;/td&gt;
&lt;td&gt;c7g.large spot, Ubuntu 24.04, nginx built from source against OpenSSL 3.5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load generator&lt;/td&gt;
&lt;td&gt;c7g.large spot, JDK 21, Gatling 3.15.1 (Java DSL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Placement&lt;/td&gt;
&lt;td&gt;Same subnet, same AZ (us-east-1) — cross-AZ latency would swamp the effect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;TLS 1.3 only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cipher suites&lt;/td&gt;
&lt;td&gt;TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server cert&lt;/td&gt;
&lt;td&gt;Self-signed ECDSA P-256&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KEM (A/B knob)&lt;/td&gt;
&lt;td&gt;X25519 vs X25519MLKEM768&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response body&lt;/td&gt;
&lt;td&gt;3 bytes ("OK\n") — the point is handshake, not transfer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session resumption&lt;/td&gt;
&lt;td&gt;Disabled (fresh handshakes only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trials&lt;/td&gt;
&lt;td&gt;3 per arm, 5 min each, 30 s warmup dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Note on the self-signed cert
&lt;/h3&gt;

&lt;p&gt;The server serves an &lt;strong&gt;ECDSA cert&lt;/strong&gt;, not an ML-DSA-65 one.&lt;/p&gt;

&lt;p&gt;The reason: Gatling uses Netty's BoringSSL under the hood, and BoringSSL doesn't advertise ML-DSA-65 in its supported signature algorithms. If nginx only had an ML-DSA-65 cert to serve, BoringSSL wouldn't be able to complete the handshake at all.&lt;/p&gt;

&lt;p&gt;This actually mirrors real-world PQ TLS in 2026: no public CA issues ML-DSA-65 certs. Everyone shipping "PQ TLS" today serves a classical cert with a hybrid KEM. This benchmark measures the KEM overhead under exactly that pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  First attempt: what didn't work
&lt;/h2&gt;

&lt;p&gt;My first serious run was at 1000 req/s. The numbers were absurd:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Classical&lt;/th&gt;
&lt;th&gt;PQ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;min&lt;/td&gt;
&lt;td&gt;1 ms&lt;/td&gt;
&lt;td&gt;1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;mean&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,318 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,230 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;6,440 ms&lt;/td&gt;
&lt;td&gt;6,091 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;max&lt;/td&gt;
&lt;td&gt;7,648 ms&lt;/td&gt;
&lt;td&gt;7,548 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not "PQ is faster than classical" — that's not physically possible when the PQ arm does the same work as classical plus more crypto. The shape (min=1ms, mean=1300ms) is the fingerprint of &lt;strong&gt;client-side CPU saturation&lt;/strong&gt;. 1000 fresh handshakes/sec on a 2-vCPU loadgen means each vCPU is doing 500 handshakes/sec of X25519 or ML-KEM math — beyond a c7g.large's capacity. Requests queue for CPU, means shoot into the seconds range, and any actual PQ vs classical delta gets swallowed by queuing noise.&lt;/p&gt;

&lt;p&gt;Cross-check: PQ p99 across the 3 trials ranged 5892 → 6091 → 7623 ms. That trial-to-trial variance is bigger than any real signal I could measure — a red flag on its own.&lt;/p&gt;

&lt;p&gt;Lesson: &lt;strong&gt;benchmark your load generator before you trust its numbers.&lt;/strong&gt; Client-side CPU cost is easy to forget when the discussion is all about server overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real numbers, at 300 req/s
&lt;/h2&gt;

&lt;p&gt;Dropping to 300 req/s puts the loadgen well inside its capacity envelope. Now I'm measuring the handshake itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Median of 3 trials, latency in milliseconds:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Classical (X25519)&lt;/th&gt;
&lt;th&gt;PQ (X25519MLKEM768)&lt;/th&gt;
&lt;th&gt;Overhead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;min&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mean&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;+1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+3 ms (+60%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;max&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+27 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Trial-by-trial p99&lt;/strong&gt; (to show the variance honestly):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trial&lt;/th&gt;
&lt;th&gt;Classical p99&lt;/th&gt;
&lt;th&gt;PQ p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Trial 3's PQ p99 of 39 ms is a real outlier — likely a spot instance CPU steal event or a JVM GC pause, though I haven't isolated the cause yet. Something to nail down in Phase 2 with more trials.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this tells me
&lt;/h2&gt;

&lt;p&gt;Three findings worth stating:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. In the common case, PQ overhead is essentially invisible.&lt;/strong&gt; At 300 req/s on Graviton3, &lt;code&gt;X25519MLKEM768&lt;/code&gt; costs the same 2 ms mean handshake as classical &lt;code&gt;X25519&lt;/code&gt;. p50 and p95 are the same at ms-resolution. If your SLO is "handshake under 50 ms", PQ vs classical is not what you should be worrying about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The cost shows up in the tail.&lt;/strong&gt; Median p99 grew from 5 ms to 8 ms. In one of three trials the PQ p99 was 39 ms — 8x what the median trial saw. This matches what I expected theoretically: KEM operations have more variance than raw X25519, and worst-case scheduling amplifies the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. This is a floor, not a ceiling.&lt;/strong&gt; At sub-10 ms latencies, Gatling's integer-millisecond quantization hides sub-ms differences. The "no difference" reading at mean/p50/p95 may be masking real ~0.5 ms deltas. Higher-precision timing and higher throughput on beefier instances would tell more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I can't say from this run
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How PQ overhead scales with concurrent connection count.&lt;/strong&gt; All measurement here is at a fixed rate with the loadgen sized carefully to avoid saturation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens with session resumption.&lt;/strong&gt; Session resumption skips the KEM entirely. Real traffic is a mix — measuring just the fresh case is the worst case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether Graviton3's specific silicon helps or hurts PQ.&lt;/strong&gt; Would need x86 (c7i.large) or AMD (c6a.large) runs for comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything about payload size scaling.&lt;/strong&gt; Every response was 3 bytes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these are Phase 2 targets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/asifmahbubnaeem/pqc-bench
&lt;span class="nb"&gt;cd &lt;/span&gt;pqc-bench
&lt;span class="c"&gt;# Configure AWS profile + AMI IDs in terraform/bench/terraform.tfvars&lt;/span&gt;
make bench-up
make wait-ready
./scenarios/01-fresh-handshake-latency/run.sh 300 3
make bench-down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wall time: ~50 min. AWS cost: ~$0.15. The raw Gatling result trees, including per-trial HTML reports and simulation.log files, can be found in &lt;code&gt;results/2026-09-27-scenario-01-rate300/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Phase 2 questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Payload sweep&lt;/strong&gt;: 100 B / 1 KB / 100 KB / 1 MB response. Does handshake overhead disappear into transfer time?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session resumption&lt;/strong&gt;: Full mix, 0-RTT, 1-RTT. What's the effective overhead when 90% of connections resume?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-architecture&lt;/strong&gt;: Same benchmark on c7g (Graviton3, ARM), c7i (Intel Sapphire Rapids), c6a (AMD EPYC). Does the KEM math favor any one microarchitecture?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency sweep&lt;/strong&gt;: 100 / 500 / 2000 concurrent connections at fixed rate — where does saturation actually hit, and does the answer differ for PQ vs classical?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll post those over the coming weekends. If there's a specific angle you'd want measured, &lt;a href="mailto:asifnaim0123@gmail.com"&gt;contact me&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Data, methodology and all scripts live in the repo. Corrections and independent reruns very welcome — that's the whole point.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Draft assistance from Claude; all measurements, interpretations, and the setup work are mine and independently verified against the raw data in the repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tls</category>
      <category>security</category>
      <category>aws</category>
      <category>benchmark</category>
    </item>
  </channel>
</rss>
