Wiaoj.Security.Rotation
0.0.1-alpha.80
dotnet add package Wiaoj.Security.Rotation --version 0.0.1-alpha.80
NuGet\Install-Package Wiaoj.Security.Rotation -Version 0.0.1-alpha.80
<PackageReference Include="Wiaoj.Security.Rotation" Version="0.0.1-alpha.80" />
<PackageVersion Include="Wiaoj.Security.Rotation" Version="0.0.1-alpha.80" />
<PackageReference Include="Wiaoj.Security.Rotation" />
paket add Wiaoj.Security.Rotation --version 0.0.1-alpha.80
#r "nuget: Wiaoj.Security.Rotation, 0.0.1-alpha.80"
#:package Wiaoj.Security.Rotation@0.0.1-alpha.80
#addin nuget:?package=Wiaoj.Security.Rotation&version=0.0.1-alpha.80&prerelease
#tool nuget:?package=Wiaoj.Security.Rotation&version=0.0.1-alpha.80&prerelease
Wiaoj.Security
A strongly-typed, AES-GCM envelope encryption library for .NET with automatic key rotation, EF Core persistence, and OpenTelemetry metrics.
Table of Contents
- Overview
- Packages
- How It Works
- Architecture
- Quick Start
- Key Rotation
- Master Key Providers
- EF Core Mapping
- Health Checks
- Observability (OpenTelemetry)
- Configuration Reference
- Database Schema
- Security Notes
- AES Key Sizes
Overview
Wiaoj.Security solves the problem of encrypting sensitive fields (webhook secrets, OAuth tokens, payment keys, PII) stored in a relational database, while keeping the ability to rotate keys automatically without downtime.
Key design goals:
- Type safety — phantom type contexts (
ISecretContext) prevent secrets from different domains from being mixed up at compile time. - Context binding (AAD) — Every ciphertext is cryptographically bound to its
ISecretContextname using AES-GCM Associated Data. This prevents cross-domain attacks even if keys are leaked or reused. - Log safety —
CipherBlobandEncryptedSecret<T>overrideToString()to return safe sentinels; raw ciphertext never leaks into logs. - Key wrapping — Data Encryption Keys (DEKs) are stored in the database wrapped (encrypted) with a master key. The master key never touches the database.
- Automatic rotation — a background service checks key age on a configurable interval and rotates when a key exceeds its
RotationInterval. - Hot reload — after a rotation, the in-memory key ring is atomically swapped without restarting the application or dropping any in-flight requests.
- Zero-copy secrets — key material lives in unmanaged memory (
Secret<T>) and is zeroed on disposal.
Packages
| Package | Purpose |
|---|---|
Wiaoj.Security.Abstractions |
Core types: EncryptedSecret<T>, CipherBlob, KeyVersion, ISecretProtector<T>, ISecretContext, IDataRotator<T> |
Wiaoj.Security |
SecretProtector<T>, KeyRing<T>, MasterKey, EncryptionKeyRecord, master key providers |
Wiaoj.Security.DependencyInjection |
ISecurityBuilder, AddWiaojSecurity(), master key provider registration extensions |
Wiaoj.Security.EntityFrameworkCore |
EfEncryptionKeyStore, EncryptedSecretValueConverter, EF configuration helpers |
Wiaoj.Security.Rotation |
ManagedSecretProtector<T>, KeyRotationService<T>, RotationBackgroundService<T>, health check |
How It Works
┌─────────────────────────────────────────────────────────────────────────┐
│ Envelope encryption │
│ │
│ Master Key (env / KMS) │
│ │ wraps / unwraps │
│ ▼ │
│ EncryptionKeyRecord (DB) ←→ KeyRing<TContext> (in-memory) │
│ wrapped DEK │ │
│ │ encrypts / decrypts │
│ ▼ │
│ EncryptedSecret<TContext> (DB column) │
└─────────────────────────────────────────────────────────────────────────┘
- On startup,
KeyRingLoader<TContext>loads allEncryptionKeyRecordrows for the context from the database and unwraps each DEK with the master key, building an in-memoryKeyRing<TContext>. ManagedSecretProtector<TContext>(singleton) wraps the key ring and exposesProtect/Unprotect/Rotate.- A
RotationBackgroundService<TContext>wakes up everyCheckInterval(default 6 h), compares the active key's age toRotationInterval(default 90 days), and triggersKeyRotationService<TContext>if rotation is needed. - Rotation generates a new DEK, wraps it with the master key, saves it to the database, retires the old key, and hot-reloads the in-memory key ring atomically.
- If an
IDataRotator<TContext>is registered andAutoRotateDataistrue, the background service re-encrypts all stale application records in batches after each rotation.
Architecture
App Startup
│
▼
SecurityInitializationService<TContext> (IHostedService — runs before requests)
│ calls EnsureInitializedAsync()
▼
ManagedSecretProtector<TContext> (singleton, lazy, hot-reloadable)
│ AsyncLazy<SecretProtector<TContext>>
│
│ on first access or reload:
▼
KeyRingLoader<TContext> (scoped)
│ LoadKeysAsync() → IEncryptionKeyStore
│ UnwrapToKey() → IMasterKeyProvider + MasterKey
│ → KeyRing<TContext>
│
▼
SecretProtector<TContext> (owns the KeyRing)
│ Protect(plaintext) → EncryptedSecret<TContext>
│ Unprotect(secret) → Secret<byte>
│ Rotate(secret) → EncryptedSecret<TContext>
Background loop (every CheckInterval):
▼
RotationBackgroundService<TContext> (BackgroundService singleton)
│ creates scoped KeyRotationService per tick
▼
KeyRotationService<TContext> (scoped)
├─ RotateIfNeededAsync() — checks key age vs RotationInterval
└─ ForceRotateAsync() — manual / admin endpoint
│
├─ 1. Generate new AES DEK
├─ 2. Wrap with master key → save to DB
├─ 3. Retire old key in DB
├─ 4. ManagedSecretProtector.ReloadAsync() (atomic ring swap)
└─ 5. IDataRotator<TContext>.RotateBatchAsync() (if registered)
Quick Start
1. Implement IEncryptionKeyDbContext
Add the EncryptionKeys set to your DbContext and apply the EF configuration:
public class AppDbContext : DbContext, IEncryptionKeyDbContext
{
public DbSet<EncryptionKeyRecord> EncryptionKeys { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new EncryptionKeyRecordConfiguration());
// ... your other configs
}
}
2. Generate a master key
# bash
openssl rand -base64 32
# PowerShell
[Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
# C#
Console.WriteLine(Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
Important: Store this value in your secrets manager (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, Kubernetes Secret, Docker Secret). Never commit it to source control or put it in
appsettings.json.
3. Register services
// Program.cs
builder.Services
.AddWiaojSecurity(opts =>
{
opts.RotationInterval = TimeSpan.FromDays(90);
opts.CheckInterval = TimeSpan.FromHours(6);
opts.KeySizeInBits = 256; // 128, 192, or 256
opts.AutoRotateData = true;
opts.DataRotationBatchSize = 100;
opts.DataRotationBatchDelay = TimeSpan.FromMilliseconds(50);
})
.AddEnvironmentMasterKey("APP_MASTER_KEY") // reads from env var
.AddEntityFrameworkKeyStore<AppDbContext>() // store DEKs in EF Core
.AddManagedProtector<WebhookSigningContext>() // full rotation lifecycle
.AddDataRotator<WebhookSigningContext, WebhookDataRotator>(); // re-encrypt data
You can also bind options from appsettings.json:
builder.Services
.AddWiaojSecurity(builder.Configuration.GetSection("Security"))
.AddEnvironmentMasterKey()
.AddEntityFrameworkKeyStore<AppDbContext>()
.AddManagedProtector<WebhookSigningContext>();
// appsettings.json
{
"Security": {
"RotationInterval": "90.00:00:00",
"CheckInterval": "06:00:00",
"KeySizeInBits": 256,
"AutoRotateData": true,
"DataRotationBatchSize": 100
}
}
You can call AddManagedProtector<TContext>() once per secret domain:
builder.Services
.AddWiaojSecurity()
.AddEnvironmentMasterKey()
.AddEntityFrameworkKeyStore<AppDbContext>()
.AddManagedProtector<WebhookSigningContext>()
.AddManagedProtector<PaymentGatewayContext>()
.AddManagedProtector<OAuthClientSecretContext>();
4. Define secret domains
// Empty marker classes — the type parameter enforces domain separation at compile time.
public sealed class WebhookSigningContext : ISecretContext { }
public sealed class PaymentGatewayContext : ISecretContext { }
public sealed class OAuthClientSecretContext : ISecretContext { }
An EncryptedSecret<WebhookSigningContext> cannot be passed to an
ISecretProtector<PaymentGatewayContext> — the mismatch is a compile error.
5. Use the protector
Inject ISecretProtector<TContext> wherever you need to encrypt or decrypt:
public class WebhookService(ISecretProtector<WebhookSigningContext> protector)
{
public async Task StoreSecretAsync(Webhook webhook, string rawSecret)
{
// Encrypt
EncryptedSecret<WebhookSigningContext> encrypted = protector.Protect(rawSecret);
webhook.EncryptedSecret = encrypted.Blob.ToStorageString();
webhook.SecretKeyVersion = encrypted.KeyVersion.Value;
// (or use the EF value converter below — no manual serialization needed)
}
public string RevealSecret(Webhook webhook)
{
var encrypted = EncryptedSecret<WebhookSigningContext>.FromPersisted(
webhook.EncryptedSecret, webhook.SecretKeyVersion);
// Decrypts into secure unmanaged memory — dispose when done.
using Secret<byte> plain = protector.Unprotect(encrypted);
return plain.Expose(bytes => System.Text.Encoding.UTF8.GetString(bytes));
}
}
6. Implement IDataRotator
The data rotator re-encrypts records that were encrypted with an older key version:
public sealed class WebhookDataRotator(
AppDbContext db,
ISecretProtector<WebhookSigningContext> protector) : IDataRotator<WebhookSigningContext>
{
public async Task<int> RotateBatchAsync(int batchSize, CancellationToken ct = default)
{
var stale = await db.Webhooks
.Where(w => w.SecretKeyVersion < (int)protector.CurrentKeyVersion)
.Take(batchSize)
.ToListAsync(ct);
foreach (var webhook in stale)
{
var encrypted = EncryptedSecret<WebhookSigningContext>.FromPersisted(
webhook.EncryptedSecret, webhook.SecretKeyVersion);
var rotated = protector.Rotate(encrypted);
webhook.EncryptedSecret = rotated.Blob.ToStorageString();
webhook.SecretKeyVersion = rotated.KeyVersion.Value;
}
await db.SaveChangesAsync(ct);
return stale.Count;
}
public async Task<bool> IsCompleteAsync(CancellationToken ct = default)
=> !await db.Webhooks.AnyAsync(
w => w.SecretKeyVersion < (int)protector.CurrentKeyVersion, ct);
}
7. Add a migration
dotnet ef migrations add AddEncryptionKeys
dotnet ef database update
Key Rotation
Automatic rotation
RotationBackgroundService<TContext> runs in the background. Every CheckInterval (default 6 h) it:
- Loads the current active key from the store.
- Checks if its age exceeds
RotationInterval(default 90 days). - If yes, calls
KeyRotationService<TContext>.RotateIfNeededAsync(), which:- Generates a new random AES key.
- Wraps it with the master key and persists it to the database.
- Marks the previous key as retired in the database.
- Hot-reloads the in-memory key ring (atomic swap, no downtime).
- Optionally runs
IDataRotator<TContext>in batches.
Transient errors (database unavailable, network blip) are caught, logged, and retried after RetryIntervalOnError (default 5 min) rather than crashing.
Forced rotation
Trigger an immediate rotation from an admin endpoint:
app.MapPost("/admin/keys/rotate", async (
[FromServices] KeyRotationService<WebhookSigningContext> rotationService,
CancellationToken ct) =>
{
await rotationService.ForceRotateAsync(ct);
return Results.Ok(new { rotated = true });
}).RequireAuthorization("Admin");
KeyRotationService<TContext>is scoped. Resolve it from a scoped context (e.g. a controller or a manually created scope) — do not inject it into a singleton.
Key lifecycle
Day 0: Key v1 created (active)
Day 90: Key v2 created (active), v1 retired → still used for decryption
Day 180: Key v3 created (active), v2 retired
Once all v1 ciphertext has been re-encrypted, v1 can be deleted from DB.
Retired keys stay in the database until all records referencing them have been re-encrypted by IDataRotator<TContext>. Only then is it safe to delete them.
Master Key Providers
Environment variable (dev/staging)
.AddEnvironmentMasterKey("APP_MASTER_KEY") // default variable name: APP_MASTER_KEY
export APP_MASTER_KEY="$(openssl rand -base64 32)"
IConfiguration / appsettings
.AddConfigurationMasterKey("Security:MasterKey") // default config path
Never commit a real key to source control. Use .NET User Secrets for local development.
File (Docker Secrets, Kubernetes Secrets)
.AddFileMasterKey("/run/secrets/app_master_key")
The file must contain a Base64-encoded key, optionally with leading/trailing whitespace.
Custom provider (Azure Key Vault, AWS KMS)
Implement IMasterKeyProvider:
public sealed class AzureKeyVaultMasterKeyProvider(SecretClient client, string secretName)
: IMasterKeyProvider
{
public async ValueTask<MasterKey> GetMasterKeyAsync(CancellationToken ct = default)
{
KeyVaultSecret kvSecret = await client.GetSecretAsync(secretName, cancellationToken: ct);
byte[] keyBytes = Convert.FromBase64String(kvSecret.Value);
try
{
return new MasterKey(Secret<byte>.From(keyBytes));
}
finally
{
CryptographicOperations.ZeroMemory(keyBytes);
}
}
}
Register it:
.AddMasterKeyProvider<AzureKeyVaultMasterKeyProvider>()
or with a factory:
builder.Services.AddSingleton<IMasterKeyProvider>(sp =>
{
var client = sp.GetRequiredService<SecretClient>();
return new AzureKeyVaultMasterKeyProvider(client, "app-master-key");
});
EF Core Mapping
Single-column storage
EncryptedSecretValueConverter<TContext> serialises an EncryptedSecret<TContext> into a single string column using the format {version}:{base64blob}.
// In OnModelCreating or IEntityTypeConfiguration:
builder.Property(x => x.EncryptedApiKey)
.HasEncryptedSecretConversion<ApiKeyContext>();
For nullable properties:
builder.Property(x => x.EncryptedApiKey) // EncryptedSecret<ApiKeyContext>?
.HasEncryptedSecretConversion<ApiKeyContext>();
Two-column storage
If you prefer storing the version and blob in separate columns (better for queries / indexes), skip the converter and map manually:
// Entity
public string EncryptedSecret { get; set; } = string.Empty;
public int SecretKeyVersion { get; set; }
// Reading
var secret = EncryptedSecret<WebhookSigningContext>.FromPersisted(
entity.EncryptedSecret, entity.SecretKeyVersion);
// Writing
entity.EncryptedSecret = encrypted.Blob.ToStorageString();
entity.SecretKeyVersion = encrypted.KeyVersion.Value;
Convention-based mapping
To automatically apply the converter to all EncryptedSecret<TContext> properties in the model:
protected override void ConfigureConventions(ModelConfigurationBuilder configBuilder)
{
configBuilder.Properties<EncryptedSecret<WebhookSigningContext>>()
.HaveEncryptedSecretConversion<WebhookSigningContext>();
}
Health Checks
Register a health check for each protected context:
builder.Services
.AddWiaojSecurity()
// ...
.AddManagedProtector<WebhookSigningContext>()
.AddSecurityHealthCheck<WebhookSigningContext>(
name: "security_webhook",
tags: ["ready", "security"]);
builder.Services.AddHealthChecks(); // required if not already registered
Map health endpoints:
app.MapHealthChecks("/health/live", new() { Predicate = r => r.Tags.Contains("live") });
app.MapHealthChecks("/health/ready", new() { Predicate = r => r.Tags.Contains("ready") });
Reported statuses:
| Status | Meaning |
|---|---|
Healthy |
Key ring loaded; active key is within its rotation window |
Degraded |
Key ring loaded but the active key is overdue for rotation, or the store is temporarily unreachable |
Unhealthy |
Key ring was never successfully initialized (startup failure) |
Observability (OpenTelemetry)
All instruments are exposed on the meter named Wiaoj.Security.
services.AddOpenTelemetry()
.WithMetrics(m => m.AddMeter(SecurityMeter.Name));
| Instrument | Type | Description |
|---|---|---|
wiaoj.security.protect.count |
Counter | Successful Protect calls |
wiaoj.security.protect.error.count |
Counter | Failed Protect calls |
wiaoj.security.protect.duration |
Histogram (ms) | Protect latency |
wiaoj.security.unprotect.count |
Counter | Successful Unprotect calls |
wiaoj.security.unprotect.error.count |
Counter | Failed Unprotect calls (auth failures, tampering) |
wiaoj.security.unprotect.duration |
Histogram (ms) | Unprotect latency |
wiaoj.security.rotation.count |
Counter | Completed key rotation cycles |
wiaoj.security.rotation.error.count |
Counter | Failed rotation cycles |
wiaoj.security.rotation.duration |
Histogram (ms) | Full rotation cycle duration |
wiaoj.security.keyring.reload.count |
Counter | Key ring reload operations (startup + post-rotation) |
All instruments carry a context tag (e.g. "WebhookSigningContext") so you can break down dashboards per domain.
Configuration Reference
All options live in KeyRotationOptions. Configure via the delegate or bind from IConfiguration:
| Property | Default | Description |
|---|---|---|
RotationInterval |
90 days |
How long a key stays active before being rotated |
CheckInterval |
6 hours |
How often the background service checks if rotation is needed |
RetryIntervalOnError |
5 minutes |
Wait time after a failed rotation check before retrying |
KeySizeInBits |
256 |
AES key size: 128, 192, or 256 |
AutoRotateData |
true |
Whether to run IDataRotator<T> after each rotation |
DataRotationBatchSize |
100 |
Records re-encrypted per batch |
DataRotationBatchDelay |
50 ms |
Delay between batches to avoid saturating the database |
Validation runs at startup (ValidateOnStart). Invalid values throw during IHost.Build().
Database Schema
The EncryptionKeyRecord entity maps to a single table (default name: class name, customizable via EF conventions).
| Column | Type | Description |
|---|---|---|
Id |
uuid |
UUIDv7 primary key (time-ordered) |
ContextName |
varchar(256) |
Name of the ISecretContext type, e.g. "WebhookSigningContext" |
Version |
int |
Monotonically increasing version number per context |
WrappedKeyMaterial |
varchar(1024) |
Base64Url(nonce[12] | auth_tag[16] | ciphertext[N]) — the DEK encrypted with the master key |
CreatedAt |
datetimeoffset |
UTC creation timestamp |
RetiredAt |
datetimeoffset? |
UTC retirement timestamp; NULL = still active |
Indexes created automatically by EncryptionKeyRecordConfiguration:
- Unique on
(ContextName, Version)— prevents duplicate versions. - Lookup on
ContextName— for fast per-context queries.
The master key never appears in the database.
Security Notes
- AES-GCM authentication: Every ciphertext includes a 128-bit authentication tag. Tampered or corrupt blobs throw
CryptographicExceptionon decryption. - Strict Context Identity: Since the
TContexttype name is used as Associated Authenticated Data (AAD), renaming a context class (e.g.WebhookPayloadContexttoPayloadContext) will cause decryption to fail for all existing records. Do not rename context classes without a data migration plan. - Master key storage: In production, always source the master key from a KMS (Azure Key Vault, AWS KMS, HashiCorp Vault). The environment variable and configuration providers are for development and staging only.
- Key material in memory: DEKs and the master key are held in unmanaged memory (
Secret<T>) and zeroed on disposal viaCryptographicOperations.ZeroMemory. Do not copy key bytes into managed arrays without zeroing them immediately after use. - Log safety:
CipherBlob.ToString()returns"[CIPHER_BLOB]".EncryptedSecret<T>.ToString()returns"[ENCRYPTED_SECRET<TContext> key_vN]". Neither ever includes ciphertext. - Retired keys: Keep retired keys in the database until
IDataRotator<T>.IsCompleteAsync()returnstrue. Deleting a retired key before all data has been re-encrypted will make those records permanently unreadable. - Compile-time domain isolation: An
EncryptedSecret<WebhookSigningContext>cannot be accidentally decrypted by anISecretProtector<PaymentGatewayContext>— the type system prevents this at compile time.
AES Key Sizes
AES supports 128 / 192 / 256-bit keys only.
| Size | Notes |
|---|---|
| 128-bit | Acceptable for low-sensitivity data |
| 192-bit | Rarely used in practice |
| 256-bit | Default. NSA Suite B, recommended for all new deployments. |
Configure via KeySizeInBits in KeyRotationOptions.
| Product | Versions 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. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.7)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.7)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.7)
- Wiaoj.Concurrency (>= 0.0.1-alpha.80)
- Wiaoj.Security (>= 0.0.1-alpha.80)
- Wiaoj.Security.DependencyInjection (>= 0.0.1-alpha.80)
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 |
|---|---|---|
| 0.0.1-alpha.80 | 39 | 6/17/2026 |
| 0.0.1-alpha.79 | 39 | 6/17/2026 |
| 0.0.1-alpha.78 | 41 | 6/17/2026 |
| 0.0.1-alpha.77 | 35 | 6/17/2026 |
| 0.0.1-alpha.76 | 37 | 6/17/2026 |
| 0.0.1-alpha.75 | 50 | 6/11/2026 |
| 0.0.1-alpha.74 | 57 | 6/10/2026 |
| 0.0.1-alpha.73 | 45 | 6/5/2026 |
| 0.0.1-alpha.72 | 50 | 6/4/2026 |
| 0.0.1-alpha.71 | 50 | 6/2/2026 |
| 0.0.1-alpha.70 | 50 | 5/30/2026 |
| 0.0.1-alpha.69 | 56 | 5/15/2026 |
| 0.0.1-alpha.68 | 52 | 5/15/2026 |
| 0.0.1-alpha.67 | 53 | 5/14/2026 |
| 0.0.1-alpha.66 | 50 | 5/13/2026 |
| 0.0.1-alpha.65 | 57 | 5/12/2026 |
| 0.0.1-alpha.64 | 49 | 5/12/2026 |
| 0.0.1-alpha.63 | 53 | 5/12/2026 |
| 0.0.1-alpha.62 | 61 | 5/8/2026 |
| 0.0.1-alpha.61 | 47 | 5/6/2026 |