How to swap in PostQuantum.DataProtection on a host that already protects its Data Protection
keys with something else. The goal is no user-visible disruption — existing cookies,
antiforgery tokens, and DP-protected payloads should keep working through the switch.
Status as of 0.1.0-preview.4.
ASP.NET Core Data Protection stores its keys with two pieces of metadata that matter here:
<encryptedSecret decryptorType="…">
<…encrypted payload…/>
</encryptedSecret>The decryptorType attribute names the type that will be used to unwrap the key at load time.
Data Protection looks at that attribute per key file, not per host. That means a single
key directory can contain keys protected by different encryptors at the same time, and each
key gets decrypted by the type its file names.
So the migration path is:
- Keep both decryptors registered for a transition window.
- Point the encryptor at PQ so fresh keys use the new wrap.
- Let old keys expire naturally under the old wrap.
- Remove the old decryptor once no in-use key file names it any more.
You never re-encrypt existing keys. They roll off as DP rotates.
Common in older intranet/Windows-host deployments. The old encryptor is
DpapiXmlEncryptor/DpapiNGXmlEncryptor; the decryptor type recorded on existing keys is
Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor (or DpapiNG).
// Before
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("keys"))
.ProtectKeysWithDpapiNG();// After — fresh keys get the PQ wrap. Old DPAPI-wrapped keys keep decrypting because
// DpapiNGXmlDecryptor is still on the host (it's a built-in type registered automatically;
// no code change required for the decryptor side).
builder.Services.AddPostQuantumKeyManagement(o => { o.Passphrase = builder.Configuration["..."]!; });
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("keys"))
.ProtectKeysWithPostQuantum(o =>
{
o.KeyStorePath = "keys/pq-keystore.txt";
o.Mode = HybridKemMode.Hybrid;
});Existing DPAPI-encrypted keys still unwrap because Data Protection's IActivator can construct
the DPAPI decryptor on its own — it's part of Microsoft.AspNetCore.DataProtection. New keys are
wrapped by PQ. Once the DPAPI-wrapped keys expire (90 days by default), the transition is
complete.
⚠️ DPAPI-encrypted keys are bound to the host's user/machine. If your migration also moves the host (e.g. to Linux), you cannot decrypt them. Roll keys over before migrating, or accept the forced sign-out.
// Before
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(...)
.ProtectKeysWithAzureKeyVault(new Uri("https://kv.vault.azure.net/keys/dp-key/..."), new DefaultAzureCredential());// After
builder.Services.AddPostQuantumKeyManagement(o => { o.Passphrase = builder.Configuration["..."]!; });
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(...)
.ProtectKeysWithPostQuantum(o =>
{
o.KeyStorePath = "keys/unused.txt"; // ignored when AKV store is registered
o.Mode = HybridKemMode.Hybrid;
});
builder.Services.AddPostQuantumDataProtectionAzureKeyVault(new Uri("https://kv.vault.azure.net/"));The Azure Key Vault decryptor type
(Microsoft.AspNetCore.DataProtection.AzureKeyVault.AzureKeyVaultXmlDecryptor) stays in the
service container as long as you keep referencing the
Azure.Extensions.AspNetCore.DataProtection.Keys package — you do not have to call
.ProtectKeysWithAzureKeyVault() to keep old keys decrypting. Drop the call; keep the package.
⚠️ The two systems use Azure Key Vault for different things. The old call uses an AKV cryptographic key to wrap DP keys. The newAddPostQuantumDataProtectionAzureKeyVaultuses AKV Secrets to persist PQ keypairs. You can have both at once; they don't conflict.
// Before
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("keys"))
.ProtectKeysWithCertificate(myCert);// After — fresh keys move to PQ. Old certificate-wrapped keys keep decrypting as long as the
// certificate is still loaded.
builder.Services.AddPostQuantumKeyManagement(o => { o.Passphrase = builder.Configuration["..."]!; });
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("keys"))
.UnprotectKeysWithAnyCertificate(myCert) // ← keep the cert available for decryption of legacy keys
.ProtectKeysWithPostQuantum(o =>
{
o.KeyStorePath = "keys/pq-keystore.txt";
o.Mode = HybridKemMode.Hybrid;
});UnprotectKeysWithAnyCertificate registers the certificate as available for decryption without
making it the active encryptor. Old keys roll off naturally.
After deploying the migrated host:
-
Check the on-disk shape.
ls keys/data-protection/ pq-dp inspect keys/data-protection/key-*.xml | grep "Public key id"
Some files will have
<pqEnvelope>elements; others will retain their old wrappers. That is correct. -
Hit a real DP-protected endpoint twice. First call mints a fresh key (PQ-wrapped); second call rolls forward. Both should succeed.
-
Watch the metrics.
pq_dataprotection.encryptionsshould tick up on fresh-key creation;pq_dataprotection.decryptionsshould remain quiet (the in-memory key ring caches the unwrapped keys).
After all keys created before the migration have expired (default DP lifetime is 90 days), you can drop the legacy decryptor:
- DPAPI: nothing to drop. The decryptor lives inside
Microsoft.AspNetCore.DataProtectionand has no cost when not invoked. - Azure Key Vault key (cryptographic key, not secret): you can stop loading
Azure.Extensions.AspNetCore.DataProtection.Keysif you want. - Certificate: drop
UnprotectKeysWithAnyCertificate(...).
- Data Protection keys wrapped under a key that has been deleted or revoked.
- Keys whose encryptor's decryptor type is no longer registered.
- Keys whose wrapping key is on a different host (DPAPI, machine-bound HSM) than the new host.
In those cases, the user-visible effect is a forced sign-out / token refresh. The PQ encryptor cannot recover keys whose wrapping was never PQ-encrypted in the first place; it can only ensure that fresh keys are PQ-wrapped going forward.
To God be the glory — 1 Corinthians 10:31.