Apricot Framework

ASP.NET Core

Registration, configuration binding, startup validation, key deletion and replacing the store.

ApricotFramework.DataProtection.AspNetCore connects the store to the key manager. It takes everything from the ASP.NET Core shared framework, so it has zero NuGet dependencies.

using ApricotFramework.DataProtection.AspNetCore.Extensions;

builder.Services.AddDataProtectionCore(builder.Configuration)
    .PersistKeysToRelationalStore(_ => new MySqlConnection(connectionString));

Two calls rather than one, on purpose. The first starts data protection, applies the discriminator and binds the store settings; the second chooses storage and supplies the connection. Keeping them apart is what leaves the rest of the builder — key protection, lifetime, algorithms, escrow — available and composable in any order.

The connection factory is handed the service provider, so the connection string need not be known at registration:

    .PersistKeysToRelationalStore(sp =>
        new SqliteConnection(sp.GetRequiredService<IConfiguration>().GetConnectionString("Keys")))

Warning

The key ring is read outside any request, so the factory runs on the root provider. Resolving a scoped service there throws. Capture what you need from configuration, or resolve a singleton.

Core is used in the sense AddMvcCore uses it: the foundation you compose onto. It also leaves AddDataProtection(IConfiguration) free, so a host can put the whole decision in one place and keep Program.cs to a single line:

// Composition root, somewhere in the app.
public static IServiceCollection AddDataProtection(this IServiceCollection services, IConfiguration configuration)
{
    services.AddDataProtectionCore(configuration)
        .PersistKeysToDataOpsStore()
        .ProtectKeysWithCertificate(configuration["Keys:Thumbprint"]!);

    return services;
}

AddDataProtectionCore(configuration, "Security:Keys") moves every setting to a custom root at once, since all of them are bound from it. A host that starts data protection some other way names the root on the storage call instead: PersistKeysToRelationalStore(configuration, root, factory).

The two need not share a root. The storage call takes its own, so the discriminator can come from one section and the store's settings from another:

builder.Services.AddDataProtectionCore(builder.Configuration, "App")
    .PersistKeysToRelationalStore(builder.Configuration, "Infra", factory);

Note

Both roots bind the same settings object, and binding merges rather than replaces — so a key the storage root omits keeps whatever the first root supplied. Keep Storage out of the discriminator's root and the two are genuinely independent. Populating it under both is the only way to get a surprise.

Warning

Choosing storage turns the framework's default at-rest key encryption off. A store registered without an encryptor fails at startup for that reason. See Key protection.

Configuration

{
  "DataProtection": {
    "Application": "/app",
    "Storage": {
      "Relational": {
        "Dialect": "MySql",
        "TableName": "DataProtectionKeys",
        "SchemaName": null,
        "CommandTimeout": "00:00:30"
      }
    }
  }
}
PathEffect
Applicationthe application discriminator; omit for the framework's own default
Storage:Relational:Dialectwhich engine's SQL to use; required by the relational store, unused by a store that learns the engine from its connection
Storage:Relational:TableNamedefaults to DataProtectionKeys
Storage:Relational:SchemaNameomit for the connection's default schema
Storage:Relational:CommandTimeoutomit for the driver's default

The root holds settings that belong to no particular backend; Storage groups the backends, one subsection each, and a future Protection groups the key encryption mechanisms the same way. Which backend is used is decided by the PersistKeysTo call, never by which subsections exist — a backend needs a connection or a credential that configuration cannot carry, so the call has to be there regardless.

Application is a separate option type from the store settings because it configures the framework and means nothing to a store: a Redis store still needs a discriminator while having no table at all. Both are bound from the one root, so a custom root name moves all of it together.

Warning

Application scopes every payload. Two applications sharing one table must set different values or each can unprotect the other's data, and changing it makes existing payloads unreadable — so it is effectively frozen once anything has been protected.

Both also take an Action<T> overload, for a host that configures in code and binds nothing.

Startup validation

A missing or unknown dialect, an unusable table name or a negative timeout fails at host start rather than the first time a cookie is issued. The error names the registered dialects.

Key deletion

The repository implements IDeletableXmlRepository, so IDeletableKeyManager.DeleteKeys works — the official Entity Framework Core provider implements only IXmlRepository and cannot. Deletion runs in the order the caller asks for and stops at the first failure.

Warning

Deleting a key whose payloads are still in circulation makes them permanently unreadable. Revoking is almost always what you want instead.

Replacing the store

TryAdd is used throughout, so anything you register first wins:

builder.Services.AddDataProtectionCore(builder.Configuration)
    .PersistKeysToStore<MyKeyStore>();

On this page