Create your first hybrid certificate chain
In this tutorial, you issue a hybrid root certificate for the fictitious company Contoso, use it to issue a
server certificate for www.example.com, and then validate the chain twice: once the way any existing
software validates it, and once including the post-quantum part. Hybrid means that every certificate
carries its usual RSA signature plus an ML-DSA signature. Software that doesn't know about PQ sees an
ordinary RSA certificate.
Prerequisites
- The .NET 8 SDK or later to run the sample, and the .NET 10 SDK to build the library.
- OpenSSL 3.5 or later for your process architecture. See Prerequisites.
- Optional, for the cross-check in step 6: any OpenSSL command-line tool, version 3.0 or later.
Steps
Step 1: Create a project and reference the library
dotnet new console -n ContosoPki -f net8.0 cd ContosoPki dotnet add reference ..\sys-sec-crypto\WKL.System.Security.Cryptography.Extensions.csproj
Adjust the path to where the library's source code is on your machine.
Step 2: Configure OpenSSL and turn on hybrid mode
Replace the contents of Program.cs with:
using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using WKL.System.Security.Cryptography; using WKL.System.Security.Cryptography.X509Certificates; PostQuantumConfig.OpenSslPath = @"C:\OpenSSL\bin"; PostQuantumConfig.EnablePostQuantum(CryptoMode.Hybrid); Console.WriteLine(PostQuantumConfig.GetOpenSslVersion());
OpenSslPath is the folder that contains libcrypto. EnablePostQuantum loads OpenSSL and throws a
CryptographicException if it's missing or doesn't provide ML-DSA and ML-KEM. That way, the library never
quietly continues without the PQ part.
Check: dotnet run prints the OpenSSL version, for example OpenSSL 3.6.1 27 Jan 2026.
Step 3: Issue the hybrid root certificate
Append:
using var rootKey = RSA.Create(3072); var rootRequest = new CertificateRequest( "CN=Contoso Root CA", rootKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); rootRequest.CertificateExtensions.Add( new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); using var root = rootRequest.CreateSelfSigned( DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(5), CryptoMode.Hybrid); Console.WriteLine(root.GetSignatureAlgorithmDescription());
This is the .NET call with one extra argument. Without CryptoMode.Hybrid, you'd get an ordinary RSA
certificate. The library generates an ML-DSA-65 key pair for the certificate and keeps the private key
available for the lifetime of the process.
Check: The output is sha256RSA + ML-DSA-65 (Hybrid).
Step 4: Issue the server certificate
using var leafKey = RSA.Create(2048); var leafRequest = new CertificateRequest( "CN=www.example.com", leafKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); using var leaf = leafRequest.Create( root, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1), RandomNumberGenerator.GetBytes(16), CryptoMode.Hybrid); using var server = leaf.CopyWithPrivateKey(leafKey); Console.WriteLine(server.HasPqSignature());
The root certificate signs with its RSA key and with the ML-DSA key from step 3. If that ML-DSA key isn't
in the process, for example because you loaded the root certificate from a PEM file instead of a PFX file,
the call fails with a CryptographicException that names the issuer.
Check: The output is True.
Step 5: Validate the chain, including the post-quantum part
using var chain = new X509Chain(); chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; chain.ChainPolicy.CustomTrustStore.Add(root); chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; bool valid = chain.Build(server, CryptoMode.Hybrid); Console.WriteLine($"Chain valid: {valid}"); foreach (var element in chain.GetValidationReport().Elements) { Console.WriteLine($"{element.Subject}: {element.PqSignatureStatus}"); } File.WriteAllText("root.pem", root.ExportCertificatePem()); File.WriteAllText("server.pem", server.ExportCertificatePem());
Build with CryptoMode.Hybrid first runs the usual .NET validation and then verifies every ML-DSA
signature in the chain. An invalid signature returns false, just as .NET does. chain.ChainStatus gives
the classical reason, and GetValidationReport() gives the PQ result for each certificate.
Check: The output is:
Chain valid: True CN=www.example.com: Valid CN=Contoso Root CA: Valid
Step 6: Cross-check with a classical verifier
openssl verify -CAfile root.pem server.pem
OpenSSL verifies the RSA signatures and skips the noncritical extensions that hold the PQ part.
Check: The output is server.pem: OK.
Expected result
You now have two certificates that any existing software accepts as RSA certificates, and whose ML-DSA signatures the library also verifies. The private ML-DSA keys live only in the running process. To keep them, export the certificate as a PFX file.