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:
- GitHub repo: https://github.com/ConnectingApps/PQCHttpClient
- .NET 10 libraries (PQC APIs): https://learn.microsoft.com/dotnet/core/whats-new/dotnet-10/libraries
- OpenSSL TLS groups (including hybrid ML-KEM groups): https://docs.openssl.org/3.6/man3/SSL_CTX_set1_curves/
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:
-
Capability detection:
MLKem.IsSupportedreports whether ML-KEM is truly available in the runtime environment. -
Real HTTPS traffic: standard
HttpClientcall, no custom handshake code. - 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}");
}
}
Key takeaways for readers
-
MLKem.IsSupportedis 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"]
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 versioncheck 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
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)