Pure Post Quantum Cryptography TLS with ASP.NET Core

Β· 1528 words Β· 8 minutes to read

Over the last few years, I have dedicated a lot of space on this blog to the topic of Post-Quantum Cryptography (PQC). Today, we will peek into the TLS 1.3 handshake.

While hybrid mode TLS is the pragmatic choice for today’s internet, understanding how to construct a fully quantum-safe connection is critical for preparing for the deprecation of classical algorithms.

In this post, we are going to explore a “pure” PQC TLS stack: Post-Quantum Key Exchange, with Post-Quantum Authentication. We will configure an ASP.NET Core application running on Linux to use ML-DSA-65 for authentication and ML-KEM-768 for key exchange.

The PQC Landscape for TLS 1.3 πŸ”—

Before we dive into the code, it’s important to understand the standards being worked on by the IETF that enable this transition. There are three key drafts to be aware of:

  1. draft-ietf-tls-ecdhe-mlkem (Hybrid Key Exchange)
    This combines classical ECDHE (like X25519) with post-quantum ML-KEM. It is the supported approach in all major browsers today (Chrome, Edge, Firefox) because it provides a safety net: if the PQC algorithm breaks, the classical one still protects the traffic. It is the “conservative” choice for early adoption.

  2. draft-ietf-tls-mlkem (Pure PQC Key Exchange)
    This defines the use of ML-KEM for key agreement in TLS 1.3 without any classical hybrid. This is the long-term goal for efficiency, but largely unsupported in browsers today. At the same time, it does not mandate the use of PQC authentication, so you could still have a classical certificate (RSA/ECDSA) with a pure PQC key exchange.

  3. draft-ietf-tls-mldsa (Pure PQC Authentication)
    This defines the use of ML-DSA (signatures) for authentication. When combined with ML-KEM (previous point), this gives us a “Pure PQC” handshake (PQC KEM + PQC Authentication), eliminating classical cryptography entirely.

For this demo, we will focus on 2 and 3, to build a TLS connection that uses zero classical cryptography.

The Challenge: Support/Availability πŸ”—

To make this work, we need an operating system and OpenSSL version that supports these algorithms. A major milestone was the release of OpenSSL 3.5 in April 2025, which brought native support for PQC algorithms (ML-KEM, ML-DSA, and SLH-DSA).

This version is now available in the following Linux distributions (and higher):

  • Debian “Trixie” 13
  • Alpine 3.22+
  • Ubuntu 25.10
  • RHEL / Alma 10.1+
  • SLES 16+

For this demo, we’ll be using Debian Trixie (and Docker, of course).

While Microsoft announced Post-Quantum Cryptography APIs for Windows 11 and Windows Server 2025 in November 2025, these algorithms have not yet been integrated into Schannel (the native Windows TLS stack). This means we cannot currently make “pure PQC” client calls directly from Windows, which is why we rely on a Linux container with OpenSSL 3.5 and curl for verification.

Step 1 - Generating a Pure PQC Certificate πŸ”—

The first step is authentication. Instead of a standard RSA or ECDSA certificate, we generate one using the ML-DSA-65 algorithm.

# generate pure ML-DSA-65 certificate (authentication side of pure PQC)
RUN openssl req -x509 -new -newkey mldsa65 -keyout server.key -out server.crt -nodes -days 365 \
    -subj "/CN=pqc-server" \
    -addext "subjectAltName=DNS:pqc-server,DNS:localhost,IP:127.0.0.1"

This certificate will be presented by our ASP.NET Core server during the handshake. We are generating a self-signed certificate here for simplicity, but in a production scenario, you would want to get this signed by a trusted CA that supports ML-DSA.

Step 2 - Forcing ML-KEM Key Exchange πŸ”—

This is the tricky part. By default, even if a client supports ML-KEM, OpenSSL might prefer a hybrid group (like X25519MLKEM768) or a classical one (like X25519). To enforce a “pure” PQC connection, we must configure OpenSSL to prioritize or exclusively offer ML-KEM-768.

In our Docker container context, we can patch the system openssl.cnf to set the supported groups:

# configure OpenSSL to only offer pure ML-KEM-768 for key exchange (no hybrid, no classical)
# append ssl_conf to existing [openssl_init] and add the TLS config sections
RUN sed -i '/^\[openssl_init\]/a ssl_conf = ssl_configuration' /etc/ssl/openssl.cnf && \
    printf '\n[ssl_configuration]\nsystem_default = tls_system_default\n\n[tls_system_default]\nGroups = mlkem768\n' >> /etc/ssl/openssl.cnf

This configuration ensures that when the client says “Hello, I support ML-KEM-768”, the server agrees to it immediately without choosing hybrid modes.

Step 3 - ASP.NET Core Server πŸ”—

The application code itself is remarkably standard. Kestrel (the ASP.NET Core web server) delegates the TLS implementation to the underlying OS (via SslStream). We simply load our PQC certificate from the PEM files we generated.

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(serverOptions =>
{
    serverOptions.ListenAnyIP(443, listenOptions =>
    {
        listenOptions.UseHttps(httpsOptions =>
        {
            var cert = X509Certificate2.CreateFromPemFile("server.crt", "server.key");
            httpsOptions.ServerCertificate = cert;
        });
    });
});

var app = builder.Build();

app.MapGet("/", (HttpContext ctx) => 
{
    var tlsFeature = ctx.Features.Get<ITlsHandshakeFeature>();
    Console.WriteLine($"[Crypto] Cipher Suite: {tlsFeature?.NegotiatedCipherSuite}");

    return Results.Text($"""
        SUCCESS! Verified Pure Post-Quantum TLS Connection.
        Cipher: {tlsFeature?.NegotiatedCipherSuite}
        Authentication: ML-DSA-65 (certificate)
        Key Exchange: ML-KEM-768 (negotiated via TLS 1.3)
        """);
});

app.Run();

Here is the complete Dockerfile used for the server. It combines the environment setup, certificate generation, and OpenSSL configuration into a single repeatable build.

# use Debian 13 (contains OpenSSL 3.5+)
FROM debian:trixie

RUN apt-get update && apt-get install -y \
    wget \
    openssl \
    ca-certificates \
    libicu-dev \
    && rm -rf /var/lib/apt/lists/*

ENV DOTNET_INSTALL_DIR=/usr/share/dotnet
ENV PATH=$PATH:$DOTNET_INSTALL_DIR
RUN wget https://dot.net/v1/dotnet-install.sh \
    && chmod +x dotnet-install.sh \
    && ./dotnet-install.sh --channel 10.0 --install-dir $DOTNET_INSTALL_DIR

WORKDIR /app

# generate pure ML-DSA-65 certificate (authentication side of pure PQC)
# key exchange (ML-KEM-768) is negotiated at the TLS protocol level
RUN openssl req -x509 -new -newkey mldsa65 -keyout server.key -out server.crt -nodes -days 365 \
    -subj "/CN=pqc-server" \
    -addext "subjectAltName=DNS:pqc-server,DNS:localhost,IP:127.0.0.1"

COPY Program.cs .
COPY TlsPurePqc.csproj .

RUN dotnet publish -c Release -o /app/publish

WORKDIR /app/publish
RUN cp /app/server.crt . && cp /app/server.key .

# configure OpenSSL to only offer pure ML-KEM-768 for key exchange (no hybrid, no classical)
RUN sed -i '/^\[openssl_init\]/a ssl_conf = ssl_configuration' /etc/ssl/openssl.cnf && \
    printf '\n[ssl_configuration]\nsystem_default = tls_system_default\n\n[tls_system_default]\nGroups = mlkem768\n' >> /etc/ssl/openssl.cnf

ENV ASPNETCORE_URLS=https://+:443
EXPOSE 443

ENTRYPOINT ["dotnet", "TlsPurePqc.dll"]

Verification πŸ”—

To verify this, standard browsers won’t work yet (they mostly support the hybrid mode). We need a tool that speaks pure ML-KEM and ML-DSA. We can use openssl s_client or a PQC-aware build of curl.

What we will do, is we will call our application, running in Debian Trixie, from another container running Debian Trixie. To do that, first, we need to set up our Docker network:

docker network create pqc-net

Then we build and run the server:

docker build -t tls-pure-pqc .
docker run -d --rm --name tls-pure-pqc --network pqc-net tls-pure-pqc

Now we can run a client in a separate container within the same network. We’ll use a Debian Trixie container to ensure we have OpenSSL 3.5+ and a PQC-aware curl:

docker run --rm -it --network pqc-net debian:trixie sh -c "
    apt-get update -qq && apt-get install -y -qq curl openssl ca-certificates; 
    
    echo '---------------------------------------------';
    echo '1. Testing with OpenSSL s_client (Handshake)';
    echo '---------------------------------------------';
    echo 'Q' | openssl s_client -connect tls-pure-pqc:443 -groups mlkem768 -ign_eof 2>/dev/null | grep -E 'Peer signature type|Server Temp Key';

    echo '---------------------------------------------';
    echo '2. Testing with Curl (HTTP Request)';
    echo '---------------------------------------------';
    curl -k -v --curves mlkem768 https://tls-pure-pqc
"

The output confirms our pure PQC stack:

---------------------------------------------
1. Testing with OpenSSL s_client (Handshake)
---------------------------------------------
Peer signature type: mldsa65
---------------------------------------------
2. Testing with Curl (HTTP Request)
---------------------------------------------
* Host tls-pure-pqc:443 was resolved.
* IPv6: (none)
* IPv4: 172.18.0.2
*   Trying 172.18.0.2:443...
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / MLKEM768 / id-ml-dsa-65
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=pqc-server
*  start date: Feb 12 15:59:53 2026 GMT
*  expire date: Feb 12 15:59:53 2027 GMT
*  issuer: CN=pqc-server
*  SSL certificate verify result: self-signed certificate (18), continuing anyway.
*   Certificate level 0: Public key type ML-DSA-65 (15616/192 Bits/secBits), signed using ML-DSA-65
* Connected to tls-pure-pqc (172.18.0.2) port 443
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://tls-pure-pqc/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: tls-pure-pqc]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.14.1]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: tls-pure-pqc
> User-Agent: curl/8.14.1
> Accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* Request completely sent off
< HTTP/2 200 
< content-type: text/plain; charset=utf-8
< date: Mon, 02 Mar 2026 15:22:42 GMT
< server: Kestrel
< content-length: 192
<
SUCCESS! Verified Pure Post-Quantum TLS Connection.

TLS Version: Tls13
Cipher: TLS_AES_256_GCM_SHA384
Authentication: ML-DSA-65 (certificate)
* Connection #0 to host tls-pure-pqc left intact
Key Exchange: ML-KEM-768 (negotiated via TLS 1.3)

Conclusion πŸ”—

In this blog post, we have successfully entirely eliminated classical cryptography (RSA, ECDSA, DH) from the TLS handshake. While we aren’t ready to deploy this to the public web due to browser support, this setup proves that the software ecosystem (ASP.NET Core, Linux, OpenSSL) is ready for the post-quantum era.

For the complete source code, check out the accompanying demo.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP