Secure-by-default encryption for sensitive configuration — connection strings, API keys, and whole appsettings sections — for .NET 8, 9, and 10.
Status:
0.2.0-preview.1. The API and token format may change before1.0. Not independently audited. See Security posture,SECURITY.md, andKNOWN-GAPS.md— we would rather under-claim than overstate.New in 0.2: an optional hybrid post-quantum key-wrapping provider (ML-KEM-768 + ECDH P-256), a zeroable
Secretreturn type, re-seal helpers for rotation, and thepqc-configCLI.
- Why this library exists
- When to use this (and when not to)
- Install
- 60-second tour
- Usage
- Token format
- Public API at a glance
- Security posture
- Threat model (short form)
- Supply chain
- Samples
- Compatibility
- Building from source
- Project status & roadmap
- License
Secrets end up in configuration. Connection strings, third-party API keys, signing secrets, SMTP
passwords — they live in appsettings.json, environment variables, and config servers, and they leak
the same way every time: a repo goes public, a backup is over-shared, a log line prints a section, a
laptop is lost.
The standard .NET answer is IDataProtection (great, but DPAPI / ASP.NET-keyring shaped and
classical-only) or "put it in a vault" (correct, but not every value justifies a Key Vault round-trip,
and you still want defence in depth for what does land on disk). What's missing is a small, honest,
secure-by-default primitive to encrypt individual configuration values so the bytes at rest are
ciphertext — and to decrypt them transparently when the app reads them.
PostQuantum.Configuration is that primitive. It builds directly on
PostQuantum.KeyManagement's
envelope-encryption engine, so key custody, rotation, and (later) cloud-KMS providers are a solved
problem you plug into, not something this library reinvents. Each value is sealed with its own 256-bit
content key under AES-256-GCM; the content key is wrapped by an Argon2id-derived (or KMS) key. The
result is a compact token you can commit to source control, and a configuration layer that hands your
code plaintext while the repo only ever holds ciphertext.
It is part of the PostQuantum.* family alongside
PostQuantum.Jwt and
PostQuantum.KeyManagement, and holds
to the same discipline: honesty over polish, fail-closed always, no rolled-your-own crypto.
Reach for it when:
- You want secrets at rest in
appsettings.json/ config servers / env vars to be ciphertext, not plaintext, with decryption that's transparent to application code. - You want a single, auditable primitive for "encrypt this config value" across services, with key
rotation handled by
PostQuantum.KeyManagement. - You want defence in depth in front of, or instead of, a vault for values that don't justify a per-read network call — and a forward-looking posture as post-quantum migration begins.
- You want to keep an air-gapped / local story (Argon2id-derived keys, no external service) and later swap in a cloud KMS without touching application code.
Prefer something else when:
| Need | Better fit |
|---|---|
| Centralised secret storage, access policies, audit logs, dynamic secrets | A managed vault (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) — and use this for the values that still land on disk. |
| Protecting ASP.NET cookies, antiforgery tokens, OAuth state | Microsoft.AspNetCore.DataProtection — that's exactly its job. |
| Quantum-safe key exchange / transport for tokens on the wire | PostQuantum.Jwt (PQ JOSE/JWE). This library's hybrid provider does PQ asymmetric key wrapping for config values, not transport. |
| Encrypting files or large blobs | A streaming AEAD / file-encryption library. |
Honest framing: the win here is good defaults and ergonomics, not novel cryptography. The primitives are the ones the .NET BCL already ships.
dotnet add package PostQuantum.Configuration --prereleaseThis pulls in PostQuantum.KeyManagement
(the key engine) and the Microsoft.Extensions.Configuration / DI abstractions.
using Microsoft.Extensions.Configuration;
using PostQuantum.Configuration;
using PostQuantum.KeyManagement.Local;
// 1. A key provider. (In a host: AddPostQuantumKeyManagement. Passphrase from a secret store.)
using var keys = LocalContentKeyProvider.Create("a strong passphrase", LocalKekOptions.Interactive);
IConfigurationProtector protector = new PostQuantumConfigProtector(keys);
// 2. Seal a secret → a token safe to commit.
string token = protector.Protect("Host=db;Username=app;Password=s3cr3t");
// 3. Read it back transparently through configuration.
IConfiguration config = new ConfigurationBuilder()
.AddEncrypted(
new Microsoft.Extensions.Configuration.Memory.MemoryConfigurationSource
{
InitialData = new Dictionary<string, string?> { ["ConnectionStrings:Db"] = token },
},
protector)
.Build();
string? plaintext = config.GetConnectionString("Db"); // "Host=db;Username=app;Password=s3cr3t"string token = protector.Protect("sk-live-0123456789"); // -> "pqc.v1.…"
string secret = protector.Unprotect(token); // -> "sk-live-0123456789"
// Untrusted input? Don't let a bad token throw:
if (protector.TryUnprotect(token, out string? value))
{
// use value
}
// Async variants exist for cloud-KMS providers where unwrap is a network call:
string t = await protector.ProtectAsync("secret");
string s = await protector.UnprotectAsync(t);Wrap any configuration source. Protected (pqc.v1.) values are decrypted on read; everything else
passes through untouched:
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.Sources.Clear();
builder.Configuration
.AddEncrypted(
new JsonConfigurationSource { Path = "appsettings.json", Optional = false, ReloadOnChange = true },
() => protector) // factory: resolved lazily, on first protected read
.AddEnvironmentVariables();Now appsettings.json can hold "ConnectionStrings:Default": "pqc.v1.…" and your handlers read it as
an ordinary string. Decryption is lazy and cached; a config reload clears the cache.
// PostQuantum.KeyManagement provides the key engine…
builder.Services.AddPostQuantumKeyManagement(o =>
{
o.Passphrase = builder.Configuration["KeyManagement:Passphrase"]!; // from a secret store / env
o.KeyringPath = "keyring.bin"; // durable, non-secret
});
// …and this library layers the protector on top.
builder.Services.AddPostQuantumConfiguration(); // registers IConfigurationProtectorResolve IConfigurationProtector anywhere, or use it as the factory for AddEncrypted.
Optionally bind a value to a logical slot so a token can't be lifted from one place and dropped into another (e.g. swapping the primary DB password onto the replica key):
string token = protector.Protect(secret, context: "ConnectionStrings:Primary");
protector.Unprotect(token, context: "ConnectionStrings:Primary"); // ok
protector.Unprotect(token, context: "ConnectionStrings:Replica"); // throws — context mismatchThe transparent layer can bind each value's configuration key as its context automatically:
builder.Configuration.AddEncrypted(jsonSource, () => protector, bindKeyAsContext: true);Rotation lives in PostQuantum.KeyManagement. Rotate the key-encryption key; old tokens still open
because previous KEKs stay available for unwrapping, and new values seal under the new active KEK:
keys.Rotate("a new passphrase", LocalKekOptions.Interactive);
protector.Unprotect(oldToken); // still worksAfter a rotation, old tokens still open but remain wrapped under the old key. Migrate them to the new
active key with the re-seal helpers (plaintext is handled through a zeroable Secret internally):
// One value:
string fresh = protector.Reprotect(oldToken);
// A whole map of configuration values, in place — plaintext entries are left untouched:
int resealed = await protector.ReprotectAllAsync(values); // values: IDictionary<string,string?>Unprotect returns a string, which the CLR cannot reliably zero. For paths that can work with bytes,
recover into a Secret that zeroes its buffer on dispose:
using Secret secret = protector.UnprotectToSecret(token);
Use(secret.Bytes); // ReadOnlySpan<byte>, valid until disposed
// string s = secret.Reveal(); // only if an API forces a string on youThis is a mitigation, not a guarantee — see KNOWN-GAPS.md — but it removes the
unavoidable lingering-string for byte-friendly code.
By default, content keys are wrapped by PostQuantum.KeyManagement (symmetric, Argon2id-derived KEK —
post-quantum by key size). For true post-quantum asymmetric key wrapping, use the hybrid provider:
each content key is wrapped to a recipient key pair using ML-KEM-768 (FIPS 203, the post-quantum
half) and ECDH P-256 (the classical half), combined via HKDF-SHA256 + AES-256-GCM. The wrap
stays secure unless both are broken.
using PostQuantum.Configuration.Hybrid;
// Recipient (the service that decrypts): generate once, persist the private key in a secret store/KMS.
using var recipient = HybridKemContentKeyProvider.Generate();
byte[] publicKey = recipient.ExportPublicKey(); // distribute to senders; safe to share
byte[] privateKey = recipient.ExportPrivateKey(); // SENSITIVE — store in a secret manager only
// Anyone with the public key can seal (wrap-only):
using var sealer = HybridKemContentKeyProvider.ImportPublicKey(publicKey);
string token = new PostQuantumConfigProtector(sealer).Protect("Host=db;Password=quantum-safe;");
// The recipient (private key) opens it:
using var opener = HybridKemContentKeyProvider.ImportPrivateKey(privateKey);
string secret = new PostQuantumConfigProtector(opener).Unprotect(token);HybridKemContentKeyProvider is an IContentKeyProvider, so it drops into everything above —
AddEncrypted, DI, Reprotect, Secret.
Requirements & honesty. Needs .NET 10+ and a platform where ML-KEM is available (on Linux, OpenSSL 3.5+); the factory methods throw
PlatformNotSupportedExceptionotherwise. The combiner follows the standard concatenate-into-HKDF, transcript-bound pattern, but this specific construction is not a named standard and has not been independently audited. SeeKNOWN-GAPS.mdanddocs/threat-model.md.
A companion dotnet tool (PostQuantum.Configuration.Tool)
protects, unprotects, and rotates from a shell or CI pipeline:
dotnet tool install --global PostQuantum.Configuration.Tool --prerelease
export PQC_PASSPHRASE='a strong passphrase' # keep secrets out of shell history
echo 'Host=db;Password=s3cr3t' | pqc-config protect --keyring keyring.txt # -> pqc.v1.…
pqc-config unprotect --keyring keyring.txt --token pqc.v1.…
pqc-config rotate --keyring keyring.txt # fresh key, old tokens still openA protected value is a self-contained envelope rendered as text:
pqc.v1.<base64url(body)>
pqc.v1.— a stable, greppable prefix.IConfigurationProtector.IsProtected(value)is just a prefix check; the transparent layer uses it to decide what to decrypt.- body — a compact, big-endian, length-prefixed binary blob:
[version][wrapped-content-key token][12-byte nonce][AES-256-GCM ciphertext][16-byte tag].
The wrapped content key is PostQuantum.KeyManagement's own portable WrappedContentKey token, so a
value carries everything needed to recover its data-encryption key from the provider — no shared
per-process state. (With the hybrid provider, that wrapped-key blob carries the ML-KEM ciphertext and
the ephemeral ECDH public key instead — the token envelope is unchanged.) The decoder uses
overflow-safe length arithmetic and caps every field at 1 MiB, so a hostile token cannot trigger a
giant allocation or an out-of-bounds read.
| Member | Purpose |
|---|---|
IConfigurationProtector |
Protect / Unprotect / TryUnprotect / UnprotectToSecret (+ async), and static IsProtected. |
PostQuantumConfigProtector |
Default implementation over IContentKeyProvider. |
Secret |
Zeroable, byte-backed recovered secret (disposes → zeroed). |
ConfigurationProtectionException |
Single, opaque failure type for malformed / tampered / wrong-context tokens. |
builder.AddEncrypted(source, …) |
Transparent decrypt-on-read wrapper for any IConfigurationSource. |
config.GetDecrypted(key, protector) |
Explicit decrypt-on-read for one value. |
protector.DecryptIfProtected(value) |
Decrypt if it's a token, pass through otherwise. |
protector.Reprotect / ReprotectAllAsync |
Re-seal values under the active key after rotation. |
services.AddPostQuantumConfiguration() |
DI registration over a registered IContentKeyProvider. |
HybridKemContentKeyProvider (net10+) |
Hybrid ML-KEM-768 + ECDH P-256 key-wrapping IContentKeyProvider. |
pqc-config (separate tool package) |
CLI to protect / unprotect / rotate. |
We aim to be honest about exactly what this library does and does not give you.
Scope of the "post-quantum" claim. With the default (symmetric) key provider, the post-quantum
property is symmetric-by-key-size — AES-256-GCM and Argon2id keep useful margin against a quantum
adversary because Grover's algorithm only halves their effective strength (AES-256 → ~128-bit), and no
asymmetric KEM is involved. With the optional hybrid provider (ML-KEM-768 + ECDH P-256), you also
get post-quantum asymmetric key wrapping — but that construction is non-standard and unaudited (see
its usage note). Choose your wording
to match the provider you deploy, and see KNOWN-GAPS.md for the precise scope.
What you get
- Authenticated encryption. AES-256-GCM — tampering with any byte is detected and fails closed, never silently decrypted to garbage. Every single-byte corruption of a token is rejected (there's a test that proves it).
- Fresh key + nonce per value. Each
Protectmints a new 256-bit content key and a new random nonce, so identical plaintexts produce different tokens and there is no deterministic-equality leak. - Native primitives, no hand-rolled crypto. AES-GCM is the .NET BCL; key custody, Argon2id, and
wrapping are
PostQuantum.KeyManagement. This library writes the envelope and the framing, not the cryptography. - Fail-closed, opaque failures. Malformed token, tampered ciphertext, wrong key, or wrong context
all surface as one
ConfigurationProtectionException(orTryUnprotect == false) — callers can't distinguish failure modes, and the message never leaks which one it was. - Hostile-input resistance. Token decoding uses overflow-safe length arithmetic and a 1 MiB field
cap;
TryUnprotectnever throws on bad input. - Optional context binding for swap resistance (above).
What you must know
- Recovered plaintext is a
string. .NET strings are immutable and can't be reliably zeroed, so a decrypted secret may linger on the managed heap until GC. Intermediate byte buffers are zeroed. This is an inherent limit of any string-returning API — seeKNOWN-GAPS.md. - Confidentiality rests entirely on the key provider. A weak passphrase, a leaked keyring + passphrase, or an unprotected KMS makes the ciphertext openable. Use a strong Argon2id work factor and treat the passphrase as a real secret.
- Not a vault. No access policies, no audit log, no per-secret authorisation. Pair with one for those properties.
- Preview. Treat the API and
pqc.v1token format as unstable until1.0.
Full detail: SECURITY.md, docs/threat-model.md,
KNOWN-GAPS.md.
| The library defends against… | …but not |
|---|---|
| Plaintext secrets in a repo, backup, or config file | An attacker who holds both the keyring / KMS access and the passphrase |
| Tampering with stored ciphertext (AES-GCM authentication) | A weak passphrase / low Argon2id work factor (offline guessing) |
| A value being moved to the wrong slot (with context binding) | Secrets read from process memory after decryption (mitigated, not solved, by Secret) |
| Hostile / oversized tokens (overflow-safe, capped decoding) | A quantum break of the classical half alone — the ML-KEM half still holds (hybrid provider) |
The full attacker model and security invariants are in docs/threat-model.md.
This package is built for verifiable provenance:
-
Deterministic, reproducible builds (
Deterministic,ContinuousIntegrationBuildin CI). -
Build-provenance attestation — the release workflow attests every
.nupkgwithactions/attest-build-provenance; verify withgh attestation verify. -
SourceLink + embedded untracked sources + a symbol package (
.snupkg) so stack traces resolve to exact source. -
SBOM (CycloneDX) generated for every release — regenerate and inspect locally:
./build/generate-sbom.sh # writes sbom/PostQuantum.Configuration.cdx.json -
Verify a downloaded package before trusting it:
# NuGet signature (author + repository countersignature) dotnet nuget verify PostQuantum.Configuration.0.1.0-preview.1.nupkg # Pin and restore with integrity checking dotnet restore --locked-mode # honours packages.lock.json hashes
What is not yet in place is stated plainly in docs/supply-chain.md: an
author code-signing certificate and an external security audit are still roadmap.
See samples/:
QuickStart— a self-contained console tour (protect, transparent read, tamper rejection, rotation).dotnet run --project samples/QuickStart.WebApi— a production-shaped ASP.NET Core minimal API with encrypted tokens committed inappsettings.json, DI wiring, and a token-minting endpoint.dotnet run --project samples/WebApi.
| Surface | Supported |
|---|---|
| Target frameworks | net8.0, net9.0, net10.0 |
| OS | Windows, Linux, macOS — anywhere .NET 8+ runs. AES-GCM is hardware-accelerated on modern CPUs. |
| AOT / trimming | IsAotCompatible=true. The public surface is string in, string out. |
| Hybrid provider | .NET 10+ and a host with ML-KEM (on Linux, OpenSSL 3.5+). Everything else has no such requirement. |
| Dependencies | PostQuantum.KeyManagement, Microsoft.Extensions.Configuration.Abstractions / .Primitives / .DependencyInjection.Abstractions. |
The core library needs no native post-quantum primitives, so it runs identically everywhere. Only the optional hybrid provider requires .NET 10 + ML-KEM; it degrades to a clear
PlatformNotSupportedExceptionwhere unavailable.
dotnet build # builds net8.0, net9.0, net10.0 — zero warnings (warnings are errors)
dotnet test # 69 tests; hybrid ML-KEM tests run where ML-KEM is available, skip cleanly otherwise
dotnet format --verify-no-changes
dotnet pack -c ReleaseThe hybrid ML-KEM tests skip themselves (with a clear reason) on hosts without ML-KEM. To run them, use .NET 10 with OpenSSL 3.5+ — for example, point the runtime at a newer OpenSSL:
LD_LIBRARY_PATH=/path/to/openssl-3.5/lib dotnet test # 69 tests, zero skips0.2.0-preview.1 — the 0.1 roadmap is done: the pqc-config CLI, the zeroable Secret return, the
Reprotect / ReprotectAllAsync re-seal helpers, and the hybrid ML-KEM-768 + ECDH P-256 provider
all ship and are tested. Core protect / unprotect, the transparent IConfiguration layer, DI, and
context binding are complete. The API and token format are not yet frozen.
Toward 1.0 (see KNOWN-GAPS.md for the honest gap list):
- External security review of the envelope and the hybrid combiner — the prerequisite for dropping
the "unaudited" caveat and for a stable
1.0. - Author code-signing certificate to complement the build-provenance attestations already produced.
- Standardised hybrid — track the IETF/NIST hybrid-KEM work and align the construction with a named scheme once one stabilises.
- Freeze the API and
pqc.v1token format and commit to SemVer wire-compatibility.
MIT © 2026 Paul Clark.
To God be the glory — 1 Corinthians 10:31.