Create PGP keys on app startup
I've recently been working on an app where we use PGP encryption.
In a nutshell: we hand out a public key, consumers of the app encrypt their payload with it, and we decrypt on the way in with the matching private key.
Fine, once you've got a keypair. Getting one in the first place, in every environment, without a developer generating it on their laptop and pasting it into a portal, is the awkward part. That's a manual step, it's error prone, and it ends up documented in a Confluence page that goes stale within a month.
So the app generates its own keypair on startup, if it doesn't already have one, and stores it in Azure Key Vault.
The implementation
An IHostedService runs at startup, checks whether the three secrets exist, and creates them if they don't.
public class PGPKeysInitializer(SecretClient keyVaultSecretClient, ILogger logger) : IHostedService
{
public const string PublicKeySecretName = "PGP-PublicKey";
public const string PrivateKeySecretName = "PGP-PrivateKey";
public const string PasswordSecretName = "PGP-Password";
public async Task StartAsync(CancellationToken cancellationToken)
{
try
{
if (await SecretsExists(cancellationToken) == false)
{
var keys = await GenerateKeys();
await Task.WhenAll(
keyVaultSecretClient.SetSecretAsync(new KeyVaultSecret(PublicKeySecretName, keys.PublicKey), cancellationToken),
keyVaultSecretClient.SetSecretAsync(new KeyVaultSecret(PrivateKeySecretName, keys.PrivateKey), cancellationToken),
keyVaultSecretClient.SetSecretAsync(new KeyVaultSecret(PasswordSecretName, keys.Password), cancellationToken)
);
}
}
catch (Exception ex)
{
logger.Error(ex, "Error with PGPKeysInitializer - {Error} - {StackTrace}", ex.Message, ex.StackTrace);
}
}
private async Task<bool> SecretsExists(CancellationToken cancellationToken)
{
string[] secretNames =
[
PasswordSecretName,
PublicKeySecretName,
PrivateKeySecretName
];
var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
var tasks = secretNames
.Select(secretName => keyVaultSecretClient
.GetSecretAsync(secretName, cancellationToken: cancellationToken)
);
await Task.WhenAll(tasks);
return true;
}
catch (Azure.RequestFailedException ex) when (ex.Message.Contains("was not found in this key vault"))
{
await cancellationTokenSource.CancelAsync();
return false;
}
}
public Task StopAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
private async Task<(string Password, string PublicKey, string PrivateKey)> GenerateKeys()
{
var password = $"{Guid.NewGuid()}-{Guid.NewGuid()}";
using PgpCore.PGP pgp = new();
var publicKeyStream = new MemoryStream();
var privateKeyStream = new MemoryStream();
await pgp.GenerateKeyAsync(
publicKeyStream,
privateKeyStream,
password: password,
strength: 4096
);
return (password, GetValue(publicKeyStream), GetValue(privateKeyStream));
}
private static string GetValue(Stream stream)
{
stream.Position = 0;
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
Register it in the usual way:
builder.Services.AddHostedService<PGPKeysInitializer>();
The password is a pair of GUIDs concatenated. Nobody types it, nobody sees it, and it goes straight into Key Vault alongside the private key, so there's no benefit in it being memorable. It needs to be long and random.
PgpCore does the actual work. Key strength is 4096, which takes a couple of seconds to generate on a decent machine, and happens once.
Things worth knowing before you copy this
The catch is deliberate. Swallowing the exception means a Key Vault permissions problem at startup produces a log line, and the app carries on. That was the right call for us, because the app has other jobs to do and refusing to boot would have caused more damage than the missing keys did. If PGP is the only thing your app does, let it throw. A hosted service that throws in StartAsync stops the host from starting, which is the failure you want.
There's a race if you scale out. Two instances starting at the same time will both see no secrets and both generate a keypair. Last write wins, and any consumer who grabbed the public key in between is now holding one you can't decrypt with. Options, in order of effort:
- Run the initialiser as a one-shot job in your deployment pipeline rather than in the app.
- Take a lease on a blob before generating, and release it after.
- Accept it, on the basis that a cold start against an empty vault is a deploy-time event and you're not deploying to an empty vault under load.
We took the third option for a new environment, then moved to the first once it mattered.
The check asks "do all three exist", not "are all three valid". Delete the private key on its own and SecretsExists returns false, so the app regenerates the lot and invalidates the public key everyone's already using. Worth a guard if that's a plausible failure mode for you.
Rotation is a separate problem. This only ever creates keys. To rotate, consumers need to hold two public keys during the changeover, and you need to keep the old private key around long enough to decrypt anything still in flight. Key Vault's versioning helps, but it's a different design.
Why bother
A new environment becomes self-sufficient. Spin up the infrastructure, point the app at an empty Key Vault, start it, and it has a working keypair before the first request arrives. No developer generates keys on a laptop, no private key travels over email, and the environment can decrypt from the moment it exists.