Hybrid post-quantum cryptography for .NET — in your browser.
This page is the gold-standard reference for
PostQuantum.Hybrid.
It leads with the recommended high-level API
(HybridEnvelope.Seal / Open), then peels
back the layers so you can see what production code should — and
should not — look like.
Why hybrid?
Today's TLS protects key exchange with elliptic-curve Diffie-Hellman (X25519) and authentication with Ed25519 signatures. Both are safe against the attackers we actually have — but a sufficiently large quantum computer would break both in polynomial time (Shor's algorithm). NIST's ML-KEM-768 (FIPS 203) and ML-DSA-65 (FIPS 204) are post-quantum replacements that no known quantum algorithm breaks — but they are new, and the cryptographic community has had decades to scrutinize X25519 / Ed25519 that ML-KEM and ML-DSA have not yet had.
Hybrid construction sidesteps that asymmetry of confidence. PostQuantum.Hybrid runs both primitives in parallel and combines their outputs so the resulting shared secret (or signature) is safe as long as either primitive holds:
Hybrid KEM
X25519 + ML-KEM-768
Two key encapsulations run side by side. The classical and post-quantum shared secrets are mixed through HKDF-SHA256 with the KEM ciphertexts bound into the transcript, so an attacker breaking just one primitive learns nothing.
Hybrid signatures
Ed25519 + ML-DSA-65
Two signatures are concatenated and BOTH must verify. A forger needs to defeat both schemes simultaneously, which is strictly harder than defeating either one alone.
The library is fail-closed: every parse, verify, and decapsulate
failure throws or returns false. There is no silent
fallback to "best effort" mode. ML-KEM uses FIPS-203 implicit
rejection, which means malformed ciphertexts yield a pseudorandom
shared secret — the combined hybrid secret then diverges and the
downstream AEAD authentically rejects.
Live demo
Three depths of API. The default — Recommended — is what new code should look like. The other tabs peel layers off so you can see exactly what the library does for you.
Default for new code. HybridEnvelope.Seal does the entire
pipeline in one call: KEM encapsulation (X25519 + ML-KEM-768),
HKDF-SHA256 key derivation, AES-256-GCM with the KEM ciphertext
bound into associated data, nonce generation, and shared-secret
zeroization on exit.
The crypto runs on the server. The Blazor Server interactive render
mode means clicks call into HybridEnvelope directly
without exposing private keys to your browser. If you want to drive
the same operations from the shell, the JSON endpoints at
/swagger
are wired through the same recommended path.
Key rotation
Production-grade key handling rotates KEM keys on a schedule so a
single key compromise doesn't leak forever. The library exposes
IRotatingHybridKemKeyProvider in
PostQuantum.Hybrid.AspNetCore for this — version
counter, Rotated event, atomic in-memory swap on
file change. Below is a live demonstration of that contract.
Bumps every time you click Rotate. Fires the
IRotatingHybridKemKeyProvider.Rotated event in the
library — applications subscribe to it to invalidate caches
or kick a key-distribution refresh.
Seal a small payload under the current key. You can then try to open it after rotating to see fail-closed behavior.
No rotations yet. Click Rotate now above.
This page rotates in-process on click for clarity. The library's
shipping implementation reloads from disk via FileSystemWatcher;
see
samples/KeyRotationDemo
for the file-driven flow with the
SimulatedSidecarService.
Security hygiene
Five Roslyn analyzers ship in
PostQuantum.Hybrid.Analyzers and turn the most common
misuse patterns into build-time errors. Each rule below shows the
shape of bad code that fires it, the exact diagnostic the
compiler will print, and the analyzer-clean replacement that
production code in this repo follows.
PostQuantum.Hybrid disposable not declared with `using`
WarningHybrid private-key and encapsulation-result types zero their buffers on Dispose. A local of one of those types declared without `using` leaks the buffers until GC (potentially forever for shared statics).
var pair = HybridKem.GenerateKeyPair();
var enc = HybridKem.Encapsulate(pair.PublicKey);
// ... enc.SharedSecret used ...
// Buffers stay in memory until GC fires.using var pair = HybridKem.GenerateKeyPair();
using var enc = HybridKem.Encapsulate(pair.PublicKey);
// Buffers are zeroed on scope exit, even if we throw.'enc' holds sensitive key material; declare with `using` so its buffers are zeroed on disposeHybrid KEM SharedSecret used as a key without HKDF
WarningA hybrid KEM shared secret is uniform 32 bytes — coincidentally what AES-256 needs — but using it directly loses domain separation across uses of the same exchange. Feed it through HKDF.Expand with a purpose-specific `info` parameter.
using var aes = new AesGcm(enc.SharedSecret, tagSizeInBytes: 16);
// Direct use of the shared secret as the AES key — no domain
// separation. Two uses of the same exchange share state.var aesKey = new byte[32];
try
{
HKDF.Expand(
HashAlgorithmName.SHA256,
enc.Secret, // typed wrapper (not raw byte[])
aesKey,
info: AeadLabel); // purpose-specific
using var aes = new AesGcm(aesKey, tagSizeInBytes: 16);
// ... use aes ...
}
finally
{
CryptographicOperations.ZeroMemory(aesKey);
}'enc.SharedSecret' is being passed directly to a symmetric primitive constructor; feed it through HKDF.Expand with a purpose-specific 'info' firstHybridKem.Decapsulate called before HybridSignature.Verify
WarningIn sign-then-encrypt flows, the signature must be verified before any cryptographic operation runs on the carried material. Decapsulating first derives a shared secret from unauthenticated input and widens the attack surface (and can leak key-related side channels).
// Decap runs on attacker-controlled bytes before authenticity is checked.
var secret = HybridKem.Decapsulate(kemPriv, kemCt);
if (!HybridSignature.Verify(sigPub, signedBody, sig))
throw new CryptographicException("bad signature");// Verify FIRST. Only authenticated input reaches Decapsulate.
if (!HybridSignature.Verify(sigPub, signedBody, sig))
throw new CryptographicException("bad signature");
var secret = HybridKem.Decapsulate(kemPriv, kemCt);Decapsulating before verifying derives a shared secret from unauthenticated input; call HybridSignature.Verify first and abort on failureHybridSignature.Verify result discarded
WarningHybridSignature.Verify returns a bool. Discarding it lets the caller treat unauthenticated input as authenticated — exactly the failure mode signatures exist to prevent.
// Result thrown away. Forgery is now indistinguishable from valid input.
HybridSignature.Verify(sigPub, data, sig);
ProcessAuthenticatedMessage(data);if (!HybridSignature.Verify(sigPub, data, sig))
throw new CryptographicException("bad signature");
ProcessAuthenticatedMessage(data);The return value of HybridSignature.Verify must be checked; ignoring it is equivalent to skipping signature verificationAES-GCM call inside a hybrid KEM flow without associatedData binding
WarningWhen AES-GCM is keyed from a hybrid KEM exchange, binding the KEM ciphertext into associatedData prevents an attacker from swapping ciphertexts to re-key the AEAD against a different exchange.
using var aes = new AesGcm(aesKey, tagSizeInBytes: 16);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
// No associatedData. Swapping kemCt for a different exchange's
// ciphertext can re-key the AEAD without detection.using var aes = new AesGcm(aesKey, tagSizeInBytes: 16);
aes.Encrypt(
nonce, plaintext, ciphertext, tag,
associatedData: kemCt); // binds the KEM exchange inAesGcm.Encrypt is called without 'associatedData' but the same method uses hybrid KEM; bind the KEM ciphertext as associatedData
These analyzers are enforced as build errors across the
library, tests, samples, and benchmarks (TreatWarningsAsErrors=true
+ the analyzer reference). The samples in this repo serve as a living
regression test: any drift from analyzer-clean patterns fails the build.
Code you can copy
The three patterns this page demonstrates, ready to paste. Each
block follows the analyzer-clean conventions —
using on every disposable, KEM ciphertext bound into
AAD where AES-GCM is wired by hand, verify before decapsulate.
Anonymous envelope — one call per direction
using PostQuantum.Hybrid;
using PostQuantum.Hybrid.Envelopes;
// Recipient holds the KEM key pair. `using` is mandatory — PQH001
// catches any local of these types declared without it.
using var recipient = HybridKem.GenerateKeyPair();
// Sender side — one call. No HKDF, no AesGcm, no nonce generation,
// no associatedData binding, no manual zeroization.
byte[] envelope = HybridEnvelope.Seal(recipient.PublicKey, plaintext);
// Recipient side — one call. Throws CryptographicException on any
// tamper / parse failure (fail-closed).
byte[] recovered = HybridEnvelope.Open(recipient.PrivateKey, envelope);
Source:
samples/EnvelopesDemo.
Signed envelope — sender identity baked in
using PostQuantum.Hybrid;
using PostQuantum.Hybrid.Envelopes;
using var sender = HybridSignature.GenerateKeyPair();
using var recipient = HybridKem.GenerateKeyPair();
// Sign + seal in one call. The signature covers the full inner
// envelope; tamper anywhere fails verification before decryption.
byte[] envelope = SignedHybridEnvelope.Seal(
sender.PrivateKey, recipient.PublicKey, plaintext);
// Verify (PQH003) THEN decapsulate. Open throws on any failure.
byte[] recovered = SignedHybridEnvelope.Open(
sender.PublicKey, recipient.PrivateKey, envelope);Verify-before-decapsulate (PQH003) is enforced internally — the recipient never sees plaintext that hasn't been authenticated.
ASP.NET Core wiring with rotating keys
using PostQuantum.Hybrid.AspNetCore;
using PostQuantum.Hybrid.Envelopes;
var builder = WebApplication.CreateBuilder(args);
// Wire static keys from configuration / KMS / IDataProtector.
builder.Services.AddPostQuantumHybrid(options =>
{
options.KemPublicKeyPem = File.ReadAllText("kem.pub.pem");
options.KemPrivateKeyPem = File.ReadAllText("kem.priv.pem");
options.SignaturePublicKeyPem = File.ReadAllText("sig.pub.pem");
options.SignaturePrivateKeyPem = File.ReadAllText("sig.priv.pem");
});
// Or — rotating KEM keys, hot-swapped from disk by a sidecar:
// builder.Services.AddRotatingHybridKemKeys("kem.pub.pem", "kem.priv.pem");
var app = builder.Build();
app.MapPost("/seal", (string plaintext, IHybridKemKeyProvider keys) =>
{
byte[] envelope = HybridEnvelope.Seal(
keys.PublicKey, Encoding.UTF8.GetBytes(plaintext));
return Results.Json(new { envelope = Convert.ToBase64String(envelope) });
});
app.Run();
Source:
samples/KeyRotationDemo.
Use this in your project
Three packages cover the vast majority of usage. Install the base library, the recommended high-level API, and the analyzers; reach for the AspNetCore / TestingSupport / Templates packages only when you need them.
Add the packages
dotnet add package PostQuantum.Hybrid
dotnet add package PostQuantum.Hybrid.Envelopes # recommended high-level API
dotnet add package PostQuantum.Hybrid.Analyzers # always — catches misuse at build time
dotnet add package PostQuantum.Hybrid.AspNetCore # if you're in ASP.NET CoreOr start from a template
dotnet new install PostQuantum.Hybrid.Templates
# Console app pre-wired with PostQuantum.Hybrid + Analyzers.
dotnet new pqhybrid-app -n MyApp
# ASP.NET Core Minimal API with the AspNetCore wiring + analyzers.
dotnet new pqhybrid-webapi -n MyApi
# Console app showing the Envelopes one-call pattern.
dotnet new pqhybrid-envelope -n MyEnvelopeWhich package do I want?
PostQuantum.HybridHybridKem + HybridSignature primitives. Direct use is the advanced path.PostQuantum.Hybrid.EnvelopesHybridEnvelope.Seal / Open and SignedHybridEnvelope.Seal / Open.PostQuantum.Hybrid.AnalyzersPostQuantum.Hybrid.AspNetCoreIRotatingHybridKemKeyProvider, HybridEnvelopeDataProtector.PostQuantum.Hybrid.TestingSupportPostQuantum.Hybrid.Templatesdotnet new scaffold instead of copying snippets.