Persistord.Protection 1.0.0-beta.3

This is a prerelease version of Persistord.Protection.
dotnet add package Persistord.Protection --version 1.0.0-beta.3
                    
NuGet\Install-Package Persistord.Protection -Version 1.0.0-beta.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Persistord.Protection" Version="1.0.0-beta.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Persistord.Protection" Version="1.0.0-beta.3" />
                    
Directory.Packages.props
<PackageReference Include="Persistord.Protection" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Persistord.Protection --version 1.0.0-beta.3
                    
#r "nuget: Persistord.Protection, 1.0.0-beta.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Persistord.Protection@1.0.0-beta.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Persistord.Protection&version=1.0.0-beta.3&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Persistord.Protection&version=1.0.0-beta.3&prerelease
                    
Install as a Cake Tool

Persistord.Protection

<div align="center">

NuGet Downloads

← Persistord docs · Documentation site

</div>

Encrypts [Protected] string columns of a Persistord context at rest, using ASP.NET Core Data Protection. It replaces hand-rolled calls to protector.Protect(...) scattered across every write path — miss one, and that value silently lands in the database as plaintext — with a single model-wide declaration: annotate the property, call one extension method, and every write to it goes through the same converter.

Setup

  1. Reference this package.

  2. Annotate the secret with [Protected] (Persistord.Core.Abstractions.ProtectedAttribute):

    public sealed class Webhook
    {
        [Protected]
        public string Token { get; set; } = string.Empty;
    }
    
  3. Register ProtectedStringConvention from ConfigureConventions — the recommended entry point. It runs at model finalization, after every module and entity configuration has had a chance to add properties, so unlike the ApplyProtection alternative below it cannot be called too early:

    public sealed class MyBotContext(
        DbContextOptions<MyBotContext> options,
        IDataProtectionProvider dataProtectionProvider) : DiscordDbContext(options)
    {
        public DbSet<Webhook> Webhooks => Set<Webhook>();
    
        protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
        {
            base.ConfigureConventions(configurationBuilder);
            configurationBuilder.Conventions.Add(_ => new ProtectedStringConvention(dataProtectionProvider));
        }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.Entity<Webhook>();
        }
    }
    

    The explicit alternative, ApplyProtection, is still shipped for call sites that configure protection inline in OnModelCreating. Call it last there, after the module and entity configurations that create the annotated properties — it walks the model as already built, so a property configured afterwards is not seen:

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.Entity<Webhook>();
        modelBuilder.ApplyProtection(dataProtectionProvider); // last
    }
    

    Both routes cover a [Protected] property nested inside an EF complex type (ComplexProperty), not only one declared directly on the entity. They disagree on one edge case, though: an explicit fluent HasConversion(...) the consumer configures on the same [Protected] property wins over the convention (EF's Explicit precedence beats the convention's DataAnnotation precedence), leaving that property unprotected — ApplyProtection, called last, overwrites it unconditionally instead. See Precedence on the documentation site.

[Protected] is inert without this package. Persistord.Managed's ManagedWebhook.Token, for example, is annotated [Protected], but without a reference to Persistord.Protection and either registering ProtectedStringConvention or calling ApplyProtection, it is stored as plaintext. The attribute alone changes nothing.

Every write is non-deterministic ciphertext. IDataProtector.Protect randomises its output, so the same plaintext produces a different stored value every time it is written. A [Protected] column cannot be queried by equality, cannot back a useful unique index, and its ciphertext is substantially longer than the plaintext (over 130 characters for a short secret) — account for that in any HasMaxLength you set on the property.

The purpose string is fixed

Every protector this package creates is derived from ProtectionPurposes.V1 ("Persistord.Protection.v1"). This is deliberate: a protector derived from a different purpose cannot decrypt what this one wrote, so the string can never change without orphaning every ciphertext already in the database.

One provider per application

EF Core caches the compiled model per context type. The protector baked into that cached model is the one derived from whichever IDataProtectionProvider built it first, so the first IDataProtectionProvider supplied to a given context type is the one every subsequent instance of that context type uses, for the lifetime of the process — passing a different provider on a later call has no effect. The supported arrangement is one IDataProtectionProvider per application, registered as a singleton in DI and constructor-injected into the context.

Tests that need different key rings per instance are the exception: they must also replace EF's IModelCacheKeyFactory so each configuration gets its own cache entry. Persistord.Testing's fixtures (SqliteTestDatabase) already do this for you.

Key ring

Data Protection encrypts with keys held in a key ring. This package does not configure where that key ring lives — that is IDataProtectionBuilder configuration, done once when you register Data Protection in your host. Persistord ships only Microsoft.AspNetCore.DataProtection.Abstractions; the implementation package (Microsoft.AspNetCore.DataProtection) is your dependency to add and keep patched.

  • Losing the key ring means losing every protected value. There is no recovery: without the keys that encrypted a value, Unprotect cannot produce it back.

  • The default key ring location is a per-user profile folder, which does not travel with your database and is easy to lose on redeploy, container recreation, or a new host. Persist it explicitly, next to the database, with PersistKeysToFileSystem:

    services.AddDataProtection()
        .PersistKeysToFileSystem(new DirectoryInfo("/var/my-bot/keys"));
    
  • Rotation alone does not break decryption. Data Protection keeps every key it has ever used in the ring, and each one keeps decrypting values it encrypted, even after a newer key becomes the default for new writes. A read only fails when the specific key that encrypted a value is actually gone from the ring — deleted or explicitly revoked — which surfaces as System.Security.Cryptography.CryptographicException while EF materializes the entity. See Protection on the documentation site for the deleted-versus-revoked distinction and what recovers.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-beta.3 70 9/10/2026