Post-quantum / hybrid key wrapping for ASP.NET Core Data Protection. One line in
Program.csand every persisted Data Protection key — cookie keys, antiforgery keys, session tickets, Blazor circuit tokens, everyIDataProtectorpayload at rest — is wrapped under an ML-KEM-768 (FIPS 203) + AES-256-GCM hybrid envelope.
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("keys"))
.ProtectKeysWithPostQuantum(o => o.KeyStorePath = "keys/pq-keystore.txt");That's it.
Six packages, one CLI, four end-to-end samples. Mix and match.
| Package | Purpose |
|---|---|
PostQuantum.DataProtection |
The core. Encryptor / decryptor, key manager, file-backed key store, DI extensions, health check, scheduled rotation, retention/prune helper, metrics + tracing. Supports ML-KEM-512, ML-KEM-768, ML-KEM-1024 + HKDF and X-Wing combiners. |
PostQuantum.DataProtection.AzureKeyVault |
IPostQuantumKeyStore backed by Azure Key Vault Secrets. One line: services.AddPostQuantumDataProtectionAzureKeyVault(vaultUri). |
PostQuantum.DataProtection.Aws |
IPostQuantumKeyStore backed by AWS Secrets Manager. One line: services.AddPostQuantumDataProtectionAws(). |
PostQuantum.DataProtection.Redis |
IPostQuantumKeyStore backed by Redis. Natural pair with PersistKeysToStackExchangeRedis. |
PostQuantum.DataProtection.OpenTelemetry |
One-line OTel wiring. .AddPostQuantumDataProtectionInstrumentation() on a MeterProviderBuilder / TracerProviderBuilder. |
PostQuantum.DataProtection.Testing |
FakePostQuantumKeyStore + AddPostQuantumDataProtectionTesting() for consumer unit tests — no cloud, no disk. |
PostQuantum.DataProtection.Cli (pq-dp) |
dotnet tool for inspecting persisted DP key XML files. No secrets emitted. |
Samples:
| Sample | What it shows |
|---|---|
AspNetCore.Sample |
Minimal-API host with cookie auth + antiforgery, both PQ-protected. |
WorkerService.Sample |
Worker Service using PQ outside ASP.NET Core, with scheduled rotation. |
Blazor.Sample |
Blazor Server with PQ-protected circuit + cookie + IDataProtector roundtrip. |
MultiReplica.Sample |
Two simulated replicas sharing one Key Vault — proves the multi-replica shape end to end. |
- Hybrid = belt-and-braces. The AES-256-GCM key that wraps each Data Protection element is
HKDF-derived from both the ML-KEM shared secret and a classical secret from your
IContentKeyProvider. An attacker has to defeat both layers to recover plaintext. A classically-broken passphrase still has ML-KEM in the way; a (hypothetical) quantum-broken ML-KEM still has the classical wrap in the way. This is the IETF hybrid-KEM pattern. - ML-KEM-768 is the general-purpose pick. NIST category 3 (≈ 192-bit classical strength) —
the level NIST recommends for general use. We default there; switch to 512 or 1024 when the
selectable parameter set lands (see
KNOWN-GAPS.md§C1). - Verified against FIPS 203. Our integration is pinned by a NIST-aligned KAT vector: given
the seed bytes from
post-quantum-cryptography/KAT, BouncyCastle's ML-KEM-768 produces the exactpk(1184 bytes),sk(2400 bytes), and decapsulates the published ciphertext to the published shared secret — byte for byte. The test runs on every PR.
| Situation | Verdict |
|---|---|
| You run ASP.NET Core, Blazor, or any .NET host with Data Protection. | ✅ Yes |
| Your threat model includes "harvest-now, decrypt-later" against the key store. | ✅ Especially |
| You want defense-in-depth without ripping out your existing Data Protection wiring. | ✅ One line |
| You need a FIPS 140-3 validated module today. | ❌ Roadmap — see §C6 |
| You want PQ session-key negotiation over the wire. | ❌ Different layer — TLS hybrid groups belong in the stack |
dotnet add package PostQuantum.DataProtection --prerelease
dotnet add package PostQuantum.KeyManagement --prereleaseusing Microsoft.AspNetCore.DataProtection;
using PostQuantum.DataProtection.Hybrid;
using PostQuantum.KeyManagement;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddPostQuantumKeyManagement(options =>
{
options.Passphrase = builder.Configuration["KeyManagement:Passphrase"]
?? throw new InvalidOperationException("Missing passphrase");
options.WorkFactor = KekWorkFactor.Moderate;
options.KeyringPath = "keys/host-keyring.bin";
});
builder.Services
.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("keys/data-protection"))
.ProtectKeysWithPostQuantum(options =>
{
options.KeyStorePath = "keys/pq-keystore.txt";
options.Mode = HybridKemMode.Hybrid;
options.RotationInterval = TimeSpan.FromDays(90); // optional auto-rotation
});
builder.Services.AddHealthChecks().AddPostQuantumDataProtection();builder.Services
.AddDataProtection()
.ProtectKeysWithPostQuantum(builder.Configuration.GetSection("PostQuantumDataProtection"));builder.Services.AddPostQuantumDataProtectionAzureKeyVault(new Uri("https://my-vault.vault.azure.net/"));builder.Services.AddPostQuantumDataProtectionAws(o => o.Region = Amazon.RegionEndpoint.USEast1);builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddPostQuantumDataProtectionInstrumentation().AddPrometheusExporter())
.WithTracing(t => t.AddPostQuantumDataProtectionInstrumentation().AddOtlpExporter());[Fact]
public void My_service_protects_and_unprotects()
{
var services = new ServiceCollection();
services.AddPostQuantumDataProtectionTesting();
using ServiceProvider sp = services.BuildServiceProvider();
IDataProtector p = sp.GetRequiredService<IDataProtectionProvider>().CreateProtector("p");
Assert.Equal("hi", p.Unprotect(p.Protect("hi")));
}dotnet tool install --global PostQuantum.DataProtection.Cli --prerelease
pq-dp inspect keys/data-protection/key-c6b3b03f-b73a-477b-92e5-d19ae0e0b5fd.xmlFormat version: 1
Mode: Hybrid
KEM algorithm: ML-KEM-768
Public key id: pq-mlkem768-4411e03446f5
KEM ciphertext: 1088 bytes
Classical wrap: 236 chars
AES-GCM nonce: 12 bytes
AES-GCM tag: 16 bytes
AES-GCM ciphertext: 120 bytes
<encryptedSecret decryptorType="PostQuantum.DataProtection.PostQuantumXmlDecryptor, …">
<pqEnvelope xmlns="https://schemas.systemslibrarian.dev/pq-dataprotection/2026/01"
version="1" mode="Hybrid" publicKeyId="pq-mlkem768-…">
BASE64URL…
</pqEnvelope>
</encryptedSecret>The Base64Url blob is a versioned binary envelope: [FormatVersion | Mode | KemAlgorithm | PublicKeyId | KemCiphertext | ClassicalWrappedKey | Nonce | Tag | Ciphertext]. Every field is
length-prefixed and capped at 1 MiB. Full byte layout in docs/wire-format.md.
Real numbers from BenchmarkDotNet on a modern x86_64 host (full table in
docs/benchmarks.md):
| Operation | Mean |
|---|---|
| ML-KEM-768 encapsulate | ~93 µs |
| ML-KEM-768 decapsulate | ~101 µs |
| Full envelope encrypt (Hybrid) | ~89 µs |
| Full envelope decrypt (Hybrid) | ~137 µs |
Envelope work happens at DP key persist / load — startup-path, not request-path. Cookie
verification, antiforgery validation, and IDataProtector.Unprotect all read keys from the
in-memory key ring; they never go through the envelope.
The library publishes a Meter and an ActivitySource named PostQuantum.DataProtection.
Subscribe with OpenTelemetry (or any IMeterListener):
| Signal | What it tells you |
|---|---|
pq_dataprotection.encryptions |
Rate of fresh DP keys being wrapped. Tagged by mode. |
pq_dataprotection.decryptions |
Rate of envelope reads. Tagged by mode. |
pq_dataprotection.decrypt_failures |
Tagged by reason: wrong_xml_element, malformed_envelope, unsupported_algorithm, unknown_keypair, auth_failed. Page on any non-zero rate. |
pq_dataprotection.rotations |
Rate of PQ keypair rotations. Should be quiet outside scheduled windows. |
pq_dataprotection.encrypt.duration / decrypt.duration |
Histograms in ms. P95 should sit < 1 ms on modern hardware. |
AddPostQuantumDataProtection() health check |
Real roundtrip on every probe. Page if Unhealthy. |
docs/threat-model.md is the precise statement: attacker model (A1 → A6 vs.
B1 → B4) and 10 numbered security invariants the library is designed to hold. Each invariant
corresponds to one or more tests.
SECURITY.md covers reporting vulnerabilities (use GitHub Security Advisories,
not public issues), supported versions, and the recommended deployment posture.
docs/deployment.md is the production operations checklist: pre-deploy
verification, multi-replica model, KEK rotation playbook, disaster recovery matrix, monitoring
signals.
The library wraps Data Protection keys at rest with a verified-against-FIPS-203 ML-KEM-768 + AES-256-GCM
hybrid envelope. That is the entire claim. We do not claim to make ASP.NET Core's request
pipeline post-quantum, to negotiate PQ session keys, or to be FIPS 140-3 validated today. See
KNOWN-GAPS.md for the full breakdown of what's deliberate, what's roadmap, and
what's been closed across previews.
Whether you're on ProtectKeysWithDpapiNG, ProtectKeysWithAzureKeyVault (the key wrap, not the
secrets store), or ProtectKeysWithCertificate, the migration is non-disruptive — existing keys
keep decrypting under their old decryptor type while fresh keys roll forward under PQ. Step by
step in docs/migration.md.
- Deterministic builds with CI-enforced reproducibility (the CI repacks twice and asserts
byte-identical
.nupkgSHA-256). - SourceLink + symbol packages so debuggers fetch the exact GitHub source for every commit you ship.
EnablePackageValidationcatches API surface drift between framework targets.TreatWarningsAsErrorspluslatest-recommendedanalyzers — zero-warning policy across the repo.- Pinned transitive overrides for known-vulnerable packages (e.g.
System.Security.Cryptography.Xmlpinned to the patched 8.0.3 to avoid GHSA-37gx-xxp4-5rgx and GHSA-w3x6-4m5h-cxqf). - SBOM-friendly metadata. Every dependency is an explicit
<PackageReference>. Recipes for CycloneDX and the Microsoft SBOM tool indocs/supply-chain.md.
To verify a published package:
sourcelink test PostQuantum.DataProtection.<version>.nupkg
# Reproduce the build from the matching commit and compare SHA-256:
git checkout v<version>
dotnet pack -c Release -o /tmp/local
sha256sum /tmp/local/PostQuantum.DataProtection.<version>.nupkgdotnet build PostQuantum.DataProtection.slnx -c Release
dotnet test PostQuantum.DataProtection.slnx -c Release --no-build87 tests across four suites — core, AzureKeyVault, Aws, Testing. Coverage gate at ≥ 85% line
/ ≥ 75% branch (current 89.5% line / 78.6% branch). Property-based fuzz-lite contract tests
drive 30 000 random inputs through both decoders on every run; a standalone SharpFuzz harness in
fuzz/ is set up for AFL-driven exploration.
- .NET 8.0, 9.0, or 10.0 (multi-targeted).
- A registered
PostQuantum.KeyManagementIContentKeyProviderfor the classical KEK layer.
0.1.0-preview.4. The API surface and the binary envelope wire format are versioned and
backward-compatible across the preview series (every envelope written by preview.1 decodes
under preview.4). Pre-1.0 the wire format may still change with a deliberate version bump
and a CHANGELOG.md note. The path to 1.0 is mapped in future.md.
The two remaining gates on 1.0 are calendar-time, not code-time: third-party cryptographic
review, and at least one cloud-backed key store in real production use. Both are tracked in
KNOWN-GAPS.md §D.
MIT © 2026 Paul Clark.
To God be the glory — 1 Corinthians 10:31.
{ "PostQuantumDataProtection": { "KeyStorePath": "keys/pq-keystore.txt", "Mode": "Hybrid", "RotationInterval": "90.00:00:00" } }