Table of Contents

Decrypting secrets

Connection strings, client secrets and passwords do not belong in a repository in clear text, and .NET user secrets stay on one developer machine. The Arc4u.Configuration.Decryptor package lets you commit the values encrypted and decrypts them when the application builds its configuration. A new developer clones the repository and starts the application; only the certificate must be available on the machine that runs it. See Configuration for the other parts of Arc4u configuration, and the concepts for how Arc4u fits together.

How it works

A value is encrypted when it starts with a prefix, Decrypt: by default. The decryptor is an IConfigurationProvider that you add after the providers holding the encrypted values. When the configuration is built, it reads every provider registered before it, decrypts each value that starts with the prefix and publishes the clear text under the same key. Because .NET lets the last provider win, the application sees the decrypted value.

flowchart LR
    A["appsettings.json<br/>Decrypt:Zm9v..."] --> D
    B["Environment variables"] --> D
    C["EncryptionCertificate section"] --> D
    D["Certificate decryptor"] --> F["IConfiguration<br/>clear text"]

The decryptor finds its own settings (which certificate to use) in the same providers, in a section named EncryptionCertificate. If that section does not exist, the decryptor does nothing and the values keep their Decrypt: prefix.

Set it up

The steps below use a PEM file pair and the built-in File option. Section Configuration shows the Windows certificate store and the other variants.

Step 1: Create a certificate

Create a self-signed RSA certificate and its private key. Both files must be unencrypted PEM: the key must not be protected by a passphrase.

openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout key.pem \
  -out cert.pem \
  -days 365 \
  -subj "/CN=Arc4u Config Secrets"

The certificate can also have an EC key (P-256, P-384 or P-521), for example an ECDSA certificate issued by your PKI. To create one:

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout key.pem \
  -out cert.pem \
  -days 365 \
  -subj "/CN=Arc4u Config Secrets"

Other key types are not supported (see Troubleshooting). See RSA or EC certificate for how each one encrypts. To check that a certificate and a key belong together, both commands print the same public key:

openssl x509 -in cert.pem -noout -pubkey
openssl pkey -in key.pem -pubout

cert.pem (-----BEGIN CERTIFICATE-----) is public: you can share it with everybody who must encrypt values. key.pem (-----BEGIN PRIVATE KEY-----) is the secret: it goes only to the machines that run the application. See Security notes.

Step 2: Encrypt a value

Arc4u has no command-line tool for encryption. The encryption method is Encrypt, an extension method on X509Certificate2 in the Arc4u package. It needs only the public certificate. A small console project that references Arc4u does the job:

dotnet new console -o EncryptSecret
cd EncryptSecret
dotnet add package Arc4u --prerelease
// Program.cs
using System.Security.Cryptography.X509Certificates;
using Arc4u.Security.Cryptography;
using CertificateLoader = System.Security.Cryptography.X509Certificates.X509CertificateLoader;

// dotnet run -- cert.pem "value to protect"
using var certificate = CertificateLoader.LoadCertificateFromFile(args[0]);
Console.WriteLine($"Decrypt:{certificate.Encrypt(args[1])}");

The alias avoids a name clash between the .NET X509CertificateLoader and the Arc4u class with the same name. Run it for each secret and copy the output, prefix included:

dotnet run -- ../cert.pem "Server=db;Database=Orders;Password=s3cret"
Decrypt:C6PBLlEuyKB3aTnC77sRYvEmgXvoKqHsKlR7kjoYVppiQXfDTWj5r0Wm...

The output is Base64 text and changes on every run (the encryption is randomized), so a different string for the same value is normal. The form of the output depends on the certificate key.

RSA or EC certificate

With an RSA certificate, a value shorter than 191 bytes is trimmed of leading and trailing whitespace and encrypted directly with the certificate's RSA public key (RSA-OAEP with SHA-256, for a 2048-bit key). A longer value is encrypted with AES-GCM and a random AES key, and the output has the form <key>.<data> where the AES key is itself encrypted with the certificate. Both forms decrypt the same way.

With an EC certificate, every value is encrypted with ECIES:

  1. A new EC key pair, on the curve of the certificate, is generated for the value.
  2. An ECDH key agreement between this ephemeral private key and the certificate's public key gives a shared secret, which HKDF-SHA256 turns into a 256-bit AES key. Both public keys are part of the key derivation, so the AES key is bound to them.
  3. The value is encrypted with AES-GCM, and the output has the form <ephemeralPublicKey>.<data>. The ephemeral private key is discarded.

To decrypt, the certificate's private key and the ephemeral public key give the same shared secret. The value is not trimmed.

Decrypt chooses the algorithm from the certificate key, not from the encrypted text: a value must be decrypted with the certificate it was encrypted for, so changing from an RSA to an EC certificate means encrypting the values again (see Rotate the certificate).

Note

Strictly, a key agreement needs the keyAgreement key usage, and many ECDSA certificates only have digitalSignature. .NET does not check the key usage, but your PKI policy may. On Windows, a private key stored with a signature-only CNG algorithm (ECDSA_P256, for example) can refuse the key agreement; a key loaded from a PEM or PFX file is not affected.

Step 3: Put the encrypted value in the configuration

{
  "ConnectionStrings": {
    "Orders": "Decrypt:C6PBLlEuyKB3aTnC77sRYvEmgXvoKqHsKlR7kjoYVppiQXfDTWj5r0Wm..."
  },
  "EncryptionCertificate": {
    "File": {
      "Cert": "certs/cert.pem",
      "Key": "certs/key.pem"
    }
  }
}

The paths are relative to the working directory of the process, or absolute. The example above keeps the encrypted value and the certificate location in the same file; you can put the EncryptionCertificate section in any provider, for example in an environment-specific file or in environment variables (EncryptionCertificate__File__Cert).

Step 4: Register the decryptor

dotnet add package Arc4u.Configuration.Decryptor --prerelease
// Program.cs
using Arc4u.Configuration.Decryptor;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddCertificateDecryptorConfiguration();

var app = builder.Build();
// app.Configuration["ConnectionStrings:Orders"] is now "Server=db;Database=Orders;Password=s3cret"
app.Run();

AddCertificateDecryptorConfiguration() must come after the sources that contain the encrypted values and the EncryptionCertificate section. WebApplication.CreateBuilder already added appsettings.json, appsettings.{Environment}.json, user secrets, environment variables and the command line, so calling it right after works for all of them. Sources you add after the decryptor are not decrypted, and they override the decrypted value if they use the same key.

Configuration

EncryptionCertificate section

AddCertificateDecryptorConfiguration() reads the EncryptionCertificate section and binds it to CertificateStoreOrFileInfo. The certificate is under exactly one of two child sections, Store or File. If both are present, Store is used.

Key Type Default Description
EncryptionCertificate:Store:Name string none (required) The value to search for in the certificate store, interpreted according to FindType.
EncryptionCertificate:Store:FindType X509FindType FindBySubjectName How Name is matched: FindBySubjectName, FindByThumbprint, FindBySerialNumber, and so on.
EncryptionCertificate:Store:Location StoreLocation LocalMachine LocalMachine or CurrentUser.
EncryptionCertificate:Store:StoreName StoreName My The store folder. My is the Personal store.
EncryptionCertificate:File:Cert string none (required) Path of the PEM file with the certificate.
EncryptionCertificate:File:Key string none (required) Path of the PEM file with the unencrypted private key.

Enumeration values are written by name, as in the example below. The store variant looks like this:

{
  "EncryptionCertificate": {
    "Store": {
      "Name": "<certificate-thumbprint>",
      "FindType": "FindByThumbprint",
      "Location": "CurrentUser"
    }
  }
}

With the store variant, the first certificate that matches is used, and it must have a private key that the process may read. The File variant loads the certificate and key with X509Certificate2.CreateFromPemFile. It works on every operating system and does not need a certificate store, which suits containers.

Note

Arc4u 6.0.11.2 up to 6.0.14 accepted a flat EncryptionCertificate:Name. Arc4u 6.0.14.1 moved it under CertificateStore (next to File). The child was renamed Store in May 2023, in the later 6.x line (6.1.18.1), and Arc4u 8 and 9 keep that name. Only Store and File are read now. See Troubleshooting.

Options in code

AddCertificateDecryptorConfiguration has an overload that takes an Action<SecretCertificateOptions>.

Property Type Default Description
Prefix string Decrypt: Values starting with this text are decrypted.
SecretSectionName string EncryptionCertificate Path of the section that holds Store or File.
Certificate X509Certificate2? null A certificate you already loaded. When set, the section is not read. It needs a private key.
CertificateLoader IX509CertificateLoader? Arc4u X509CertificateLoader The service that turns the section into a certificate. Not used when Certificate is set.

Common scenarios

Use a certificate in the Windows certificate store

Install the certificate with its private key in the machine store, give the account that runs the application read access to the key, and reference it by thumbprint or subject name in the Store section as shown above. Because the default Location is LocalMachine and StoreName is My, the minimal form is:

{
  "EncryptionCertificate": {
    "Store": {
      "Name": "Arc4u Config Secrets"
    }
  }
}

That value is matched with FindBySubjectName. It is a partial, case-insensitive match on the subject name, so use the thumbprint when several certificates share a name.

Use one certificate per environment

Keep the encrypted values in appsettings.json and the certificate reference in appsettings.{Environment}.json (or in environment variables). Encrypt the values for each environment with that environment's certificate, and put them in the environment-specific file.

Run in Kubernetes

Mount the certificate and its key from a Kubernetes secret as files, and point the File section at them, using environment variables so that the image is the same in every environment:

env:
  - name: EncryptionCertificate__File__Cert
    value: /certs/tls.crt
  - name: EncryptionCertificate__File__Key
    value: /certs/tls.key

tls.crt and tls.key are the file names of a kubernetes.io/tls secret when it is mounted as a volume. Both must be PEM, and the key must be unencrypted (a PKCS#8 PRIVATE KEY, an RSA PRIVATE KEY or an EC PRIVATE KEY block). To convert a key, use openssl pkcs8 -topk8 -nocrypt -in old.key -out key.pem.

Load the certificate yourself

Set Certificate when the certificate comes from somewhere the built-in options do not cover, such as a secret manager, a PFX in a secret, or a certificate generated in a test:

// Program.cs
using System.Security.Cryptography.X509Certificates;
using Arc4u.Configuration.Decryptor;

var builder = WebApplication.CreateBuilder(args);

var certificate = X509CertificateLoader.LoadPkcs12FromFile("secrets.pfx", "<pfx-password>");
builder.Configuration.AddCertificateDecryptorConfiguration(options => options.Certificate = certificate);

var app = builder.Build();
app.Run();

Arc4u does not read the public and private key from configuration values: the certificate is found through the EncryptionCertificate section, or you provide it as shown above.

Change the prefix or the section

// Program.cs
using Arc4u.Configuration.Decryptor;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddCertificateDecryptorConfiguration(options =>
{
    options.Prefix = "Secret:";
    options.SecretSectionName = "Security:Certificate";
});

var app = builder.Build();
app.Run();

Values that start with Secret: are now decrypted, and the certificate is read from Security:Certificate:File:Cert or Security:Certificate:Store:Name, and so on.

Encrypt with an AES key instead of a certificate

AddRijndaelDecryptorConfiguration() decrypts with an AES key that you provide to the configuration. It uses the same prefix mechanism, so values start with Decrypt:. The key is a Base64 string in the EncryptionRijndael section (CypherCodecConfig):

{
  "EncryptionRijndael": {
    "Key": "<base64-key>"
  }
}
// Program.cs
using Arc4u.Configuration.Decryptor;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddRijndaelDecryptorConfiguration();

var app = builder.Build();
app.Run();

SecretRijndaelOptions has the same Prefix and SecretSectionName properties (default EncryptionRijndael), and a RijnDael property to pass the key in code. To generate a key and encrypt a value, use CypherCodec:

using Arc4u.Security.Cryptography;

var key = CypherCodec.GenerateKey();   // a 256 bits key, Base64
var cypher = CypherCodec.Encrypt("s3cret", Convert.FromBase64String(key));
Console.WriteLine($"Decrypt:{cypher}");

CypherCodec.Encrypt uses AES-GCM: each value gets its own random nonce, so one key can protect many values, and a modified value or a wrong key fails with a CryptographicException instead of returning garbage.

Caution

Do not put the AES key in appsettings.json next to the values it protects: that only hides values from someone who reads one file but not the rest of the configuration. Give the key from a source kept apart, such as an environment variable (EncryptionRijndael__Key), a Kubernetes secret or a key vault, added before AddRijndaelDecryptorConfiguration(). Better, prefer the certificate decryptor, where the private key lives in the certificate store or a protected file.

Rotate the certificate

The decryptor uses one certificate at a time and cannot try an old one as a fallback. To rotate:

  1. Create the new certificate.
  2. Encrypt every secret again with the new public certificate (Step 2).
  3. Deploy the new encrypted values together with the new private key and a matching EncryptionCertificate section.
  4. Delete the old private key once no deployment uses it.

Extensibility points

Service Default implementation Replace it to
IX509CertificateLoader X509CertificateLoader Find the certificate somewhere else. Set it as SecretCertificateOptions.CertificateLoader.

The configuration providers are built before dependency injection exists, so the loader is not resolved from the service collection: pass an instance in the options.

// Program.cs
using System.Security.Cryptography.X509Certificates;
using Arc4u.Configuration.Decryptor;
using Arc4u.Security;
using Arc4u.Security.Cryptography;
using Microsoft.Extensions.Configuration;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddCertificateDecryptorConfiguration(options => options.CertificateLoader = new MyCertificateLoader());

var app = builder.Build();
app.Run();

internal sealed class MyCertificateLoader : IX509CertificateLoader
{
    public X509Certificate2 FindCertificate(CertificateInfo certificateInfo) => throw new NotSupportedException();

    public X509Certificate2 FindCertificate(CertificateFilePathInfo? certificateInfo) => throw new NotSupportedException();

    public X509Certificate2? FindCertificate(IConfiguration configuration, string sectionName)
    {
        // Read what you need from the section and return the certificate, or null to skip decryption.
        return null;
    }
}

The decryptor calls only the FindCertificate(IConfiguration, string) overload, with the temporary configuration built from the earlier providers and SecretSectionName. Returning null skips decryption.

Security notes

Caution

Never commit key.pem, a PFX or any private key. Commit only the encrypted values and, if you want, the public cert.pem.

  • Who can do what. Anyone with the public certificate can produce an encrypted value. Only the holder of the private key can read it. Keep the private key out of the repository, out of the container image and out of build logs.
  • Where the private key lives. In production, use the certificate store with an access control list on the private key, or a Kubernetes secret mounted as a read-only file. A PEM private key on a file system is readable by every process that can read the file.
  • Clear text in memory. Once decrypted, a value is an ordinary configuration string. It shows up in a configuration dump, in a debugger and in a diagnostic endpoint that prints IConfiguration. Do not log the configuration.
  • One key per environment. Use a different certificate per environment so that a leak in a test environment does not expose production secrets. A secret is only as private as the private key that decrypts it.
  • Start-up fails on purpose. The decryptor does not catch exceptions: a missing certificate, a certificate with neither an RSA nor an EC key or a value that cannot be decrypted stops the application at startup, so the error is visible.
  • Not a secret manager. The encrypted values are public data. Rotating or revoking a secret means re-encrypting and redeploying. For secrets that change often, use a dedicated secret store and a configuration provider for it.

Troubleshooting

The value still starts with Decrypt:

Check, in this order:

  1. AddCertificateDecryptorConfiguration() is called after the provider that holds the value.
  2. There is an EncryptionCertificate section (or the section you set as SecretSectionName). Without it the decryptor skips every value.
  3. The prefix matches, including the colon and the case. A custom Prefix replaces Decrypt:, it does not add to it.
  4. A provider added after the decryptor does not overwrite the key with the encrypted text.

No certificate information found in the configuration

The application stops with an InvalidOperationException. The section exists but has neither a Store nor a File child. This happens with the flat layout of Arc4u 6.0.11.2 up to 6.0.14 (EncryptionCertificate:Name) and with the CertificateStore child of 6.0.14.1 and later 6.0.x (EncryptionCertificate:CertificateStore:Name). Rename it to Store, or move the settings under Store or File.

Certificate name cannot be null

This InvalidOperationException means the Store section has no Name.

No certificate found for the given criteria

This KeyNotFoundException means no certificate in the configured store matches. Check Name, FindType, Location (the default is LocalMachine, not CurrentUser) and StoreName. On Linux and macOS, use the File variant.

Public key file doesn't exist

The FileNotFoundException message is "Public key file doesn't exist." or "Private key file doesn't exist.". The Cert or Key path is wrong. Relative paths are resolved from the working directory of the process, which is not always the folder of the application.

CryptographicException at startup

The value was encrypted with another certificate, or the certificate has no private key (The certificate ... has no private key!). The exact type and message depend on the operating system. Encrypt the value again with the public certificate that matches key.pem.

NotSupportedException: only certificates with an RSA or an EC key

Encryption and decryption use the RSA or the EC key of the certificate. Encrypt and Decrypt throw a NotSupportedException naming the certificate and its key algorithm when the certificate has another key type, such as DSA or Ed25519. Use a certificate with an RSA or an EC key (openssl x509 -in cert.pem -noout -text shows Public Key Algorithm: rsaEncryption or id-ecPublicKey) and encrypt the values again.

Which earlier sources does the decryptor read?

The decryptor reads the values of the providers registered before it, as they were already loaded: an earlier source is not built or loaded a second time. A stream source (AddJsonStream) or a remote provider such as Azure Key Vault can therefore be placed before the decryptor.

This needs the decryptor to be added with AddCertificateDecryptorConfiguration or AddRijndaelDecryptorConfiguration. On a plain ConfigurationBuilder (for example in ConfigureAppConfiguration), these methods wrap the earlier sources so the decryptor can reach the providers built from them. On a ConfigurationManager (WebApplication.CreateBuilder, Host.CreateApplicationBuilder), nothing is wrapped. A decryptor source added directly with Add(...) still builds the earlier sources again.

The input is not a valid Base-64 string

This FormatException means the text after the prefix is not the output of Encrypt. Copy the whole string, without line breaks or quotes.

See also