An earlier piece on CTO Insights, Post-Quantum Readiness in AI Infrastructure, argued that the common plan (“when standards finalize, we’ll just upgrade OpenSSL”) badly underestimates the work. That was an argument. This is the same claim with running code attached, and the code is more specific than the argument was.

Standards for post-quantum cryptography exist. Reference implementations of the algorithms exist. The gap sits between them: a certificate authority does not have to migrate its own signing algorithm before it can issue post-quantum leaf certificates, a TLS handshake needs a hybrid group negotiated before either side can safely drop the classical one, and none of it works unless the calling code can swap algorithms without a rewrite. This repository is five demos and a benchmark suite aimed squarely at that gap.

A note on what it is not, because the naming invites the wrong assumption. Everything substantive lives under internal/, which in Go is a hard compiler-enforced boundary: nothing here is importable from another module, by construction. That is deliberate. This is a lab, and a lab that other systems started depending on would stop being free to be wrong in public.

Two Things Named “Hybrid,” and They Never Meet

The most important structural fact about this repository is also the one most likely to be misread from a distance. There are two entirely separate notions of “hybrid” in it, and no code path connects them.

internal/agility is the seam the whole repository is built around: two parallel interface pairs, KEMFactory/KEMSession and SignatureFactory/SignatureSession, plus a Registry that resolves an algorithm by name at runtime so the choice can come from configuration instead of a compile-time import. Concrete algorithms register against it. Nothing else in the codebase reaches past it to a specific crypto library.

internal/kem/hybrid composes any two registered KEMs into one, running both and combining their shared secrets. It is a real demonstration of what the seam buys you: it composes its two components generically, without knowing their concrete types. It is also not a TLS group, not wire-compatible with any TLS implementation, and not a standardized combiner. It frames each component with a four-byte big-endian length prefix (so component sizes are never hardcoded) and combines the two secrets through HKDF-SHA384 under the info string "pq-migration-lab hybrid kem v1". That version-tagged, repo-specific string is the honest signal: this is a construction for demonstrating agility, not an interoperable one.

The TLS demos, meanwhile, route around internal/agility entirely. They negotiate X25519MLKEM768 through oqs-provider by name, on an OpenSSL command line, in a subprocess. The hybrid key exchange that actually crosses a wire in this repository never touches the agility layer at all.

Diagram of pq-migration-lab showing two layers below one CLI. On the left, the library layer routes through internal/agility to internal/kem and internal/sig. On the right, the protocol and PKI layer routes around it, through OpenSSL 3 with oqs-provider, selecting algorithms by name for the TLS group X25519MLKEM768 and for X.509 issuance with -newkey mldsa65.
Figure 1: One CLI, two paths down. The dashed line is the seam, and half the repository is deliberately on the far side of it.

Stating that boundary plainly is most of the value. “We added a hybrid KEM” is exactly the kind of sentence that gets overread into “our TLS is hybrid now.” In this repository those are two different demos with two different proof strengths, which is the next thing worth being specific about.

The Demos Do Not All Prove the Same Amount

Five demos ship here, and they are not equally strong evidence. Ranking them by what they actually establish is more useful than listing them, and the repository is already honest about this in its own documentation, if you read closely.

hybrid-tls proves the least, and proves it structurally. It starts an openssl s_server offering exactly one TLS 1.3 group, connects with an openssl s_client restricted to the same single group, and confirms the handshake completed. It does not read the negotiated group name back, because it cannot: SSL_get_peer_tmp_key returns nothing for this group under this OpenSSL/oqs-provider combination, so there is no group name in the output to grep for. The proof is an argument from structure instead. TLS 1.3 requires a negotiated group for every handshake, no fallback was offered on either side, therefore a completed handshake could only have used that group. Valid, and weaker than a direct read.

interop-tls proves more, in two independent ways. Go’s standard library has implemented this exact group natively since Go 1.24, at the same IANA codepoint 0x11ec that oqs-provider uses. So the demo runs a Go server against an OpenSSL client, and an OpenSSL server against a Go client, and on the Go side reads the negotiated group back directly:

curve := conn.ConnectionState().CurveID
success := curve == tls.X25519MLKEM768

That is confirmation rather than inference, and it is confirmation across two independently written implementations. The README’s own summary of why this demo exists at all is the sentence I would keep: hybrid-tls only proves openssl agrees with itself.” A migration that has only ever been tested against another copy of its own stack has not been tested against the thing that will actually break it, which is a peer someone else built.

ca-rotation proves the most, because one of its four checks has to fail. More on that below; it is the part of this repository I would point at first.

Where the Tooling Forced the Design

The premise of the repository is that migration breaks in places the standards documents do not discuss. Most of those places turned out to be tooling, not cryptography, and this is the material that does not get published anywhere.

Through Go 1.26, Go could not represent a post-quantum certificate at all. Both PKI demos shell out to openssl for every step rather than using internal/sig, and not for convenience. Through Go 1.26, x509.CreateCertificate and x509.MarshalPKIXPublicKey recognized only RSA, ECDSA, and Ed25519 public keys. They could not produce, or even represent, a certificate carrying an ML-DSA-65 subject public key. The agility seam is genuinely useful for library-level algorithm selection and genuinely could not help here, because the constraint sat in the standard library’s type system rather than in the calling code. Go 1.27 closed this gap on 19 August 2026. See below.

Cross-signing has to be assembled backwards. openssl x509 -req has no -addext of its own; it only carries over extensions the CSR itself requested. So marking the cross-certificate as a CA means putting basicConstraints and keyUsage on the CSR, then issuing with -copy_extensions copy. Get that wrong and you produce a cross-cert that looks fine and silently is not a CA.

The obvious flag for supplying an intermediate does not work here. Passing the cross-cert via openssl verify -untrusted is the textbook approach and hits a decode error under this combination: the intermediate’s ML-DSA-65 public key is not re-fetched with provider awareness on that code path mid-chain-build. Bundling the cross-cert into -CAfile instead works. That is a one-line difference between a working migration and an afternoon of debugging something that reads like a broken certificate.

Two TLS clients disagreed about the same certificate. The ephemeral demo certificate originally carried only a CommonName, which openssl s_client accepts without complaint. Go’s crypto/x509 has ignored CommonName for hostname matching since Go 1.15, so adding the Go-native peer meant adding a 127.0.0.1 IP SAN, purely to let the Go client verify properly through an x509.CertPool instead of falling back to InsecureSkipVerify. Nothing about that is post-quantum. It is just what happens the first time a second implementation looks at your certificates, and it is a fair preview of what a real migration finds.

The Check That Has to Fail

Eventually the CA itself has to migrate, and that is the hard one. The bridge is a cross-certificate: a certificate carrying the new CA’s subject and public key, but signed by the old CA, so certificates issued under the new root still chain back to the already-trusted old one during the transition. It is the same mechanism real web PKI root transitions use.

What makes the demo worth reading is not the three checks that pass. It is the second one.

Diagram of the CA rotation demo. An Ed25519 old-ca and an ML-DSA-65 new-ca sit either side of a cross certificate that carries the new CA's subject and key but the old CA's signature. Each CA issues a leaf. Below, four openssl verify checks are listed: the legacy leaf verifies against the old CA, the new leaf must fail against the old CA alone, the new leaf verifies directly against the new CA, and the new leaf verifies against the bundle of cross certificate plus old CA.
Figure 2: Three checks confirm the rotation works. The fourth confirms it is not working by accident.

A post-rotation leaf must fail to verify against the old CA alone. The old CA never signed anything from the new CA, so a relying party without the cross-cert is correct to reject it. The demo asserts that failure as an expected outcome, with a wantVerified field on each check rather than a bare success assertion.

Without that negative check, the fourth check proves nothing. If the post-rotation leaf verified against the old CA whether or not the cross-cert was present, the cross-cert would be decorative and the test suite would pass anyway. A validation suite that only asserts successes cannot tell the difference between a mechanism working and a mechanism being unnecessary. That is a general property, not a cryptographic one, and it is the single thing from this repository most worth stealing.

The Benchmarks Do Not Flatter the Story

The tidy narrative about post-quantum cryptography is that it is slower and larger, and you pay for safety. The numbers here, captured on an Apple M1 Pro under linux/arm64 with liboqs 0.15.0, only partly cooperate.

Where post-quantum actually costs more, and where it doesn’t

Operation Classical Post-quantum Ratio
KEM keypair generation 37,157 ns (X25519) 17,542 ns (ML-KEM-768) 0.47× — PQ faster
KEM encapsulation 75,746 ns (X25519) 17,849 ns (ML-KEM-768) 0.24× — PQ faster
KEM keypair memory 376 B/op (X25519) 4,112 B/op (ML-KEM-768) 10.9× — PQ larger
Signing 19,952 ns (Ed25519) 426,175 ns (ML-DSA-65) 21.4× — PQ slower
Verification 43,178 ns (Ed25519) 96,690 ns (ML-DSA-65) 2.2× — PQ slower

Relative costs on one machine, not absolute performance claims. The KEM rows are a statement about implementations, not about algorithms.

ML-KEM-768 being roughly four times faster than X25519 at encapsulation is the result most likely to be quoted out of context, so the repository says plainly what it is: liboqs ships optimized native C, while Go’s crypto/ecdh is portable Go. It is a measurement of implementation maturity, not evidence that lattice cryptography is inherently cheaper than elliptic curves. Structure explains part of the gap too: this KEM-from-Diffie-Hellman construction generates a fresh ephemeral keypair and runs ECDH on every encapsulation, which is why X25519’s encapsulate is about twice its own keypair generation.

The result that survives that caveat is ML-DSA-65 signing at roughly 21× Ed25519. That is the clearest “post-quantum costs more” number in the repository, and its asymmetry is the operationally useful part: verification is only about 2×. Systems that sign rarely and verify often (TLS certificates, package signing) absorb this far more easily than systems signing on a hot path. Certificate issuance is exactly the former, which is why the PKI demos never feel the cost.

The hybrid KEM lands at roughly the sum of its parts, which is the number that matters for planning. Running both algorithms through a migration window is not a meaningful performance decision at this layer. It is close to free, and the interesting costs sit in handshake payload size and connection volume rather than in the primitive.

One deliberate choice worth naming: the benchmark dashboard is generated by a tool in the repository that runs the suites itself and parses go test’s own output. Nothing in it is hand-transcribed. CI smoke-tests that the generator runs, and does not assert on the numbers, because they are hardware-dependent and expected to vary. A benchmark table maintained by copy-paste is a benchmark table that is quietly wrong within two releases.

What This Deliberately Does Not Do

The Insights piece proposed a three-step readiness path: inventory, benchmark, placement strategy. It is worth being exact about how much of that this repository covers, because the answer is “one of three, partially.”

It benchmarks primitives, not systems. These are Go microbenchmarks of individual operations. They say nothing about handshake latency under connection storms, throughput regression on a saturated link, or GPU underutilization during cluster churn, which is where the real capacity argument lives. Nothing here touches placement strategy: no GPU-accelerated PQ, no AVX-512 vectorization, no hardware offload. And a lab cannot inventory anyone’s cryptographic dependencies.

Only key exchange is post-quantum in the TLS demos. The certificate those servers present is ECDSA P-256. Post-quantum signatures appear in this repository at the PKI layer, in certificate issuance, and never inside a handshake. Migrating authentication in TLS is a separate problem with a much worse size profile, and it is not attempted here. Go 1.27 added ML-DSA signature schemes to crypto/tls, so this is now reachable in the standard library. It was not when this lab was built.

PKI evolution stops at rotation. No revocation across a migration, no multi-level intermediate hierarchies. Both are real, and both are where a large PKI actually gets painful.

The hybrid combiner is not standards-track. As above: HKDF-SHA384 under a repo-specific info string, useful for demonstrating the agility seam and appropriate for nothing else.

Nothing here is production-hardened cryptography. It composes vetted implementations (Go’s standard library, liboqs, oqs-provider) rather than implementing primitives, which is the right call, and it is still a lab. The internal/ boundary is what keeps that honest.

Each of those is something a reader might reasonably want. None was skipped by accident.

Go 1.27 Closed the Go Half, Seven Days Before Publication

Go 1.27 shipped on 19 August 2026, seven days before this note went out. The new crypto/mldsa package implements FIPS 204. crypto/x509 now supports ML-DSA private keys, public keys, and signatures, and crypto/tls now supports ML-DSA signatures in TLS 1.3 through the MLDSA44, MLDSA65, and MLDSA87 signature schemes. The claim above is bounded to Go 1.26 and left standing.

FIPS 204 was finalized in August 2024. Go shipped x509 support in August 2026. That two-year lag is the thesis of this note, and it just closed for exactly one language. It has not closed for the rest of the ecosystem. The OpenSSL findings here are untouched: the -untrusted decode error and the -addext cross-signing problem are oqs-provider behavior, not Go behavior. The repository still shells out to openssl for both PKI demos, which is now a question of provider coverage and no longer a standard library limitation.

Why Write It Down

The failures collected here are not cryptographic failures. A CSR that needed its extensions in a different place, a verify flag that decodes fine for classical keys and not post-quantum ones, a certificate two TLS implementations disagreed about, a standard library type system that, at the time, could not name the key you were holding. None of it appears in a NIST document, and all of it is what a team actually hits in the first week.

That is the argument the Insights piece was making in the abstract, and it holds up better with the specifics attached. Post-quantum migration is not a library swap, not because the algorithms are exotic, but because the ecosystem around them is unevenly ready in ways you only discover by running it.

pq-migration-lab is available now on GitHub under the MIT license. Every demo runs from a fresh clone with docker compose run pqlab demo <name>; the container pins its own liboqs and oqs-provider versions, which is the only way any of these results reproduce.

Sean O'Hara

Sean O’Hara

Fractional CTO at Arbor Engineering Group. Publishing on AI security, post-quantum cryptography, and the engineering decisions that compound quietly before they surface. More at CTO Insights and Field Notes , or on LinkedIn and GitHub.