Clean, high-level envelope-encryption key management for .NET — symmetric, rotatable, honestly scoped.
⚠️ What "PostQuantum" means in this package today. The only "post-quantum" property here is symmetric-by-key-size: AES-256-GCM (wrapping) and Argon2id (passphrase stretching) keep useful margin against a quantum adversary because Grover's algorithm only halves the effective security of a symmetric primitive — AES-256 retains ~128-bit post-quantum strength. There is no post-quantum asymmetric KEM in this release — no ML-KEM, no X-Wing, no hybrid wrap. PQ asymmetric KEK-wrapping is roadmap, not shipped. SeeKNOWN-GAPS.md§1 for the precise scope andfuture.mdfor the planned PQ-wrapping layer. We would rather under-claim than overstate.
PostQuantum.KeyManagement is the small, honest abstraction over the part of cryptography that is
easiest to get wrong: managing the keys that protect your keys. It implements the
envelope encryption pattern — short-lived random content keys (data-encryption keys, "DEKs")
wrapped by long-lived key-encryption keys ("KEKs") — and makes the KEK pluggable so the same
code runs against a local passphrase today and a cloud HSM tomorrow.
It is the natural companion to PostQuantum.FileEncryption,
PostQuantum.Jwt, and the rest of the PostQuantum.* family.
✅ Stable (
1.0.0). The public API and the keyring/token wire formats are frozen; v1/v2/v3 keyrings all still import. Read KNOWN-GAPS.md before relying on it — it is deliberately blunt about the honest scope (notably: the post-quantum property here is symmetric-by-key-size; no asymmetric KEM ships in this package) and the documented limitations carried into 1.0 (no third-party audit yet; cloud KMS providers are an extension point, not yet shipped). Release notes in CHANGELOG.md; roadmap in future.md.
Most encryption bugs are not broken ciphers — they are mishandled keys: keys logged by accident, keys that can never be rotated, keys hard-coded next to the data they protect. This library narrows the surface you have to reason about to three things:
| Question | Answer |
|---|---|
| Where does a fresh content key come from? | CreateContentKeyAsync() |
| How do I get it back later? | UnwrapAsync(wrappedKey) |
| How do I rotate the key that protects everything? | Rotate(...) + RewrapAsync(...) |
Everything else — random key generation, zeroing key material, authenticated wrapping, thread safety, hostile-input rejection — is handled for you and is identical across providers.
Three working samples ship in samples/:
# Minimal API: HTTP endpoints that envelope-encrypt request bodies and rotate KEKs
cd samples/MinimalApi.Sample && ASPNETCORE_ENVIRONMENT=Development dotnet run
# Worker Service: liveness probe + scheduled rotation + durable keyring
cd samples/WorkerService.Sample && DOTNET_ENVIRONMENT=Development dotnet run
# EF Core: per-row envelope encryption with SQLite that survives a KEK rotation
cd samples/EfCore.Sample && dotnet runEach sample has its own README explaining what it demonstrates and how to adapt it to production.
- .NET 8.0, 9.0, or 10.0 (multi-targeted, deterministic, SourceLink, symbol packages).
dotnet add package PostQuantum.KeyManagement --prereleaseOne package contains everything: the core abstraction, the local provider, the
Microsoft.Extensions.DependencyInjection integration, the FileKeyringStore, and the
KeyManagementHealthCheck. Future cloud KMS providers (Azure Key Vault, AWS KMS, Google Cloud KMS)
will ship as separate packages so they can carry their own SDK dependencies without bloating the
core.
using PostQuantum.KeyManagement;
using PostQuantum.KeyManagement.Local;
// 1. Create a provider. The local provider derives its KEK from a passphrase with Argon2id.
using var keys = LocalContentKeyProvider.Create("a strong, high-entropy passphrase");
// Persist this salt (it is NOT secret) so you can re-derive the same KEK later.
byte[] salt = keys.ActiveSalt.ToArray();
// 2. Mint a fresh content key, encrypt your data with it, and store the *wrapped* key.
WrappedContentKey wrapped;
using (ContentKey key = await keys.CreateContentKeyAsync())
{
// key.Key is a 256-bit DEK — use it with AES-GCM, ChaCha20-Poly1305, your file format, etc.
EncryptMyData(key.Key);
wrapped = key.WrappedKey; // safe to store next to the ciphertext
string token = wrapped.Encode(); // ...or as a compact URL-safe string
}
// 3. Later — recover the content key from its wrapped form.
using (ContentKey key = await keys.UnwrapAsync(wrapped))
{
DecryptMyData(key.Key);
}Re-deriving the same KEK in a different process:
using var keys = LocalContentKeyProvider.Create("a strong, high-entropy passphrase", salt);
using ContentKey key = await keys.UnwrapAsync(wrapped); // works — same KEKFor untrusted input (network payloads, user-supplied tokens), use the exception-free overload:
if (WrappedContentKey.TryDecode(token, out var wrapped) && wrapped is not null)
{
using ContentKey key = await keys.UnwrapAsync(wrapped);
// ...
}Rotation never re-encrypts your data. It re-wraps the content key under a new KEK; the content key itself — and therefore your ciphertext — is untouched.
using var keys = LocalContentKeyProvider.Create("old passphrase");
WrappedContentKey wrapped = (await keys.CreateContentKeyAsync()).WrappedKey;
// Rotate in a new KEK. Old keys still unwrap; new content keys use the new KEK.
string newKeyId = keys.Rotate("new, stronger passphrase");
// Migrate an existing wrapped key onto the new KEK at your leisure.
WrappedContentKey migrated = await keys.RewrapAsync(wrapped);
// migrated.KeyId == newKeyId, but it still unwraps to the exact same content key.A short, opinionated checklist — the long version is in docs/deployment.md:
- Rotate KEKs on a schedule, not on impulse. A common starting cadence is every 60–90 days for KEKs; the previous KEKs stay in the ring and keep unwrapping existing data.
- Never reuse a salt across rotations. The default
Rotate(newPassphrase)overload generates a fresh random salt; only pass a salt explicitly if you have a specific reason to. - DEKs rotate themselves automatically.
CreateContentKeyAsyncmints a fresh DEK every time, so per-record / per-blob keys are already rotating without any extra ceremony. RewrapAsyncis your migration tool. It re-wraps an old DEK under the active KEK without touching the underlying ciphertext. Do it lazily on access, not in a big batch — that way rotation never blocks a deploy.- Persist the keyring on every rotation. The
WorkerService.Sampleshows the shape: rotate, then immediately callIKeyringStore.SaveAsyncso a crash between rotations doesn't leave the on-disk keyring stale. - Back up the keyring file. Losing the keyring means losing the ability to unwrap every key
ever wrapped by it. See
docs/deployment.md§ 8 for the recovery matrix.
After one or more rotations the provider holds several KEKs. Export the ring's non-secret structure (salts + Argon2id parameters + a per-KEK integrity verifier + which KEK is active) and rebuild it later by supplying the passphrases — the export never contains key material or passphrases.
// Before shutdown: persist the keyring structure (safe to store next to your data).
string keyring = keys.ExportMetadata().Encode();
// After restart: rebuild, providing the passphrase for each KEK by id.
LocalKeyringMetadata metadata = LocalKeyringMetadata.Decode(keyring);
PassphraseResolver passphrases = keyId => LookUpPassphraseFor(keyId);
using var keys = LocalContentKeyProvider.Import(metadata, passphrases);
// Every KEK is back: keys wrapped under rotated-out KEKs still unwrap, and the active KEK is restored.A wrong passphrase is caught at import time (constant-time HMAC-SHA256 verifier) with a clear
InvalidOperationException naming the offending key id — not as a delayed
AuthenticationTagMismatchException at first unwrap.
LocalKekOptions ships with presets aligned to RFC 9106 and OWASP:
| Preset | Memory | Iterations | Parallelism | When to use |
|---|---|---|---|---|
Interactive |
64 MiB | 3 | 4 | server-side default — RFC 9106 §4 "second" |
Moderate |
256 MiB | 4 | 4 | background jobs, admin operations |
Sensitive |
2 GiB | 1 | 4 | long-lived master KEKs — RFC 9106 §4 "first" |
LowMemory |
19 MiB | 2 | 1 | constrained hosts (CI, edge) — OWASP minimum |
using var keys = LocalContentKeyProvider.Create("strong passphrase", LocalKekOptions.Sensitive);The instance defaults match Interactive. Whatever you pick gets recorded per-KEK in the exported
metadata, so future rebuilds reproduce the exact same KEK.
The package wires the provider into any Microsoft.Extensions.DependencyInjection host (ASP.NET
Core, worker services, Blazor) in one line, persists the keyring via an atomic file store, and
exposes a real-round-trip health check. No second using required — the AddPostQuantumKeyManagement
extensions live in the Microsoft.Extensions.DependencyInjection namespace, the same namespace
every ASP.NET Core Program.cs already imports.
builder.Services.AddPostQuantumKeyManagement(options =>
{
options.Passphrase = builder.Configuration["KeyManagement:Passphrase"]
?? throw new InvalidOperationException("Missing passphrase");
options.WorkFactor = KekWorkFactor.Interactive;
options.KeyringPath = "keyring.bin"; // optional; survives restarts via FileKeyringStore
});
builder.Services.AddHealthChecks().AddPostQuantumKeyManagement();
// Anywhere in the app:
public sealed class SecretsService(IContentKeyProvider keys) { /* ... */ }The samples table:
| Sample | What it shows |
|---|---|
MinimalApi.Sample |
ASP.NET Core minimal-API with POST/GET/rotate endpoints + /health. |
WorkerService.Sample |
A worker service with a liveness probe and a scheduled rotation worker that persists the keyring on every rotation. |
EfCore.Sample |
Per-row envelope encryption with EF Core + SQLite. Demonstrates that a KEK rotation does not invalidate existing rows. |
The DEK that CreateContentKeyAsync returns is just a 256-bit symmetric key — it composes with any
authenticated cipher. The shape with PostQuantum.FileEncryption looks like this (sketch — adjust
to the actual FileEncryption API):
using var keys = LocalContentKeyProvider.Create(passphrase);
// Encrypt a file: mint a DEK, hand it to FileEncryption, persist the wrapped key.
WrappedContentKey wrapped;
using (ContentKey dek = await keys.CreateContentKeyAsync())
{
await PostQuantumFile.EncryptAsync(
input: "secret.docx",
output: "secret.docx.enc",
key: dek.Key); // ReadOnlySpan<byte> — pass straight through
wrapped = dek.WrappedKey;
}
File.WriteAllText("secret.docx.enc.key", wrapped.Encode()); // non-secret, safe to store
// Decrypt later: load the wrapped key, unwrap, decrypt.
WrappedContentKey w = WrappedContentKey.Decode(File.ReadAllText("secret.docx.enc.key"));
using (ContentKey dek = await keys.UnwrapAsync(w))
{
await PostQuantumFile.DecryptAsync(
input: "secret.docx.enc",
output: "secret.docx",
key: dek.Key);
}The DEK doubles as a JWT signing key (HS-family) or encryption key (A256GCM enc algorithm):
using var keys = LocalContentKeyProvider.Create(passphrase);
string token;
using (ContentKey dek = await keys.CreateContentKeyAsync())
{
// Mint a JWT signed/encrypted with the DEK, then persist the wrapped key alongside the JWT
// (in a sidecar, in a "kid" claim that points at a wrapped-key store, etc).
token = PostQuantumJwt.IssueHS256(claims: new { sub = "user-42" }, key: dek.Key);
string keyId = dek.WrappedKey.KeyId; // record alongside the token
string wrappedKeyToken = dek.WrappedKey.Encode();
SaveWrappedKey(keyId, wrappedKeyToken);
}
// Verify later: load the wrapped key by id, unwrap, verify the JWT.
WrappedContentKey w = WrappedContentKey.Decode(LoadWrappedKey(KidFromJwt(token)));
using (ContentKey dek = await keys.UnwrapAsync(w))
{
var claims = PostQuantumJwt.VerifyHS256(token, key: dek.Key);
}The same shape applies to column-level encryption in EF Core (see samples/EfCore.Sample)
and to any other library that takes a symmetric key as ReadOnlySpan<byte>.
| Concern | Local provider | Cloud KMS provider (when shipped) |
|---|---|---|
| Where the KEK lives | Derived in-process from a passphrase via Argon2id | In the cloud HSM; never leaves the service |
| Wrap / unwrap latency | ~microseconds (AES-GCM in-process) | One network round-trip per call (~ms) |
| Cost | Free | Per-call charges |
| Offline / air-gapped | Yes | No |
| Audit trail | Whatever you log | Cloud provider's audit log |
| Best for | Single-tenant apps, edge, dev/test, file vaults | Multi-tenant SaaS, compliance regimes, fleet scale |
The same IContentKeyProvider interface fronts both. Switching from local to cloud is changing one
registration line — no application logic moves. Cloud providers (Azure Key Vault, AWS KMS, GCP
KMS) are tracked in future.md; the extension point is documented in
docs/extending-providers.md.
Scope of the "post-quantum" claim. Today this library's only post-quantum property is
symmetric-by-key-size — AES-256-GCM and Argon2id retain useful margin against a quantum
adversary because Grover only halves their security. No post-quantum asymmetric KEM is shipped
in this release (no ML-KEM, no X-Wing, no hybrid wrap); PQ asymmetric KEK-wrapping is roadmap.
Do not describe deployments built on this release as "quantum-safe key exchange." See
KNOWN-GAPS.md §1 for the
precise scope and future.md for the planned layer.
- Content keys are 256-bit and drawn from
RandomNumberGenerator. - Wrapping uses AES-256-GCM (authenticated): tampering with a wrapped key is detected, never silently decrypted to garbage.
- Local KEK derivation uses Argon2id with presets aligned to RFC 9106 §4 and OWASP, tunable
via
LocalKekOptions. SeeSECURITY.mdfor the recommended production profile. - Memory hygiene: plaintext key material lives in
ContentKey, which zeroes its buffer onDispose. Always wrap content keys inusing. - Quantum stance: see the Scope of the "post-quantum" claim note above. We would rather
under-claim than overstate. (
KNOWN-GAPS.md§1) - Thread-safety:
LocalContentKeyProvideris safe for concurrent use. Rotation, wrap, and unwrap serialise on a private lock so a rotating thread cannot dispose a KEK that another thread is using. - Hostile-input resistance: every token decoder uses overflow-safe length arithmetic and caps
fields at 1 MiB; the keyring decoder caps the number of KEKs. A malicious token cannot trigger
huge allocations or out-of-bounds reads.
TryDecodeoverloads exist for inputs from untrusted sources. - Boundary validation: empty passphrases are rejected with a clear
ArgumentExceptionat the library boundary, before any cryptographic work runs. - Safe diagnostics: the records that carry byte arrays (
WrappedContentKey,LocalKekMetadata,LocalKeyringMetadata) overrideToString()to redact byte content (<NN bytes>), so they are safe to log in production. Salts, KEK ids, and Argon2id parameters are non-secret and shown in full. - Cross-platform atomic persistence:
FileKeyringStoreusesFile.Replace(POSIXrename(2)) with a bounded retry on Windows-specificIOExceptionfrom concurrent readers — single-writer + many-readers, the deployment model indocs/deployment.md, is race-free in practice.
| Document | What it tells you |
|---|---|
docs/threat-model.md |
Attacker model + 10 numbered security invariants |
docs/versioning.md |
SemVer + wire-format compatibility commitments |
docs/deployment.md |
Production operational checklist |
docs/extending-providers.md |
How to add a cloud KMS provider |
KNOWN-GAPS.md |
What the library deliberately does NOT do yet |
future.md |
Concrete plan to ship cloud providers and reach 1.0 |
Please report vulnerabilities privately — see SECURITY.md.
1.0.0 — first stable release. The public API and the keyring/token wire formats are frozen;
v1/v2/v3 keyrings all still import. No crypto-logic changes since 0.4.0-preview.2 (AES-256-GCM
content-key wrap, Argon2id KEK derivation, HMAC-SHA256 keyring verifier, and memory zeroing are
byte-for-byte identical) — 1.0 is the SemVer commitment plus a supply-chain fix (the EF Core sample
moved to the patched SQLitePCLRaw 3.0.x to clear advisory GHSA-2m69-gcr7-jv3q; samples are not part
of the shipped package).
Honest limitations carried into 1.0 (documented, not blockers — see
KNOWN-GAPS.md and SECURITY.md):
- The post-quantum property here is symmetric-by-key-size only (AES-256-GCM + Argon2id, ~128-bit post-quantum strength under Grover). No ML-KEM / hybrid asymmetric KEM ships in this package.
- Not yet independently audited. Written with care, KATs, hostile-input tests, and a published threat model; a third-party review remains on the roadmap.
- Cloud KMS providers are an extension point, not yet shipped as packages.
The roadmap is in future.md.
dotnet build # builds net8.0, net9.0, net10.0
dotnet test # 74 tests across the core and DI packages
dotnet pack -c ReleaseMIT © 2026 Paul Clark.
To God be the glory — 1 Corinthians 10:31.