I know, I know… I said I was done with the IPsec series, BUT what would this series say about me if I did not automate everything??
In the last few blog posts we reviewed the CLI story: classical baseline, PPK, ML-KEM hybrid, ML-DSA certificates, the atomic cutover. That’s how you LEARN a platform. But that is not how you RUN a fabric.
And if you’ve been hanging around DevNet for a while, you already know the next move. That was the point the whole time: enable the community on programmability so you can stop typing at boxes and start driving them through APIs. YANG, NETCONF, RESTCONF, Ansible: years of that work. API-driven automation was one of the main goals. Let’s now use those habits to configure Post-quantum IPsec!
Type the same IKEv2 profile on three boxes, change two addresses, and you’ll get two routers agreeing and one that doesn’t. You’ll find out a week later. The post-quantum posture is a decision: ML-KEM-768, optional or required, ML-DSA-65. You want to state it once, push it, and prove the live SA actually used it. DONE.
This post is that layer. Same lab, same RFCs, same two pillars. The interface is Ansible over NETCONF instead of three consoles.
All docs and Ansible playbooks are available in my GitHub for your own testing, but if you don’t have three routers in your lab you can still follow. You won’t be typing along unless you’ve got the boxes. What you’ll see is the playbooks we ran, the XML that actually landed, and the show output that came back.
Why this is worth automating #
Go back to Part 9’s hub-and-spoke. The hub needs one IKEv2 profile per spoke. Each spoke needs one toward the hub. The proposal, the transform set, the tunnel, the fragmentation MTU: same lines, two addresses flipped.
Then add PQC and the cost of a typo goes up. Forget “pqc mlkem768 optional” on one spoke and that tunnel stays classical while the others go hybrid. Forget fragmentation and the handshake never completes. Flip authentication on one end of a pair and, as Part 11 showed, the tunnel dies until the other end follows.
Automation is not about saving keystrokes. It’s about stating the posture in one place and refusing to move on until the box agrees.
Config is modelled. Actions are not. #
Here’s the load-bearing fact about IOS XE 26.2, and it shapes everything below.
The post-quantum configuration is real YANG. Not CLI strings shipped over a fancier wire. Named leaves, typed, validated at the edit. If you’ve consumed a Cisco IOS XE model before, this is the same muscle. The leaf names are new (e.g. mlkem768 or mldsa-sig). The pattern is not: read the model, send structured data, check what came back.
| Intent | Where it lives |
|---|---|
| ML-KEM group | /native/crypto/ikev2/proposal[name]/pqc/mlkem768 |
| PPK (RFC 8784) | /native/crypto/ikev2/keyring[name]/peer[name]/ppk/manual/... |
| ML-DSA auth | trustpoint mldsakeypair +authentication/{local,remote}/mldsa-sig |
What YANG will not do is ask the box to do something. Cisco-IOS-XE-crypto.yang has zero rpc statements. So these still go over CLI:
crypto key generate mldsacrypto pki enrollclear crypto ikev2 sa
“This proposal should offer ML-KEM-768” is expressible. “Renegotiate now” is not. And you need both, because changing a proposal does not touch an SA that is already up. Push ML-KEM, read the operational data, see no PQC, and you’ll conclude the feature is broken when it simply hasn’t renegotiated.
That’s the whole design in one paragraph. Desired state over NETCONF. The handful of actions over CLI, marked as escape hatches, not buried.
Four playbooks, same progression #
Three routers in hub-and-spoke. One playbook per unit. Each one tears itself down with -e state=absent. Every apply ends by reporting the resulting posture.
┌─ PPK ────────────┐
baseline ───────────┼─ ML-KEM ─────────┼─────────► ML-DSA
└─ neither ────────┘
▲
pick one
| Playbook | What it is | Maps to |
|---|---|---|
| ipsec-baseline.yml | classical Diffie-Hellman | Part 9, the IKEv2 baseline |
| ipsec-pq-ppk.yml | RFC 8784 PPK | Part 9, the PPK section |
| ipsec-pq-mlkem.yml | native ML-KEM-768 hybrid | Part 9, native ML-KEM |
| ipsec-pq-mldsa.yml | authentication swaps to ML-DSA certificates | Parts 10 and 11 |
PPK and ML-KEM are alternatives, not steps. Same rule as Part 9: we never stack them. ML-DSA is orthogonal. It swaps how identity is proved and does not care whether you chose PPK, ML-KEM, or neither for keying.
The posture itself lives in one file, “pqc.yml”. Two lines decide the IPsec half of the lab:
pq_key_exchange: mlkem768
pq_key_exchange_optional: true
pq_signature_param_set: 65
Change a value, re-run, every proposal on the fabric moves. You should never have to open an XML template.
Before any of that, “bootstrap.yml” turns NETCONF on. That one still runs over SSH CLI, because you can’t configure the transport over the transport. 🙂 First run waits for the subsystem (on these boxes, a bit over two minutes). Second run diffs against the running config, pushes nothing, and reports changed=false.
ML-KEM is two leaves #
“ipsec-pq-mlkem.yml” adds two YANG leaves to the existing proposal and nothing else. The payload that lands on the box looks like this:
<ikev2 xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-crypto">
<proposal>
<name>LAB-PROPOSAL</name>
<pqc>
<mlkem768/>
<optional/>
</pqc>
</proposal>
</ikev2>
The parameter set is the element name, not a value. mlkem512, mlkem768 and mlkem1024 are sibling leaves of type empty. That’s why pq_key_exchange has to be spelled the way the schema spells it.
On the hub CLI, the whole unit is just one line:
crypto ikev2 proposal LAB-PROPOSAL
+ pqc mlkem768 optional
encryption aes-cbc-256
integrity sha512
group 20
Then the role clears the IKEv2 SAs (that’s the CLI escape hatch) so the next negotiation actually uses the new proposal.
PLAY RECAP from the real apply, three routers in parallel:
r1 : ok=33 changed=2 unreachable=0 failed=0
r2 : ok=33 changed=2 unreachable=0 failed=0
r3 : ok=28 changed=2 unreachable=0 failed=0
And the proof, off R2 (the hub), both tunnels:
R2# show crypto ikev2 sa detailed
Tunnel-id Local Remote fvrf/ivrf Status
2 10.0.12.2/500 10.0.12.1/500 none/none READY
Encr: AES-CBC, keysize: 256, PRF: SHA512, Hash: SHA512, DH Grp:20, Auth sign: PSK, Auth verify: PSK
PQC Key Exchange: ML-KEM-768
...
Quantum-safe Encryption using PQC: ML-KEM-768
Tunnel-id Local Remote fvrf/ivrf Status
1 10.0.23.1/500 10.0.23.2/500 none/none READY
Encr: AES-CBC, keysize: 256, PRF: SHA512, Hash: SHA512, DH Grp:20, Auth sign: PSK, Auth verify: PSK
PQC Key Exchange: ML-KEM-768
That’s not “the edit returned <ok/>”. That’s the live SA telling you it negotiated ML-KEM-768. The operational YANG even has a leaf for it: “pqc-group = pqc-gt-mlkem768”.
Cannot stack PPK on ML-KEM #
Part 9 configured PPK, then removed PPK, then enabled ML-KEM. The automation encodes that as a hard stop.
Run “ipsec-pq-mlkem.yml” while a PPK is still configured and nothing is touched:
TASK [ipsec_pq_mlkem : Abort if a PPK is still configured]
fatal: [r1]: FAILED! => {
"msg": "A post-quantum pre-shared key is still configured on r1.
PPK and ML-KEM are alternative paths in this lab and are
never stacked. Tear the PPK down first, then come back:
\"ansible-playbook ipsec-pq-ppk.yml -e state=absent\".
Nothing has been changed."
}
Same the other way: ipsec-pq-ppk.yml probes for ML-KEM and aborts if it finds it. Neither role auto-removes the other.
ML-DSA one router at a time #
Swapping authentication replaces the method rather than adding to it. Between one end switching and the other following, the peers disagree and the tunnel is legitimately down. A parallel run would leave the fabric half-migrated. Rolling r2, then r1, then r3 keeps that window to a single peer pair.
That’s why the first apply takes around ten minutes, not because Ansible is slow. Each router generates its own ML-DSA key on-box, builds a CSR, the control node signs it, the certificate comes back, then the profile flips. While the hub has switched and a spoke has not, overlay pings fail and retry. Those red FAILED - RETRYING lines are intentional and non-fatal. failed=0 in PLAY RECAP is what matters.
Clean build from zero, three routers:
r1 : ok=75 changed=10 unreachable=0 failed=0
r2 : ok=75 changed=10 unreachable=0 failed=0
r3 : ok=70 changed=10 unreachable=0 failed=0
A second run is much faster: the CA and the issued certificates already exist, so most tasks report changed=0.
One difference from Part 10 is worth being explicit about. The hand-driven CLI walkthrough imported a PKCS#12 bundle built entirely on the workstation (private key included). The playbook does it the other way around: the router generates the key, only the CSR leaves the box, only the certificate comes back. Same external OpenSSL 3.5+ CA, same ML-DSA-65 chain. The private identity key never sits on the laptop.
After the loop has been all the way round:
R2# show crypto ikev2 sa | include Auth sign
Encr: ... DH Grp:20, Auth sign: MLDSA, Auth verify: MLDSA
Encr: ... DH Grp:20, Auth sign: MLDSA, Auth verify: MLDSA
Hub, two tunnels, both ML-DSA. The operational YANG cannot report it: typedef crypto-auth-method has no ML-DSA value. So this is the one legitimate screen-scrape on the IPsec read path. Everything else post-quantum comes back as a leaf.
And we check Auth sign on every SA row, on every router, not Auth verify. Part 11 already showed why: mid-migration, the initiator’s Auth verify field is not trustworthy. A spoke can print Auth verify: MLDSA while the hub is still signing PSK. Each box asserts only the direction it is authoritative for.
An <ok/> is not proof #
That’s the sentence to take away from this whole layer.
A successful NETCONF edit means the payload parsed. It does not mean the feature works. It does not mean the SA renegotiated. It does not mean the certificate the router just imported is the one the profile is using.
So every unit asserts against the box after configuring it:
| Question | What we check |
|---|---|
| Did this SA use ML-KEM, and which parameter set? | pqc-group per IKEv2 SA (YANG) |
| Is PPK working? | quantum-resistance counters (YANG + CLI) |
| Did this SA authenticate with ML-DSA? | Auth sign: on every row (CLI) |
You can re-check later without re-applying:
ansible-playbook verify.yml -e verify_ipsec_only=true -e verify_require_mlkem=true
ansible-playbook verify.yml -e verify_ipsec_only=true -e verify_require_mldsa=true
Pass both flags once the full stack is up, and you have a CI check: fail the job unless the live SAs are hybrid and ML-DSA.
What we’ve done #
Same three boxes, no more CLI typing. The posture lives in one file. PPK and ML-KEM refuse to stack. ML-DSA rolls one peer at a time because that’s what the protocol requires. And the check that matters is not “did NETCONF say <ok/>”, it’s PQC Key Exchange: ML-KEM-768 and Auth sign: MLDSA on a live SA.
You started Part 1 with a question: is your VPN ready for the quantum era? You can answer it now, on a laptop and on a Cisco router, by hand and as code. DevNet’s job was to enable this community on programmability, with API-driven automation as one of the main goals. Here’s that work, running against a live quantum-safe tunnel.
From “docker compose up” to “show crypto ikev2 sa” to “ansible-playbook ipsec-pq-mlkem.yml”. The headlines about quantum computers breaking the internet stop being scary once you’ve seen a tunnel come up, and they stay that way once you can bring the next hundred up the same way.
Go automate your PQC deployments!