Avatar

In Part 6 we met the post-quantum signatures and learned why authentication has a sneakier quantum deadline than key exchange. Now let’s get tactile. We’re going to generate real post-quantum keys and certificates with our own hands, line them up next to the classics, and weigh them. The size story is the single most important thing to internalise about post-quantum authentication, and the best part is you measure it yourself with nothing fancier than ls. Everything runs in a throwaway alpine container with OpenSSL, so all you need is Docker.


Tooling

OpenSSL 3.5+ is the first mainstream OpenSSL with native support for all three NIST PQC algorithms (ML-KEM, ML-DSA, and SLH-DSA) built right in. No external plugins, no patches, no extra add-ons to install. If you’ve got 3.5 or newer, you generate post-quantum keys and certificates with plain, ordinary OpenSSL commands, the same kind you’d use for any classical key (check your version with openssl version).

Wait, if OpenSSL does ML-KEM, why did we use strongSwan for the key exchange? Fair question. OpenSSL is a crypto library, not a VPN; its PQC support is wired into TLS and certificates, not IKEv2. So OpenSSL is perfect for making and inspecting the keys and certificates here, but it can’t run an IKEv2 handshake. That’s why anything happening on the actual VPN wire (the key exchange in Parts 4–5, and the authentication coming up in Part 8) is strongSwan’s job. Two tools, two jobs.

A tiny throwaway container gives us a clean 3.5+ environment in seconds:

docker run --rm -it alpine:3.22 sh

Then inside you install openssl:

apk add --no-cache openssl
openssl version          # expect OpenSSL 3.5.x or newer
mkdir -p /pqauth && cd /pqauth

All commands below run in that same shell.


Step 1: Confirm the PQC algorithms are there

openssl list -signature-algorithms | grep -iE "ml-dsa|slh-dsa|ed25519"

You’ll see the ML-DSA and SLH-DSA families listed, for example:

{ 1.3.101.112, ED25519 } @ default
{ 2.16.840.1.101.3.4.3.18, id-ml-dsa-65, ML-DSA-65, MLDSA65 } @ default
{ 2.16.840.1.101.3.4.3.20, id-slh-dsa-sha2-128s, SLH-DSA-SHA2-128s } @ default
...

There they are: straight from OpenSSL’s default provider, no plugins required.


Step 2: Mint a self-signed cert

We’ll generate a key and a self-signed certificate for eight algorithms in one loop. Quick note on the yardstick: a self-signed certificate (subject = issuer, signed with its own key) is the cleanest possible comparison: it bundles exactly one public key and one signature with no external CA, so the size reflects only the algorithm’s footprint. We also output DER (the compact binary encoding that actually travels on the wire), not the base64 PEM text you usually see.

algs="ED25519:ed25519 EC:ecp256 RSA:rsa3072 ML-DSA-44:mldsa44 ML-DSA-65:mldsa65 ML-DSA-87:mldsa87 SLH-DSA-SHA2-128s:slh128s SLH-DSA-SHA2-128f:slh128f"

for entry in $algs; do
    alg=${entry%%:*}; name=${entry##*:}
    case "$alg" in
        RSA) openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out "$name.key" ;;
        EC)  openssl genpkey -algorithm EC  -pkeyopt ec_paramgen_curve:P-256 -out "$name.key" ;;
        *)   openssl genpkey -algorithm "$alg" -out "$name.key" ;;
    esac
    openssl req -x509 -new -key "$name.key" -out "$name.crt" -days 365 -subj "/CN=$name-peer" -outform DER
done

Notice that for the post-quantum algorithms, generating a key uses the exact same one-liner as a classical one; only the algorithm name changes. That’s the beauty of OpenSSL 3.5: post-quantum isn’t a separate, special workflow, it’s just another algorithm you pass to the same command.


Step 3: Weigh them

ls -la *.crt | awk '{print $5"  "$9}' | sort -n

Brace yourself:

326    ed25519.crt
389    ecp256.crt
1043   rsa3072.crt
3987   mldsa44.crt
5516   mldsa65.crt
7474   mldsa87.crt
8139   slh128s.crt
17371  slh128f.crt

Look at that jump! Let’s put it in plain terms with the underlying numbers:

Algorithm Security Public key Signature Cert (DER)
Ed25519 ~128-bit 32 B 64 B 326 B
ECDSA P-256 ~128-bit 65 B ~70 B 389 B
RSA-3072 ~128-bit 384 B 384 B 1043 B
ML-DSA-44 NIST L2 1312 B 2420 B 3987 B
ML-DSA-65 NIST L3 1952 B 3309 B 5516 B
ML-DSA-87 NIST L5 2592 B 4627 B 7474 B
SLH-DSA-128s NIST L1 32 B 7856 B 8139 B
SLH-DSA-128f NIST L1 32 B 17088 B 17371 B

An Ed25519 certificate is just 326 bytes. The equivalent ML-DSA-65 cert is 5516 bytes, roughly 17× larger. And SLH-DSA-128f? Its signature alone is 17088 bytes, bigger than many entire web pages. Notice SLH-DSA’s quirk too: a tiny 32-byte public key, but a gigantic signature. Whoa.

Wait, why isn’t the cert just key + signature? Good catch. For ML-DSA-65, key (1952 B) + signature (3309 B) = 5261 B, yet the cert is 5516 B, about 255 B more. A certificate isn’t a simple concatenation; it’s a structured X.509 document embedding the key and signature alongside metadata (version, serial, validity dates, issuer/subject names, algorithm OIDs, a few extensions, plus ASN.1/DER framing on every field). That overhead is roughly fixed (~230–260 B) regardless of algorithm, which is why it dominates a tiny Ed25519 cert but barely registers on a chunky ML-DSA one.


Why this size story matters

Handshakes don’t carry one cert; they carry certificate chains plus a handshake signature. Swap a 3-cert ECDSA chain (~1.2 KB total) for an ML-DSA-65 chain and you’re suddenly shipping 15–20 KB in the handshake. In IKEv2 that means the IKE_AUTH exchange balloons and leans hard on fragmentation: the same pressure ML-KEM put on IKE_INTERMEDIATE back in the key-exchange pillar, but now on the authentication leg.

And “leaning hard on fragmentation” isn’t just a minor detail. Fragmentation was designed as an occasional fallback for the rare oversized message; post-quantum flips that so almost every handshake is large and fragmented. That brings real costs:

  • Packet loss hurts more. An N-fragment message reassembles only if all N arrive; lose one and the whole thing retransmits. On lossy links, big PQC handshakes retransmit more and latency spikes.
  • Middleboxes are hostile to fragments. Firewalls, NATs, and load balancers routinely drop or mishandle fragmented UDP, producing handshakes that fail in really hard-to-debug ways.
  • Bigger pre-auth attack surface. These bytes fly before the peer has proven who it is, so a responder must buffer and reassemble fragments from a not-yet-authenticated initiator: extra state an attacker can try to overload.

None of this breaks PQC auth (strongSwan handles it), but it’s why “the certs got bigger” turns into round trips, retransmits, and middlebox trouble at scale. We’ll watch exactly this happen on the wire in Part 8.


Step 4: Are they slow?

A common worry, so before we sign anything ourselves (we’ll do that in Step 5), here’s what to expect per signature:

Algorithm Signing Notes
Ed25519 / ECDSA ~instant so fast the timing is dominated by just launching the command
ML-DSA-44/65/87 ~instant sits right alongside the classics
SLH-DSA-128f a few ms noticeably heavier
SLH-DSA-128s ~100+ ms dramatically slower to sign

The takeaway: ML-DSA signs and verifies right alongside Ed25519 and ECDSA, while SLH-DSA’s s (“small”) variants are hugely slower. That slowness is exactly why SLH-DSA is reserved for things you sign rarely (a root CA signs a handful of certs a year, so who cares if each takes 100 ms?) rather than a busy TLS terminator doing thousands of handshakes a second. You’ll feel that difference for yourself with the time command in Step 5.


Step 5: Sign, verify, tamper

Certificates are about trust, and trust comes from signatures that can’t be forged. Let’s prove it.

echo "transfer 1000 to tom" > msg.txt
openssl pkeyutl -sign -inkey mldsa65.key -rawin -in msg.txt -out msg.sig
wc -c < msg.sig          # ~3309 bytes

# Pull out the public half and verify with it
openssl pkey -in mldsa65.key -pubout -out mldsa65.pub
openssl pkeyutl -verify -pubin -inkey mldsa65.pub -rawin -in msg.txt -sigfile msg.sig

You should see Signature Verified Successfully. Note the clean split: signing used the private key, verifying used only the public key. That’s the whole point of public-key signatures: signing is private, verifying is public.

Now tamper with the message and watch it fail:

echo "transfer 1000 to mallory" > msg.txt
openssl pkeyutl -verify -pubin -inkey mldsa65.pub -rawin -in msg.txt -sigfile msg.sig

Signature Verification Failure. Change a single byte and the post-quantum signature rejects it: exactly the unforgeable integrity guarantee we depend on, now resting on lattice math instead of elliptic curves.

Want to feel the SLH-DSA difference? Time it:

time openssl pkeyutl -sign -inkey slh128s.key -rawin -in msg.txt -out slh.sig
wc -c < slh.sig          # ~7856 bytes

Noticeably slower, and the signature more than twice the ML-DSA size. That’s the hash-based trade-off in action: ultra-cautious security, but you’d never want it on a high-volume handshake.


What we just did

You generated post-quantum certificates, measured the size hit with your own hands, watched ML-DSA and SLH-DSA sign and verify (and reject a forgery), and felt the SLH-DSA speed penalty. The “certs get bigger” story is no longer something you read; it’s something you weighed.

But certificates sitting in a folder are only half the fun. In Part 8 we hand these certs to strongSwan and use them to mutually authenticate a real IKEv2 VPN tunnel: classical ECDSA first, then bleeding-edge post-quantum ML-DSA. We’ll watch the handshake balloon and fragment exactly as predicted, and stand right at the bleeding edge where this stuff is still being built. See you there!

Authors

Julio Gomez

Principal Engineer, AI & Quantum

Office of the CTO