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.
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.
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.
