Post-Quantum Cryptography: ML-KEM, ML-DSA, SLH-DSA, the Hybrid Approach and Crypto Agility
Because data that must stay confidential can be recorded today and decrypted later with a quantum computer, key exchange based on RSA and ECC should move to hybrid ML-KEM now; AES and SHA-2 are outside the migration. With real outputs we show NIST IR 8547's 2030/2035 timeline, .NET 10's MLKem, MLDsa, SlhDsa and CompositeMLDsa APIs, the X25519MLKEM768 TLS group that comes with OpenSSL 3.5, and Bookstore's hybrid purchase order envelope.
Version note: The code in this post was built and run with .NET 10 and xUnit v3 4.0; the outputs were taken in Ubuntu 24.04 (OpenSSL 3.0) and Alpine 3.24 (OpenSSL 3.5) containers.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, encrypts the purchase orders it sends to suppliers with the method we built in the fourth post: an ephemeral ECDH key, HKDF and AES-GCM. The orders contain discount rates and annual contract volumes, and that information must stay confidential ten years from now. Today there's no quantum computer that can break this encryption. So is it enough to migrate when quantum computers arrive?
It isn't. Someone on the network can record the encrypted orders today and open them once a machine that can break ECDH exists. This threat is called "harvest now, decrypt later", and NIST's transition report also says that data whose confidentiality must last a long time is at risk today for that reason. In this post we look, on Bookstore, at which algorithms are affected, NIST's new standards, their counterparts in .NET 10, hybrid key exchange in TLS, and the crypto agility that makes the next algorithm change cheap.
The Threat: Which Algorithms Are Affected?
Shor's algorithm, running on a large enough quantum computer, efficiently solves the factoring problem that RSA relies on and the discrete logarithm problem that elliptic curve algorithms rely on. That means every asymmetric construction in this series is affected: encryption with RSA-OAEP, key agreement with ECDH, signatures with RSA-PSS and ECDSA. On the symmetric side things look much better. According to NIST IR 8547, approved symmetric primitives that give at least 128 bits of classical security, meaning AES, the SHA-2 and SHA-3 families, HMAC and HKDF, are considered adequate against quantum attacks too, and NIST doesn't expect a transition away from them. Bookstore's phone field encrypted with AES-256-GCM and its HMAC-signed webhooks are outside this migration.
The replacement algorithms were published in August 2024: FIPS 203 defines ML-KEM for key encapsulation, FIPS 204 defines ML-DSA for signatures, and FIPS 205 defines SLH-DSA, also for signatures. The first two rest on lattice problems; SLH-DSA relies only on the security of hash functions, which makes it slower with a larger signature, but its assumption is more conservative.
NIST's Timeline
The final version of NIST IR 8547 was published on 27 October 2026. According to the report, NIST will deprecate quantum-vulnerable public key algorithms, that is RSA, ECDSA, EdDSA and finite field and elliptic curve Diffie-Hellman, after 2030 and disallow their use under its standards after 2035. That also changes an earlier plan: SP 800-57 had projected that public key schemes giving 112 bits of security (such as RSA-2048) would be disallowed on 1 January 2031. The report says instead that all quantum-vulnerable signature algorithms will be deprecated after 2030 and disallowed after 2035. The same report also mentions earlier dates for the most sensitive systems of US federal agencies. The practical consequence for you: 2030 isn't far away, and the code you write will probably still be running after it.
ML-KEM, ML-DSA and SLH-DSA in .NET 10
.NET 10 brought these three algorithms as the MLKem, MLDsa and SlhDsa classes. The provider model from the first post of the series shows up here at its clearest: according to Microsoft Learn, these algorithms need OpenSSL 3.5 or later on Linux, and CNG with PQC support on Windows; as of August 2026 the platform table on Learn says "Windows 11 Insiders (Latest)" in the Windows column. I ran the same lab command in two containers:
$ dotnet run --project src/Bookstore.CryptoLab -- pqc-sizes
Ubuntu 24.04.5 LTS, OpenSSL 3.0.13
algorithm public key signature / ciphertext
ECDSA P-256 (SPKI) 91 B 64 B
RSA-3072 (SPKI) 422 B 384 B
ML-KEM: False, ML-DSA: False, SLH-DSA: False -> post-quantum algorithms need OpenSSL 3.5+ or Windows CNG with PQC
$ docker run --rm -v "${PWD}:/src/bookstore" -w /src/bookstore mcr.microsoft.com/dotnet/runtime:10.0-alpine dotnet src/Bookstore.CryptoLab/bin/Debug/net10.0/Bookstore.CryptoLab.dll pqc-sizes
Alpine Linux v3.24, OpenSSL 3.5.9
algorithm public key signature / ciphertext
ECDSA P-256 (SPKI) 91 B 64 B
RSA-3072 (SPKI) 422 B 384 B
ML-KEM-512 800 B 768 B secret 32 B, same on both sides: True
ML-KEM-768 1184 B 1088 B secret 32 B, same on both sides: True
ML-KEM-1024 1568 B 1568 B secret 32 B, same on both sides: True
ML-DSA-44 1312 B 2420 B verify: True
ML-DSA-65 1952 B 3309 B verify: True
ML-DSA-87 2592 B 4627 B verify: True
SLH-DSA-SHA2-128s 32 B 7856 B verify: True
SLH-DSA-SHA2-128f 32 B 17088 B verify: True
MLDSA65-ECDSA-P256-SHA512 2017 B 3379 B verify: True, ECDSA part tampered: False
The first thing that stands out is the sizes. ML-KEM-768's public key is 1184 bytes and its ciphertext 1088 bytes; compare that with P-256's 91-byte public key. An ML-DSA-65 signature is 3309 bytes, more than fifty times ECDSA's 64 bytes. SLH-DSA's public key is only 32 bytes, but its signature is between 7856 and 17088 bytes; the "s" (small) parameter set gives a small but slow signature, the "f" (fast) one a fast but large signature. A JWT, a certificate chain or a signature sent with every request growing this much touches header limits, cookie sizes and bandwidth.
The second is the maturity of the API. MLKem is a stable type, but its SPKI import and export methods are [Experimental]. MLDsa, SlhDsa and CompositeMLDsa are entirely experimental, and to use them you have to turn off the SYSLIB5006 warning on purpose; if you don't, the build stops with this error:
error SYSLIB5006: 'System.Security.Cryptography.SlhDsaAlgorithm' is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
In the lab we did that with a file-level #pragma warning disable SYSLIB5006. In production code don't spread these types everywhere; put them behind a small interface of your own, so that when the API changes you update one place.
Hybrid Signatures: Composite ML-DSA
The new algorithms haven't been tested as long as RSA and ECC, which have been studied for decades. That's why the common approach during the transition is hybrid: use a classical and a post-quantum algorithm together, so that if one breaks the other still protects. On the signature side the .NET counterpart is CompositeMLDsa, which implements the composite signatures from an IETF draft. The MLDSA65-ECDSA-P256-SHA512 line at the end of the output carries an ML-DSA-65 signature and an ECDSA P-256 signature as one signature: 3379 bytes, that is 3309 plus ECDSA's DER-encoded signature. When I corrupted the last byte of the signature, only the ECDSA half, verification returned False: both halves have to pass.
Hybrid Key Exchange in TLS
The harvest now, decrypt later threat matters most for TLS traffic, because it's the easiest data to record. The group defined for hybrid key exchange in TLS 1.3 is X25519MLKEM768: the key shares of X25519 and ML-KEM-768 travel inside one key share, and both shared secrets go into the key schedule. In the IANA registry it has the number 4588 and is marked as recommended. The TLS 1.3 handshake post of the Networking and HTTP series introduced post-quantum key exchange; now let's look at what .NET itself sends. The tls-groups command opens the loopback connection from the previous post and reads the relevant extensions of the ClientHello and ServerHello:
$ dotnet run --project src/Bookstore.CryptoLab -- tls-groups
Ubuntu 24.04.5 LTS, OpenSSL 3.0.13
ClientHello supported_groups: x25519, secp256r1, x448, secp521r1, secp384r1, ffdhe2048, ffdhe3072, ffdhe4096, ffdhe6144, ffdhe8192
ClientHello key_share : x25519 (32 B)
ServerHello key_share : x25519 (32 B)
negotiated : Tls13, TLS_AES_256_GCM_SHA384
Alpine Linux v3.24, OpenSSL 3.5.9
ClientHello supported_groups: X25519MLKEM768, x25519, secp256r1, x448, secp384r1, secp521r1, ffdhe2048, ffdhe3072
ClientHello key_share : X25519MLKEM768 (1216 B), x25519 (32 B)
ServerHello key_share : X25519MLKEM768 (1120 B)
negotiated : Tls13, TLS_AES_256_GCM_SHA384
Not a single line of code changed; only the base image changed, and with it the OpenSSL version. With OpenSSL 3.5, SslStream puts the hybrid group first, and a connection where both sides run OpenSSL 3.5 uses it. The key share grows from 32 bytes to 1216: 32 bytes of X25519 and the 1184-byte ML-KEM-768 public key. In practice this means making TLS post-quantum isn't a .NET code change, it's a platform update. The OpenSSL version in your container image, Schannel support on your Windows servers, and the TLS library of the reverse proxy or load balancer in front are the real decision points. I haven't verified for this post whether Schannel on Windows offers this group.
Bookstore's Purchase Order File: A Hybrid Envelope
Data we encrypt at the application level, on the other hand, needs a code change. Next to the envelope from the fourth post we added a hybrid version: key encapsulation with ML-KEM-768 and an ephemeral ECDH P-256, with both shared secrets going through HKDF together. An attacker has to break both to learn the key:
recipientKem.Encapsulate(out byte[] kemCiphertext, out byte[] kemSecret);
using ECDiffieHellman ephemeral = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
byte[] ephemeralPublic = ephemeral.ExportSubjectPublicKeyInfo();
byte[] ecdhSecret = ephemeral.DeriveRawSecretAgreement(recipientEcdh.PublicKey);
// Encapsulated key = ML-KEM ciphertext (fixed size) | ephemeral ECDH public key
byte[] encapsulated = [.. kemCiphertext, .. ephemeralPublic];
byte[] key = DeriveKey(kemSecret, ecdhSecret, encapsulated, keyId);
// Both secrets in, both ciphertexts as salt: an attacker must break ML-KEM and ECDH to learn the key
private static byte[] DeriveKey(byte[] kemSecret, byte[] ecdhSecret, byte[] encapsulated, string keyId) =>
HKDF.DeriveKey(HashAlgorithmName.SHA256, [.. kemSecret, .. ecdhSecret], 32, salt: encapsulated,
info: Encoding.UTF8.GetBytes($"bookstore/purchase-order/v2|{Algorithm}|{keyId}"));
This follows the same logic as the hybrid group in TLS: concatenate the secrets and run them through a KDF. The algorithm name also goes into info, so the same key can't be derived from the same secrets under another algorithm name. The command encrypts the same order both ways and reads it on the supplier's side:
$ dotnet src/Bookstore.CryptoLab/bin/Debug/net10.0/Bookstore.CryptoLab.dll pqc-envelope
ECDH-P256.HKDF-SHA256.A256GCM 473 B JSON, decrypted: {"purchaseOrder":"PO-2026-11-0042"...
MLKEM768+ECDH-P256.HKDF-SHA256.A256GCM 1940 B JSON, decrypted: {"purchaseOrder":"PO-2026-11-0042"...
hybrid relabelled as classic: CryptographicException: Unsupported envelope: v2 ECDH-P256.HKDF-SHA256.A256GCM
unknown algorithm: CryptographicException: Algorithm not accepted: MLKEM512.A128GCM
ML-KEM part of the key replaced: AuthenticationTagMismatchException: The computed authentication tag did not match the input authentication tag.
The same order went from 473 bytes to 1940 bytes; most of that is the ML-KEM ciphertext. An attempt to relabel the hybrid envelope as classic and steer it down the weaker path is rejected, an algorithm that isn't on the list isn't accepted, and when the ML-KEM key is wrong the AES-GCM tag doesn't match even though the ECDH half is right.
Crypto Agility
What makes this migration possible are the small fields we put into our formats throughout the series: the version byte and keyId in field encryption, the BSL1 prefix and keyId in the license, the Algorithm field in the envelope. Crypto agility means that changing an algorithm is a data migration and a config change, not a rewrite. The reading side decides in one place, against an allow-list:
public static byte[] Open(RecipientKeys keys, HybridEnvelope envelope)
{
if (!Accepted.Contains(envelope.Algorithm))
{
throw new CryptographicException($"Algorithm not accepted: {envelope.Algorithm}");
}
return envelope.Algorithm switch
{
PqHybrid.Algorithm => PqHybrid.Decrypt(keys.Kem ?? throw new CryptographicException("No ML-KEM key."), keys.Ecdh, envelope),
EcdhHybrid.Algorithm => EcdhHybrid.Decrypt(keys.Ecdh, envelope),
RsaHybrid.Algorithm => RsaHybrid.Decrypt(keys.Rsa ?? throw new CryptographicException("No RSA key."), envelope),
_ => throw new CryptographicException($"Algorithm not accepted: {envelope.Algorithm}"),
};
}
The algorithm is read from the data, but the data doesn't make the decision: the allow-list and each algorithm's own version check live in code. Old algorithms stay on the list for reading only; the write path always uses the current algorithm, and old records are re-encrypted in the background.
The second leg of agility is inventory: without knowing which algorithm, which key size and which library each service uses, you can't decide what to migrate. CycloneDX 1.7 (October 2025) extended its CBOM support for this with a catalog of algorithm families and a list of elliptic curves; producing SBOMs and CBOMs is covered in the SBOM post of the DevSecOps series. At Bookstore the priority order is this: first, data whose confidentiality must last and that can be recorded today, meaning supplier orders and service-to-service TLS. For TLS we update the platform; for orders we move to the hybrid envelope. Signatures come later: since forging a signature only becomes possible once a quantum computer exists, moving the license keys to composite signatures by 2030 is enough. We don't touch the AES-GCM field encryption or the HMAC webhooks.
Production Notes
Check platform support at startup. The CryptoRequirements approach from the first post of the series applies here too: a service that uses the hybrid envelope on its write path must not start on an image without ML-KEM, rather than fail on the first order. Switching an Ubuntu 24.04-based image to Alpine "to make it smaller", or the other way around, changes the crypto capabilities too.
Your tests depend on the platform as well. Our ML-KEM tests are guarded with Assert.SkipUnless(MLKem.IsSupported, ...); on Ubuntu 24.04 dotnet test skips 3 of the 66 tests. When I ran the same test binary on Alpine 3.24, all 66 passed. Run at least one CI job on a PQC-capable platform; otherwise skipped tests turn into tests that never run.
Measure the size increase. A ClientHello that's 1.2 KB larger can cause trouble with some old middleboxes, a JWT signed with ML-DSA can hit header limits, and the order file grows fourfold. Try these limits in your own environment before migrating.
Common Pitfalls
The first pitfall is the one from Guess First: leaving the migration for the day quantum computers arrive. For data that has to stay confidential for a long time, the threat starts the day it's recorded.
The second is trying to replace symmetric crypto too. AES-256, SHA-256 and HMAC don't need a migration; put the effort into the asymmetric side.
The third is switching to the new algorithm alone. The hybrid approach is insurance against a weakness that may yet be found in the new algorithms; don't drop the classical algorithm during the transition.
The fourth is taking a shortcut when combining secrets: XORing the two shared secrets, or feeding only one of them to the KDF. Run both through a KDF, together with their ciphertexts.
The fifth is spreading experimental APIs all over the code. Turning off SYSLIB5006 project-wide makes it hard to see what's affected when the API changes; suppress the warning only in your wrapper class.
A Small Experiment
★☆☆ Add SlhDsaShake256f and MLDsa87WithECDsaP384 rows to the pqc-sizes command. Which one has the larger signature?
★★☆ Add a BSL2 version to Bookstore's license format: signed with CompositeMLDsa (MLDsa65WithECDsaP256), with the verifier still accepting BSL1. How many characters does the license text grow to? Evaluate that for a QR code or an e-mail attachment.
★★★ Add the concept of "write path algorithms" to CryptoRequirements: if PqHybrid.Algorithm is required for writing and MLKem.IsSupported is false, the application should stop at startup. Try it on the Ubuntu and Alpine images separately.
In this post we saw .NET 10's post-quantum APIs and the migration plan. In the last post of the series we look back and forward: reading old crypto code left over from .NET Framework, obsolete types and their warnings, and the X25519, HPKE and composite ML-KEM that arrived with .NET 11.