Avatar

This is the one we’ve been building toward. In Parts 2 and 3 we talked through the theory: the threat, the contenders, the hybrid solution. Now we run it. Two containers, a real IKEv2 handshake, real packets, and a side-by-side comparison that turns every table from last post into something you can see with your own eyes.

Everything runs locally on your own workstation: two Docker containers talking over a private bridge network.


Clone the repo

First, grab the repo and step into this lab’s directory. All commands below run from key-exchange/:

git clone https://github.com/juliogomez/pqc.git
cd pqc/key-exchange

Build and start

# Build images locally (~5 min first run, compiles strongSwan from source)
docker compose build

# Start both containers
docker compose up -d

# Verify both are running
docker compose ps

Once the healthcheck passes you’ll see both peers Up (healthy):

NAME            IMAGE                    STATUS
ike-initiator   key-exchange-initiator   Up (healthy)
ike-responder   key-exchange-responder   Up (healthy)

These two containers are our VPN peers. Let’s make them talk.


Step 1: Confirm ML-KEM is available

Shell into the initiator (all swanctl commands below run from inside it):

docker exec -it ike-initiator bash
swanctl --list-algs

Look for the ML-KEM key exchange methods provided by strongSwan’s ml plugin:

ML_KEM_512[ml]
ML_KEM_768[ml]
ML_KEM_1024[ml]

There it is: post-quantum key exchange, built right into stable strongSwan. (In proposal strings these are written mlkem512 / mlkem768 / mlkem1024.)


Step 2: Start capturing

We want to catch the whole conversation, so start the recorder first. tcpdump runs in the background watching the IKE ports:

tcpdump -i any --immediate-mode -U -n 'port 500 or port 4500' -w /tmp/capture.pcap &
TCPDUMP_PID=$!

Step 3: Fire the handshake

This single command tells the local strongSwan daemon to run the full hybrid negotiation: X25519 in IKE_SA_INIT, then ML-KEM-768 in the extra IKE_INTERMEDIATE, then IKE_AUTH to finish:

swanctl --initiate --child pqc-child

You should see:

[IKE] initiating IKE_SA pqc-tunnel[1] to 172.20.0.3
[IKE] IKE_SA pqc-tunnel[1] established between 172.20.0.2...172.20.0.3
[IKE] CHILD_SA pqc-child{1} established ...

And just like that, a quantum-resistant tunnel is up. It works! But let’s not take the daemon’s word for it. Let’s inspect what we got:

swanctl --list-sas

The line that matters:

AES_GCM_16-256/PRF_HMAC_SHA2_256/CURVE_25519/KE1_ML_KEM_768

Decoded:

Field Meaning
CURVE_25519 The classical X25519 exchange, done in IKE_SA_INIT.
KE1_ML_KEM_768 ML-KEM-768 as RFC 9370 additional exchange #1, done in IKE_INTERMEDIATE. The 1 means it’s the first add-on.

Both secrets feed the final keys; that’s the hybrid promise from Part 3, confirmed in the running tunnel.


Step 4: Read the packets

Now the fun part. Stop the capture and let the file flush:

kill $TCPDUMP_PID
wait $TCPDUMP_PID 2>/dev/null

Filter the verbose output down to one line per IKE message:

tcpdump -r /tmp/capture.pcap -n -vv 2>/dev/null | grep -E "parent_sa|child_sa"

Here’s what the conversation looks like, annotated:

Packet Direction What it means
1 .2 → .3 IKE_SA_INIT: proposes X25519 + ML-KEM-768
2 .3 → .2 IKE_SA_INIT: responder accepts
3-4 .2 → .3 IKE_INTERMEDIATE: ML-KEM public key (~1250 B, fragmented into two packets)
5 .3 → .2 IKE_INTERMEDIATE response: ML-KEM ciphertext (~1155 B)
6 .2 → .3 IKE_AUTH: encrypted auth + child SA request
7 .3 → .2 IKE_AUTH: encrypted confirmation

A couple of things worth pausing on:

  • IKE_SA_INIT only promises ML-KEM; it doesn’t carry it. That first packet’s key-exchange payload is just the tiny 32-byte X25519 key. The chunky ML-KEM key doesn’t appear until IKE_INTERMEDIATE, exactly the RFC 9370 design: keep the base exchange small and standard, put the big PQC payload in the extra round trip.
  • The 1280-byte ceiling is right there in the capture. RFC 7296 says every IKEv2 implementation must handle messages up to 1280 bytes without IP fragmentation. The ML-KEM-768 public key is only 1184 B, but once you wrap it in key-exchange, encryption, IKE, and UDP/IP headers, it just crosses 1280. So strongSwan splits it at the IKE layer: fragment 1 is sized to land exactly on 1280 B, and the remainder spills into a tiny ~98 B fragment 2. That’s the concrete reason fragmentation is mandatory at the IKE layer.

Why does tcpdump label IKE_INTERMEDIATE as #43? It does not have a name for exchange type 43 (the number RFC 9370 assigned), so it shows the raw value. Seeing #43 is the tell that the hybrid PQC round trip happened.

Tear down before the next experiment:

swanctl --terminate --ike pqc-tunnel
exit

Step 5: Classical vs hybrid

This is the moment the comparison stops being a table. We’ll run the same handshake twice (once pure X25519, once hybrid) on the same containers, same network. The only thing that changes is the proposal string.

The config files ship with both proposal lines pre-provisioned, so switching is just swapping which one is commented out. Shell into each container (in 2 different terminal windows):

docker exec -it ike-initiator bash

docker exec -it ike-responder bash

and edit the config file in both of them:

vi /usr/local/etc/swanctl/swanctl.conf

Classical-only: comment the hybrid line, enable plain X25519, disable fragmentation (those tiny keys don’t need it):

# proposals = aes256gcm16-prfsha256-x25519-ke1_mlkem768
proposals = aes256gcm16-prfsha256-x25519
# fragmentation = yes

Reload in both containers with swanctl --load-all, then from the initiator capture and time the classical run:

tcpdump -i any --immediate-mode -U -n 'port 500 or port 4500' -w /tmp/classical.pcap &
TCPDUMP_PID=$!
sleep 1
time swanctl --initiate --child pqc-child
sleep 1
kill $TCPDUMP_PID; wait $TCPDUMP_PID 2>/dev/null
swanctl --terminate --ike pqc-tunnel

Then flip both configs back to hybrid (uncomment the hybrid proposal and fragmentation = yes), reload, and from the initiator capture again into a different file (/tmp/hybrid.pcap) with the same recipe as before.

Now compare the exchange types:

tcpdump -r /tmp/classical.pcap -n -vv 2>/dev/null | grep -E "parent_sa|child_sa"
tcpdump -r /tmp/hybrid.pcap   -n -vv 2>/dev/null | grep -E "parent_sa|child_sa"
# CLASSICAL: straight SA_INIT → AUTH, 2 round trips, no IKE_INTERMEDIATE
parent_sa ikev2_init[I]    # IKE_SA_INIT request
parent_sa ikev2_init[R]    # IKE_SA_INIT response
child_sa  ikev2_auth[I]    # IKE_AUTH request  ← jumps straight here
child_sa  ikev2_auth[R]    # IKE_AUTH response

# HYBRID: an entire IKE_INTERMEDIATE wedged in, 3 round trips
parent_sa ikev2_init[I]
parent_sa ikev2_init[R]
child_sa  #43[I]           # IKE_INTERMEDIATE fragment 1  ← the ML-KEM public key
child_sa  #43[I]           # IKE_INTERMEDIATE fragment 2
child_sa  #43[R]           # IKE_INTERMEDIATE response     ← the ML-KEM ciphertext
child_sa  ikev2_auth[I]
child_sa  ikev2_auth[R]

And here’s the key findings:

Classical (X25519) Hybrid (X25519 + ML-KEM-768)
Exchanges SA_INIT → AUTH SA_INIT → INTERMEDIATE → AUTH
Extra round trip No Yes
Largest packet 357 B 1252 B (fragmented ML-KEM key)
Fragmented packets None 2
IKE_AUTH size 357 B / 213 B identical: 357 B / 213 B
Handshake time ~20 ms ~21 ms
Quantum-safe

Look at that IKE_AUTH row: byte-for-byte identical in both runs. Every extra byte of the hybrid handshake lives in that one added IKE_INTERMEDIATE round trip carrying ML-KEM. That’s the entire cost of going quantum-safe, laid out line by line.

And the timing? About 1 ms more, virtually all of it the extra network round trip, not crypto. Post-quantum security in IKEv2 is cheap in latency; its main footprint is a couple of kilobytes of bandwidth and one extra round trip. Quantum resistance for the price of a couple of KB? I’ll take it.


Cleanup

swanctl --terminate --ike pqc-tunnel   # inside the container, if a tunnel is up
exit
docker compose down

docker compose down removes the containers and the bridge network but keeps the built images, so your next docker compose up -d starts instantly.


What we just proved

You stood up a hybrid post-quantum VPN tunnel, captured it, and proved, with your own packets, that quantum-safe IKEv2 is both practical and cheap. The three round trips, the fragmentation, the combined secrets, the negligible cost: all of it, no longer a claim but a measurement. Not bad for a couple of containers, huh?

But ML-KEM isn’t the only road to a quantum-safe key exchange. There’s a completely different trick, one that needs no new algorithm at all, and works even on gear too old to speak ML-KEM. In Part 5 we’ll describe that alternative path with a post-quantum preshared key and prove it carries the quantum resistance all on its own. See you there!

Authors

Julio Gomez

Principal Engineer, AI & Quantum

Office of the CTO