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_INITonly 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 untilIKE_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_INTERMEDIATEas#43? It does not have a name for exchange type 43 (the number RFC 9370 assigned), so it shows the raw value. Seeing#43is 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!