Almost a year ago I wrote an article on this same topic: Using Azure Key Vault and Azure Storage to store Data Protection keys with ASP.NET Core. Since Microsoft is publishing new packages for Azure integrations, I thought I'd revisit this topic.
The new libraries use the newest Azure SDKs, which enables some really nice capabilities. To me the main one is identity. The new Azure SDKs use a unified authentication model, which makes using them a lot easier.
To explore these new SDKs, I made a sample app that you can find on GitHub.
Motivation for using Azure Storage and Azure Key Vault
The purpose here is to configure the data protection system in such a way that its keys are stored outside the app server, but also to do so in a secure manner. By default data protection keys may be stored in a local folder (see default settings), and the keys may or may not be encrypted at rest. Issues can arise in certain situations like when running in Azure App Service and using its deployment slots feature. When swapping the slots for deploying a new version, the data protection keys get swapped with the old version. This then causes things which are dependent on those keys to no longer be valid, like authentication cookies and password reset tokens.
It's quite an unwanted situation to log out all users when deploying a new version. Or disable all password reset tokens sent to users' emails before the slot swap. But we also don't want to store the keys in plaintext in some file. So this is where the combo of Key Vault and Blob Storage comes in. The app generates a data protection key when it is needed. This key is then encrypted with another key in Key Vault. The result is then stored in Blob Storage.
So a user would need access to the Unwrap Key operation + read access to the blob container in order to decrypt the keys. It is definitely a good idea to enable audit logs on a production Key Vault, and limit the number of users with access to cryptographic operations/access management on it.
And always keep in mind the following note in the docs:
If the developer overrides the rules outlined above and points the Data Protection system at a specific key repository, automatic encryption of keys at rest is disabled. At-rest protection can be re-enabled via configuration.
You should always configure an encryption provider as well as a persistence provider. In this article we will use Key Vault for encryption and Storage for persistence.
Azure Identity package and local setup
The sample app uses the Azure.Identity package to authenticate with Azure Storage and Key Vault. The nifty part of this library is the DefaultAzureCredential class, that enables usage in local development environments as well as in Azure. It essentially attempts multiple ways of authentication until one works.
In local development, we can utilize a shared token cache used by multiple Microsoft apps like Visual Studio. When running in Azure, we can use a Managed Identity. As long as you have logged into e.g. Visual Studio with the correct user account, it should be possible to use it to authenticate to Key Vault and Storage.
For detailed setup instructions, refer to the README of the sample app.
Persisting keys to Blob Storage
Let's configure ASP.NET Core to store the data protection keys in Azure Storage. First, we need to add the new package in the project file:
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.0.0" />
Then we can configure data protection to use Azure Storage:
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
var options = _configuration.GetSection("App").Get<AppOptions>();
services.AddDataProtection()
.PersistKeysToAzureBlobStorage(GetBlobClient(options));
}
private BlobClient GetBlobClient(AppOptions options)
{
var client = new BlobServiceClient(
new Uri(options.StorageAccountBlobBaseUrl),
GetTokenCredential(options));
var containerName = options.StorageContainerName;
BlobContainerClient containerClient = client.GetBlobContainerClient(containerName);
var blobName = options.StorageBlobName;
BlobClient blobClient = containerClient.GetBlobClient(blobName);
return blobClient;
}
private TokenCredential GetTokenCredential(AppOptions options)
{
var credentialOptions = new DefaultAzureCredentialOptions();
if (options.SharedTokenCacheTenantId != null)
{
credentialOptions.SharedTokenCacheTenantId = options.SharedTokenCacheTenantId;
}
return new DefaultAzureCredential(credentialOptions);
}
So the main part is this:
services.AddDataProtection()
.PersistKeysToAzureBlobStorage(GetBlobClient(options));
There are various overloads for PersistKeysToAzureBlobStorage
.
We are using the one that takes a BlobClient
.
I prefer using that since it gaves the greatest flexibility,
allowing usage of the Storage Emulator too if wanted.
We could also use the overload that takes the URI for the blob and a TokenCredential
.
The GetBlobClient
function is responsible for creating a client
that can read and write a specific blob in Azure Storage.
We also define a GetTokenCredential
function that
we can reuse for the Key Vault integration.
This function creates a DefaultAzureCredential
with an optional
shared token cache tenant id.
Some configuration settings are used here. They tell the target blob location and the Azure AD tenant id to use locally. The latter can be important for local development, in case you have tokens for multiple tenants in your token cache.
This is all that is needed to persist the keys in Blob Storage. However, now that we have configured a custom location, automatic key encryption is disabled. So let's take care of that.
Protecting keys with Key Vault
In order to encrypt the keys stored in Azure Storage, we will need another package:
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.0.0" />
Then we can enable key protection with Azure Key Vault (Azure Storage-related code removed for brevity):
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
var options = _configuration.GetSection("App").Get<AppOptions>();
var keyIdentifier = options.KeyVaultKeyId;
services.AddDataProtection()
.ProtectKeysWithAzureKeyVault(
new Uri(keyIdentifier),
GetTokenCredential(options));
}
private TokenCredential GetTokenCredential(AppOptions options)
{
var credentialOptions = new DefaultAzureCredentialOptions();
if (options.SharedTokenCacheTenantId != null)
{
credentialOptions.SharedTokenCacheTenantId = options.SharedTokenCacheTenantId;
}
return new DefaultAzureCredential(credentialOptions);
}
Well that's pretty easy.
We only need to tell it the identifier of the key to use,
and then use the same GetTokenCredential
function from before to
allow it to authenticate to Key Vault.
Now that we have configured Key Vault protection, the keys in Storage will be encrypted.
You can see the complete configuration in the sample app.
Testing data protection key persistence
The sample app sets up a cookie with a protected value when you hit the index route. If this cookie is found, it unprotects its contents and shows them to you. We can deploy the app in Azure App Service, deploy a new version to a deployment slot, and swap it to production to see that the app can indeed still unprotect the cookie value.
If we had not configured the keys to be persisted to Azure Storage, the cookie would have been impossible to decrypt as the keys would have swapped.
If you run the app locally on Windows,
you can also see that no new keys appear in %LOCALAPPDATA%\ASP.NET\DataProtection-Keys
.
Conclusions
Setting up proper persistence of data protection keys in Azure is easier than ever with the new SDKs. The fact that they share authentication infrastructure makes it better. All of this allows us to use Azure services to their maximum.
Do make sure you use a separate Key Vault and Storage account for production environments. Limit the number of users with management access to those resources and enable audit logging on Key Vault.
The sample app is intended to be configured identically for local development,
but that can be quite overkill.
It usually is not necessary to do this for local environments.
The default behaviour of storing the keys under %LOCALAPPDATA%
with DPAPI protection is quite sufficient for most people, I think.
Until next time! :)