DEV Community

Daan Acohen
Daan Acohen

Posted on

Post-Quantum TLS in .NET 10 on Linux: ML-KEM in a Real Dockerized HTTP Client

Quantum-safe cryptography is moving from “future topic” to “engineering backlog item.”
The reason is simple: encrypted traffic captured today may be decrypted in the future once sufficiently capable quantum systems exist. If your data needs long-term confidentiality, this risk is relevant now.

A practical migration path is hybrid TLS key exchange: combine a classical key exchange (for interoperability) with a post-quantum one (for quantum resistance). In the .NET ecosystem, this becomes especially interesting with .NET 10 and ML-KEM support.

This article walks through the PQCHttpClient demo repo and explains exactly how it proves post-quantum hybrid TLS negotiation from a .NET app on Linux containers:


Why this matters for .NET developers

If you run .NET workloads on Linux (VMs, containers, Kubernetes), your TLS and crypto behavior depends on native platform libraries. For ML-KEM scenarios, that means:

  • .NET 10 gives you the managed API surface (System.Security.Cryptography.MLKem)
  • Linux runtime support depends on the OpenSSL version actually loaded at runtime
  • OpenSSL 3.5+ is the key enabler for ML-KEM TLS group support in this context

So this is not just about targeting net10.0; it is also about deterministic runtime linking.


What the demo proves

The project demonstrates three concrete things:

  1. Capability detection: MLKem.IsSupported reports whether ML-KEM is truly available in the runtime environment.
  2. Real HTTPS traffic: standard HttpClient call, no custom handshake code.
  3. Negotiation evidence: response header output shows which key exchange group was used.

This makes it a strong proof-of-concept for teams evaluating PQC readiness in production-like container environments.


Program.cs (why it is effective)

// HttpClient GET request demonstration
// Performs a GET request to GitHub API and displays status code and response headers

using (var client = new HttpClient())
{
    Console.WriteLine($"ML-KEM supported: {System.Security.Cryptography.MLKem.IsSupported}");
    try
    {
        // Set User-Agent header (required by GitHub API)
        client.DefaultRequestHeaders.Add("User-Agent", "pqcheader-console-app");

        // Execute GET request
        Console.WriteLine("Sending GET request to https://www.quantumsafeaudit.com..");
        Console.WriteLine();

        var response = await client.GetAsync("https://www.quantumsafeaudit.com");

        // Ensure the request was successful
        response.EnsureSuccessStatusCode();

        // Display status code
        Console.WriteLine($"Status Code: {(int)response.StatusCode} {response.StatusCode}");
        Console.WriteLine();

        // Display response headers
        Console.WriteLine("Response Headers:");
        Console.WriteLine(new string('-', 50));

        foreach (var header in response.Headers)
        {
            Console.WriteLine($"{header.Key}: {string.Join(", ", header.Value)}");
        }

        // Also display content headers if present
        if (response.Content.Headers.Any())
        {
            Console.WriteLine();
            Console.WriteLine("Content Headers:");
            Console.WriteLine(new string('-', 50));

            foreach (var header in response.Content.Headers)
            {
                Console.WriteLine($"{header.Key}: {string.Join(", ", header.Value)}");
            }
        }
    }
    catch (HttpRequestException ex)
    {
        Console.WriteLine($"Error making HTTP request: {ex.Message}");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Unexpected error: {ex.Message}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Key takeaways for readers

  • MLKem.IsSupported is a runtime truth signal, not a compile-time guarantee.
  • The app uses ordinary HttpClient; PQ/hybrid negotiation happens in the underlying TLS stack.
  • Showing response headers gives concrete handshake observability (important in demos and audits).

Dockerfile (the critical engineering part)

# =============================================================================
# Stage 1: Build OpenSSL 3.5 from source
# =============================================================================
FROM ubuntu:24.04 AS openssl-builder

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    ca-certificates \
    curl \
    perl \
    && rm -rf /var/lib/apt/lists/*

ARG OPENSSL_VERSION=3.5.0
RUN curl -fsSL https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz \
    -o /tmp/openssl.tar.gz \
    && tar -xzf /tmp/openssl.tar.gz -C /tmp

WORKDIR /tmp/openssl-${OPENSSL_VERSION}

RUN ./Configure \
    --prefix=/opt/openssl-3.5 \
    --openssldir=/opt/openssl-3.5/ssl \
    linux-x86_64 \
    shared \
    no-tests \
    && make -j$(nproc) \
    && make install_sw install_ssldirs

# =============================================================================
# Stage 2: .NET 10 SDK with custom OpenSSL — build the app
# =============================================================================
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview-noble AS build

# Copy OpenSSL 3.5 from builder stage
COPY --from=openssl-builder /opt/openssl-3.5 /opt/openssl-3.5

# Make the system use our OpenSSL 3.5
ENV LD_LIBRARY_PATH=/opt/openssl-3.5/lib64:/opt/openssl-3.5/lib
ENV PATH="/opt/openssl-3.5/bin:${PATH}"

WORKDIR /src

# Copy project file first for layer caching
COPY pqcheader.csproj ./
RUN dotnet restore

# Copy source and build
COPY Program.cs ./
RUN dotnet publish -c Release -o /app --no-restore

# =============================================================================
# Stage 3: Runtime image with custom OpenSSL
# =============================================================================
FROM mcr.microsoft.com/dotnet/runtime:10.0-preview-noble AS runtime

# Install minimal runtime dependencies for OpenSSL
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# Copy OpenSSL 3.5 libraries
COPY --from=openssl-builder /opt/openssl-3.5 /opt/openssl-3.5

# Configure dynamic linker to find OpenSSL 3.5 FIRST (before system OpenSSL)
RUN echo "/opt/openssl-3.5/lib64" > /etc/ld.so.conf.d/openssl-3.5.conf \
    && echo "/opt/openssl-3.5/lib" >> /etc/ld.so.conf.d/openssl-3.5.conf \
    && ldconfig

# Also set LD_LIBRARY_PATH as belt-and-suspenders
ENV LD_LIBRARY_PATH=/opt/openssl-3.5/lib64:/opt/openssl-3.5/lib
ENV SSL_CERT_DIR=/etc/ssl/certs

WORKDIR /app
COPY --from=build /app .

# Verify OpenSSL version at build time
RUN /opt/openssl-3.5/bin/openssl version

ENTRYPOINT ["dotnet", "pqcheader.dll"]
Enter fullscreen mode Exit fullscreen mode

What makes this production-relevant

  • Version control over crypto backend: avoids silently using distro-default OpenSSL.
  • Isolated OpenSSL prefix: explicit, auditable dependency placement.
  • Deterministic loader behavior: ld.so.conf.d + ldconfig + LD_LIBRARY_PATH.
  • Build-time validation: openssl version check inside the image.

For security-sensitive software, this is exactly the pattern you want: deterministic cryptography behavior across environments.


How to run

docker build -t pqcheader-mlkem .
docker run --rm pqcheader-mlkem
Enter fullscreen mode Exit fullscreen mode

You should see output like:

  • ML-KEM supported: True
  • successful HTTP status
  • response headers including negotiated key exchange group details

Practical interpretation of results

When you see ML-KEM supported: True, it means your runtime stack actually supports ML-KEM in that environment.
When the server indicates a hybrid group such as X25519MLKEM768, it means the connection negotiated a hybrid classical+PQC key exchange.

That distinction is important:

  • Capability (IsSupported) tells you what the client can do.
  • Negotiation result tells you what this specific TLS session actually did.

Final thoughts

For .NET teams, this project is a strong template for early PQC adoption:

  • no app-layer TLS customization required,
  • clear runtime capability checks,
  • explicit Linux/OpenSSL dependency management in Docker,
  • observable handshake outcome.

If your domain has long-lived confidentiality requirements, this is an excellent starting point to move from theory to implementation.

Top comments (0)