<?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>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>
