hexasync.dataprotection 1.10.61
hexasync.dataprotection
Envelope encryption (AES-256-GCM) with pluggable KEK custody and key rotation — the HexaSync at-rest data-protection
library. Self-describing hxdp: wire format, code-registry algorithm versioning, per-service DEK keyring with
in-memory cache, EF Core integration ([Encrypted]), and a self-healing DEK/KEK lifecycle driver.
The Azure Key Vault KEK adapter ships separately as hexasync.dataprotection.azurekeyvault.
How it works (30 seconds)
A per-service KEK (key-encryption key) wraps per-generation DEKs (data-encryption keys). Your data is
encrypted with the active DEK; the wrapped DEKs live in a data_encryption_keys table in your database. On boot
the library unwraps the keyring into an in-memory cache (one KEK round-trip), then encrypt/decrypt are local. It is
hxdp:-only and fails closed — it never falls back to plaintext or a legacy format.
Install
dotnet add package hexasync.dataprotection
dotnet add package hexasync.dataprotection.azurekeyvault # production KEK custody (optional in dev)
Configuration (flat DATA_PROTECTION_* env vars)
House convention: config via flat UPPER_SNAKE_CASE environment variables (no appsettings.json). Each maps to a
property via [ConfigurationKeyName], bound as IOptions<DataProtectionConfig>.
DATA_PROTECTION_KEK_PROVIDER=Local # "Local" (dev/low envs) or "AzureKeyVault" (prod); also the default KEK_ACTIVE
DATA_PROTECTION_KEK_ACTIVE=Local # which registered provider WRAPS new/re-wrapped DEKs (default = KEK_PROVIDER).
# Register BOTH providers and flip this for a seamless Local↔Azure cutover
# (see "Multiple KEK providers" below). Unwrap always routes to whoever wrapped a DEK.
DATA_PROTECTION_MASTER_KEY_BASE64=<b64 32 bytes> # REQUIRED when Local is active/fallback (`openssl rand -base64 32`)
DATA_PROTECTION_MASTER_KEY_BASE64_OLD= # the PREVIOUS master, set only while rotating it (see "Rotating the local master")
DATA_PROTECTION_KEK_ID=local # the id LEGACY rows carry. It no longer stamps new wraps — the kek_id is
# derived from the master itself (`local:<12 hex>`), so it tracks the key
DATA_PROTECTION_ACTIVE_SCHEME=v1 # optional; default = newest registered scheme; unknown → boot throws
DATA_PROTECTION_EXPECTED_MAX_PODS=16 # must be >= your HPA maxReplicas; drives the nonce early-rotation trip
DATA_PROTECTION_STARTUP_MAX_ATTEMPTS=5 # optional; bounded startup-warm retries (KEK may be transiently down)
DATA_PROTECTION_STARTUP_RETRY_DELAY=00:00:02 # optional
Localis for dev / low environments. The master key sits in config; a KV-wrapped DEK cannot be unwrapped locally (by design — prod data is not decryptable off-cluster). Production should useAzureKeyVault(see that package's README).
Key rotation has no env var. The DEK rotation period and the rest of the maintenance schedule are configured in
code via the AddKeyringMaintenance(cfg => …) lambda — there is no DATA_PROTECTION_MAX_DEK_AGE and no
DataProtection:KeyringMaintenance JSON section. See Key rotation.
Wiring (EF-backed, in order)
// 1. Your DbContext factory — the library needs a FRESH context per keyring call (the provider is a singleton;
// a captured scoped DbContext would throw ObjectDisposedException under load).
services.AddDbContextFactory<MyDbContext>(o => o.UseNpgsql(connectionString));
// 2. Compose the library (store + provider + IDataProtector + startup warm); binds the DATA_PROTECTION_* env vars.
services.AddHexaSyncDataProtectionWithEf<MyDbContext>(configuration); // bundles AddEfDekKeyringStore<MyDbContext>()
// 3. Azure Key Vault KEK custody. May be registered ALONGSIDE Local (both coexist — DATA_PROTECTION_KEK_ACTIVE picks
// which one wraps; unwrap routes to whichever wrapped a given DEK). This is the seamless-cutover path (below).
services.AddAzureKeyVaultKeyWrapper(configuration);
// 4. Health check (readiness): after AddHexaSyncDataProtection.
services.AddHealthChecks().AddHexaSyncDataProtectionHealthCheck();
// 5. Opt-in self-healing maintenance (DEK rotation + KEK re-wrap). Requires a leader lock (below).
// WITHOUT this call nothing ever rotates or re-wraps — see "Key rotation" for what that costs you.
services.AddSingleton<IDataProtectionLeaderLock, RedisMedallionLeaderLock>();
services.AddKeyringMaintenance(cfg =>
{
cfg.LeaderKey = "hexasync:<your-service>:keyring-maintenance"; // per SERVICE — the default is shared
cfg.MaxDekAge = TimeSpan.FromDays(180); // the DEK rotation period (this is the default)
});
A custom audit sink (optional) must be registered before AddHexaSyncDataProtection (it wins a TryAdd
over the default no-op): services.AddSingleton<IDataProtectionAuditSink, MySink>();.
Leader-lock adapter (Medallion.Threading Redis)
AddKeyringMaintenance needs an IDataProtectionLeaderLock so exactly one replica per service runs the cycle. The
library ships no Redis dependency — adapt your existing distributed lock (the shape is a single blocking acquire
returning a handle held until disposed):
public sealed class RedisMedallionLeaderLock(IDistributedLockProvider locks) : IDataProtectionLeaderLock
{
public async Task<IAsyncDisposable> AcquireAsync(string key, CancellationToken ct)
=> await locks.AcquireLockAsync(key, cancellationToken: ct); // Medallion handle is already IAsyncDisposable
}
AcquireAsync should block until acquired (so a dead leader's lock is picked up as soon as it releases).
Key rotation (DEK age) and the maintenance cycle
The first DEK is created at startup regardless — but nothing rotates until you call AddKeyringMaintenance.
AddHexaSyncDataProtection deliberately does not wire it (that would force an IDataProtectionLeaderLock on every
consumer). Skip it and you get a DEK that encrypts forever, no KEK re-wrap when the KEK version changes, and no
overdue signal — the health check's Degraded branch only runs when KeyringMaintenanceConfig is registered.
One elected leader per service runs a cycle every Interval, and each cycle: ensures an active DEK exists →
rotates it if it is older than MaxDekAge (the previous DEK is retained so old data still decrypts) → re-wraps
every DEK forward if the KEK version changed.
KeyringMaintenanceConfig
| Property | Default | What it does |
|---|---|---|
MaxDekAge |
180 days | The rotation period. The active DEK is rotated once it is older than this. |
Interval |
24 hours | How often the leader checks. Rotation can only happen on a cycle boundary — keep this well below MaxDekAge, or a rotation lands late by up to one interval. |
LeaderKey |
hexasync:dataprotection:keyring-maintenance |
Distributed-lock key. Override per service — services sharing a Redis and this default elect one leader between them, so only one service's keyring gets maintained. |
OverdueGrace |
7 days | Slack past MaxDekAge before the health check reports Degraded. Never Unhealthy — an overdue rotation still encrypts and decrypts fine. |
MaxJitter |
30 minutes | Random extra delay per cycle so a fleet doesn't rotate in lockstep. 0 disables. |
ReacquireDelay |
30 seconds | Backoff before re-acquiring leadership after a leader-loop error. |
services.AddKeyringMaintenance(cfg =>
{
cfg.LeaderKey = "hexasync:my-service:keyring-maintenance";
cfg.MaxDekAge = TimeSpan.FromDays(30); // rotate monthly instead of the 180-day default
cfg.Interval = TimeSpan.FromHours(12); // still ≪ MaxDekAge
});
Shortening MaxDekAge is always safe; retired DEKs stay in the keyring for decrypt, so old blobs keep reading.
What it costs is keyring growth and re-encryption pressure if you also run force-reconcile.
Want it env-driven? Read it yourself at the call site — the library binds no env var for it:
cfg.MaxDekAge = TimeSpan.Parse(builder.Configuration["DATA_PROTECTION_MAX_DEK_AGE"] ?? "180.00:00:00");
The 180-day timer is the enforced bound, not the only trigger. A per-process invocation counter can rotate
earlier to stay inside the AES-GCM nonce budget — that one is env-tunable via
DATA_PROTECTION_EXPECTED_MAX_PODS (and DATA_PROTECTION_NONCE_THRESHOLD_OVERRIDE). It is defense-in-depth; set
EXPECTED_MAX_PODS ≥ your HPA maxReplicas and leave the rotation policy to MaxDekAge.
Rotating the DEK (this section, automatic) is not rotating the KEK (manual, in your vault, and data-loss-class if you get retention wrong — see the ops runbook).
Rotating the local master (the Local KEK)
The kek_id a locally-wrapped DEK carries is derived from the master key: local:<12 hex>, a domain-separated
SHA-256 prefix. That is deliberate — it means the id changes exactly when the key changes, so the maintenance
re-wrap notices a new master on its own. You cannot swap the master while the id stays put, which is what used
to turn a master swap into silent, total, unrecoverable data loss.
To rotate it, keep the old master reachable for as long as any DEK is still wrapped by it:
# 1. Both masters present. The old one is a DECRYPT-ONLY fallback (token: LocalPrevious); it can never be KEK_ACTIVE.
DATA_PROTECTION_MASTER_KEY_BASE64=<the NEW master>
DATA_PROTECTION_MASTER_KEY_BASE64_OLD=<the master being retired>
- Deploy. The next maintenance cycle re-wraps every DEK onto the new master (
ReWrapAllAsynctargets by exactkek_id), or call it directly for an immediate, logged pass. This requiresAddKeyringMaintenance— without it nothing ever re-wraps and the rotation never completes. - Verify before step 4:
AssertNoneWrappedByAsync("LocalPrevious")(fail-closed) orCountWrappedByAsync("LocalPrevious") == 0. The KEK status endpoint shows the same thing perkek_id. - Only now remove
DATA_PROTECTION_MASTER_KEY_BASE64_OLDand deploy. Dropping it while step 3 is non-zero destroys those DEKs.
Already running Local today? Nothing to do and nothing to change. Existing rows carry the literal id from
DATA_PROTECTION_KEK_ID (default local); they keep unwrapping — the AAD binds the id recorded on the row, not the
current one — and they are re-wrapped onto the derived id on the first maintenance cycle. Keep DATA_PROTECTION_KEK_ID
set to whatever it has been: it is how the drop gate still recognizes those rows as yours.
Composition refuses three configurations outright, each of which would break the rotation silently: KEK_ACTIVE
pointing at LocalPrevious (new DEKs would be wrapped under the key you are retiring), ..._OLD identical to the
current master (the drop gate could no longer tell them apart), and ..._OLD set with no current master.
DEK deletion & retention (how long a retired key is kept)
By default: forever. Rotation retires a DEK and keeps its material (KEK-wrapped) so old blobs still decrypt.
Deletion is the separate opt-in AddDekDestroy path, which AddHexaSyncDataProtection does not wire. Not wiring it
is a safe default — it only costs keyring rows.
Once wired, deletion is two-phase, on two independent clocks (DekDestroyConfig):
| Phase | Knob | Default | Clock starts at | Reversible? |
|---|---|---|---|---|
Soft delete — out of service, wrapped_dek retained |
DestroyGracePeriod |
30 days | retired_at, the drain-confirmed-since anchor (not the retirement time) |
Yes — DekDestroyer.RestoreAsync(keyId) |
Hard delete — wrapped_dek = NULL, irreversible shred |
SoftDeleteRetention |
60 days | soft_deleted_at |
No |
At defaults, measured from the moment the drain is confirmed: soft delete ≈ 30 days, hard delete ≈ 90 days. A
leader evaluates both phases every CheckInterval (1 h + up to 5 min jitter), so each lands within about an hour of
eligibility. Whole life of a DEK from creation ≈ 180 d rotation + drain + 30 d + 60 d ≈ 270+ days.
services.AddDekDestroy(cfg =>
{
cfg.LeaderKey = "hexasync:<your-service>:dek-destroy"; // per SERVICE
cfg.DestroyGracePeriod = TimeSpan.FromDays(30); // → soft delete (recoverable)
cfg.SoftDeleteRetention = TimeSpan.FromDays(60); // → hard shred (irreversible)
cfg.CompromiseGracePeriod = TimeSpan.FromDays(1); // → soft delete, compromised fast-path
});
Four behaviours worth knowing before you tune these:
- A compromised generation shortens the wait before the soft delete only.
CompromiseGracePeriod(1 day, clamped so it can never lengthen the wait) replaces the 30 days, but the 60-day soft-delete window still applies — so material lifetime is ~61 days, not 1. ShortenSoftDeleteRetentiontoo if you need it gone faster. - The 30-day clock is retractable.
retired_atis stamped only once the reconcile sweep confirms the generation is drained, and is reset to null if that confirmation drops — the 30 days restart. A generation with straggler rows never starts the clock. Consequence: withoutAddDekForceReconcilenothing is ever deleted (it is the only writer of the drain signal, which is whyAddDekDestroyfails the host at startup without it, plus active-DEK fencing and a shared non-defaultIDekReconcileGate). - Both phases re-run a fresh drained-veto at the instant they act. A row that reappears during the 60-day window (a backup restore, an unregistered dynamic table) defers the shred and logs it — it is never forced.
RestoreAsyncclears both timestamps, so a recovered generation must re-drain and serve the fullDestroyGracePeriodagain.
Lengthening either window is always safe. Shortening SoftDeleteRetention shortens your recovery runway — that
window is what makes a premature soft-delete an operator fix instead of a data-loss event. Don't set it to zero.
Completing the lifecycle — the background services and what breaks without each
Seven hosted services ship here; AddHexaSyncDataProtection wires exactly one. The lifecycle is a chain: a missing
stage doesn't error, it makes every later stage a silent no-op (destroy is the exception — it refuses to boot).
| Stage | Service | Registered by | Leader? | Missing ⇒ |
|---|---|---|---|---|
| 1. Warm + bootstrap | DataProtectionInitializer |
AddHexaSyncDataProtection — always |
no | n/a (on exhausted retries the pod stays NOT READY, fail-closed) |
| 2. Rotate + re-wrap | KeyringMaintenanceService |
AddKeyringMaintenance |
yes | one DEK forever, no KEK re-wrap, no overdue signal |
| 3. Drain | DekForceReconcileService |
AddDekForceReconcile<TContext> |
yes | retired DEKs never drain ⇒ the deletion clock never starts. Sole writer of the reconcile gate |
| 4. Delete | DekDestroyConfirmService |
AddDekDestroy |
yes | retired DEKs kept indefinitely — the safe default |
| Writer fence | DekActivePointerRefreshService |
core, only if EnableActiveDekFencing |
no | auto-wired with the flag — nothing to do |
| Fleet evict | DekEvictionSubscriberService |
AddDekDestroy |
no | no-op without an IDekEvictionBroadcast; rolling restart is the backstop |
| Boot guard | DekDestroyFenceValidationService |
AddDekDestroy |
no | it is the guard — see the trap below |
Most services should stop at stage 2. Stages 3-4 exist to retire key material; with no compliance driver for shredding, rotate + re-wrap and leave retired DEKs in the keyring — they cost rows, not risk.
Full wiring for a multi-pod service that deletes:
// Stage 1 (+ DATA_PROTECTION_ENABLE_ACTIVE_DEK_FENCING=true)
services.AddHexaSyncDataProtectionWithEf<MyDbContext>(configuration);
services.AddHealthChecks().AddHexaSyncDataProtectionHealthCheck();
// Your seams — multi-pod destroy needs the pointer cache AND the gate SHARED (Redis); the built-in defaults are
// per-pod and the boot guard rejects them.
services.AddSingleton<IDataProtectionLeaderLock, RedisMedallionLeaderLock>();
services.AddSingleton<IDekActiveKeyPointerCache, RedisDekActiveKeyPointerCache>();
services.AddSingleton<IDekReconcileGate, RedisDekReconcileGate>();
services.AddSingleton<IDekEvictionBroadcast, RedisDekEvictionBroadcast>(); // optional, recommended
// Stages 2-4 — each with its OWN per-service LeaderKey
services.AddKeyringMaintenance(cfg => { cfg.LeaderKey = "hexasync:my-service:keyring-maintenance"; });
services.AddDekForceReconcile<MyDbContext>(cfg =>
{
cfg.LeaderKey = "hexasync:my-service:dek-force-reconcile";
cfg.ActiveKeyPropagationDelay = TimeSpan.FromHours(24); // REQUIRED for multi-pod destroy — see below
});
services.AddDekDestroy(cfg => { cfg.LeaderKey = "hexasync:my-service:dek-destroy"; });
Traps:
ActiveKeyPropagationDelaydefaults toTimeSpan.Zero(hold off) and nothing validates it. Multi-pod +AddDekDestroymust set it positive (24 h is the reference value), or rows can be reconciled onto a new key — arming destruction — while a stale pod still encrypts under the previous one. The boot guard checks fencing, the pointer cache and the gate; not this knob, so a misconfiguration boots clean.- Every leader-elected
Add…defaultsLeaderKeyto the samehexasync:dataprotection:*string — two services on one Redis then elect a leader between them and only one keyring gets maintained. Override all three. AddDekForceReconcile<TContext>needsAddDbContextFactory<TContext>— the drain is a singleton driver creating a fresh context per batch.
DekForceReconcileConfig
| Property | Default | What it does |
|---|---|---|
BatchSize |
500 rows | Rows re-encrypted per pass. Bounds the transaction and the load on your database; the pass is incremental and resumable, so a smaller value slows the drain rather than breaking it. |
CheckInterval |
1 minute | How often the leader runs a pass. |
ReconciledTtl |
24 hours | How long a clean pass stays trusted before it must be re-proved. A DEK rotation invalidates it immediately, so a rotation always forces a fresh drain. |
NonceBudgetBackoff |
5 seconds | Re-encryption spends nonces from the current DEK. When that budget tightens the pass yields for this long instead of driving an early rotation. |
ActiveKeyPropagationDelay |
off (Zero) |
Withholds a pass until the DB-active generation has been active this long. Set it to 24h for multi-pod, so no stale writer can still be encrypting under a prior key while rows are drained and destruction is armed. Off by default so enabling it never silently adds a day's delay to an existing single-pod consumer. |
MaxJitter |
30 seconds | Random extra delay per cycle. |
ReacquireDelay |
30 seconds | Backoff before re-acquiring leadership after an error. |
Lowering BatchSize or lengthening CheckInterval is always safe — the drain just takes longer. The one value that
changes correctness rather than speed is ActiveKeyPropagationDelay.
- A drain pass that hits a fail-closed decrypt does not set the gate, so the next tick retries instead of declaring the generation drained — a stuck row blocks deletion rather than risking it.
Your DbContext
Implement IDataProtectionDbContext (exposes the keyring DbSet), inject the resolved IDataProtector into the
ctor, and call ApplyDataProtection in OnModelCreating:
public class MyDbContext(DbContextOptions options, IDataProtector protector)
: DbContext(options), IDataProtectionDbContext
{
public DbSet<DataEncryptionKeyRecord> DataEncryptionKeys => Set<DataEncryptionKeyRecord>();
public DbSet<UserSecret> UserSecrets => Set<UserSecret>();
protected override void OnModelCreating(ModelBuilder mb) => mb.ApplyDataProtection(protector);
// ApplyDataProtection wires the [Encrypted] converter, guards (no index/non-string [Encrypted]), and maps the
// data_encryption_keys table by construction.
}
[Table("user_secrets")]
public class UserSecret
{
[Key] public int Id { get; set; }
[Encrypted] public string TotpSeed { get; set; } = ""; // stored as an hxdp: blob, transparently en/decrypted
public string Label { get; set; } = ""; // plaintext, queryable
}
Migration (you own it — the library ships none)
The keyring table your migration must create:
CREATE TABLE data_encryption_keys (
key_id text PRIMARY KEY,
wrapped_dek bytea NOT NULL,
kek_id text NOT NULL,
state text NOT NULL, -- pending | active | retired | destroyed (plain text, not an enum)
created_under_scheme text NOT NULL, -- e.g. 'v1'
created_at timestamptz NOT NULL
);
Each [Encrypted] business column is text (it holds the ASCII hxdp: blob — not bytea). If an entity
implements IEncryptedEntity (for the future 1.9 re-encrypt sweep), also add a nullable dek_reconciled_version text
column. Do not put an index / WHERE / ORDER BY / FK on an [Encrypted] column (see the ops runbook).
Health check
AddHexaSyncDataProtectionHealthCheck() reports Unhealthy until the keyring warms (IsInitialized) — wire it to
readiness so a pod that can't reach the KEK at boot doesn't take traffic. When AddKeyringMaintenance is wired, an
overdue rotation (age > MaxDekAge + OverdueGrace) reports Degraded (never Unhealthy — the pod still serves).
Without AddKeyringMaintenance there is no overdue check at all — a stuck rotation is invisible to health.
Migrating off a legacy format
The library is hxdp:-only; migrating legacy AES-CBC (or any prior format) is the consumer's job. Read the
raw legacy value with your own code (raw SQL, or a temporary non-[Encrypted] mapping — you can't read it
through the [Encrypted] property, which would try to decrypt it as hxdp: and fail closed), decrypt it with your
legacy code, then write the plaintext through the [Encrypted] property so the converter re-encrypts it on save.
Use HxdpEnvelope.IsProtected on the raw value to skip already-migrated rows:
foreach (var (id, raw) in ReadRawLegacyColumn()) // your code — bypasses the [Encrypted] converter
{
if (HxdpEnvelope.IsProtected(raw)) continue; // already "hxdp:" → migrated; leave it
// Attach a stub and mark ONLY the [Encrypted] property modified. This emits an UPDATE that encrypts the
// assigned plaintext — WITHOUT a prior SELECT, so the legacy value is never read through the converter
// (which would fail closed). Do NOT db.Find/materialize the row here.
var stub = new UserSecret { Id = id };
db.Attach(stub);
stub.TotpSeed = DecryptLegacy(raw); // plaintext in → converter encrypts on save
}
db.SaveChanges();
Non-EF callers use IDataProtector directly — note it is bytes-in / bytes-out (the result is the ASCII hxdp:
blob): var blob = Encoding.ASCII.GetString(await protector.EncryptAsync(Encoding.UTF8.GetBytes(plaintext)));.
No-break rollout checklist
The failure mode of any migration is a pod (or a restored backup) that can't read what another pod already wrote. Order the rollout so that can't happen:
- Additive schema first. Add the new
[Encrypted]/v2 column; never repurpose the legacy column in the same deploy — legacy readers keep working. - Decrypt-before-encrypt. Deploy the version that can decrypt
hxdp:to 100% of replicas before any version writeshxdp:. For the length of a rolling update the old and new versions are both Ready and serving; the old pod must not choke on a blob the new pod just wrote. (A config-flag cutover is the same — the flip lands pod-by-pod.) - Backfill idempotently. Skip already-migrated rows with
HxdpEnvelope.IsProtected(raw); the job must be safe to re-run and to interrupt mid-flight. - Drop the legacy column only after prod backfill is confirmed — production-gated, a separate later deploy, never bundled with the write cutover.
For a KEK provider cutover (Local ↔ Azure), the same discipline is re-wrap-before-drop — see the next section.
Sensitive-data-logging prohibition
EnableSensitiveDataLogging would write decrypted plaintext + key material to logs. Call
context.WarnIfSensitiveDataLoggingWithEncryptedProperties(logger) at startup — it logs a warning if the trap is on
for a context with [Encrypted] properties. (It warns; it does not block.)
Observability
Metrics ship on a Meter named HexaSync.DataProtection (tag-free counters: encrypt/decrypt/decrypt_miss/
rotation/rewrap/kek_unwrap; gauges: active-DEK nonce consumption + age). Subscribe your OpenTelemetry exporter to
that meter name. For per-event auditing (rotate / re-wrap / destroy / decrypt-miss / kek-version-change), implement
IDataProtectionAuditSink and register it before AddHexaSyncDataProtection.
Crypto inventory
EncryptedFieldInventory.ToMarkdown(ctx.Model) renders the declared [Encrypted] fields as a Markdown table for a
CRYPTO-INVENTORY.md artifact (emit it at boot or from a CLI dump mode). Schema metadata only — never key material.
Name note: IDataProtector
This library's hexasync.dataprotection.IDataProtector shares its simple name with
Microsoft.AspNetCore.DataProtection.IDataProtector. If a consumer uses both, namespace-qualify or alias
(using IHxDataProtector = hexasync.dataprotection.IDataProtector;). They are unrelated types.
Multiple KEK providers & seamless Local→Azure cutover
Both KEK providers can be registered at once (a Local master and AddAzureKeyVaultKeyWrapper). They compose
behind a single IKeyWrapper:
- Wrap (new + re-wrapped DEKs) always uses the active provider (
DATA_PROTECTION_KEK_ACTIVE). - Unwrap tries the active provider first; on a wrong-key/wrong-format failure it falls back to the other provider (so a DEK wrapped by either is always decryptable through a migration). A transient failure (KV throttle/timeout/5xx) retries the active provider and never falls through — an outage is never masked. A KEK outage degrades rotation, not decryption of already-cached DEKs.
Cutover (Local → Azure), reversible until the last step:
// 0. Register BOTH: Local master present + AddAzureKeyVaultKeyWrapper(...); DATA_PROTECTION_KEK_ACTIVE=Local.
// 1. Prove Azure works while Local is still active — the self-test endpoint (below), green across the fleet.
// 2. Flip DATA_PROTECTION_KEK_ACTIVE=AzureKeyVault → new wraps use Azure; existing DEKs still unwrap via Local.
// 3. Re-wrap the existing DEK(s) onto the active provider (Local → Azure), no data re-encryption:
await provider.ReWrapAllToActiveAsync(ct); // provider = the resolved KeyringDekProvider
// 4. Soak. Rollback (before step 5) = flip active back + ReWrapAllToActiveAsync again.
// 5. Drop Local ONLY after the fail-closed gate passes (else a still-Local-wrapped DEK becomes unrecoverable):
await provider.AssertNoneWrappedByAsync("Local", ct); // throws if any DEK is still Local-wrapped
// → then remove DATA_PROTECTION_MASTER_KEY_BASE64.
Both providers stay registered throughout, so a pod restart mid-cutover always unwraps. The gate classifies each DEK's
actual recorded kek_id (not the literal "local"), so a custom DATA_PROTECTION_KEK_ID still gates correctly.
KEK self-test endpoint (hexasync.dataprotection.endpoints)
An admin can confirm a named provider's full path (auth/network/RBAC/wrap/unwrap) on demand — e.g. prove Azure works while Local is still active — without touching the keyring or real data:
builder.Services.AddDataProtectionSelfTestRateLimiter(); // fixed-window, 5/min per caller
app.UseRateLimiter();
// Authorization is CONSUMER-supplied via the configure callback — this package depends on no auth middleware
// (it is upstream of every consumer), so you apply your own capability gate here:
app.MapDataProtectionSelfTest("/api/v3/admin/dataprotection/self-test",
endpoint => endpoint.RequireCapability(DataProtectionCapabilities.KekSelfTest));
// POST .../self-test?provider=AzureKeyVault → { provider, ok, kekIdFingerprint, latencyMs, errorKind? }
DataProtectionCapabilities.KekSelfTest ("dataprotection:kek:self_test") is a library-owned string constant — mirror
it into your capability catalog and grant it to your admin/super-admin role. The response returns a fingerprint of
the kek_id (never the vault URI) and a classified errorKind (never raw provider exception text). The self-test
calls the adapter directly — it writes nothing to the keyring.
Operating the key lifecycle — every lever, and how to smoke-test it
Everything below is opt-in. Wire the endpoints you want; a surface you do not map does not exist. Every endpoint
takes a required configure callback where you apply your own capability gate — this package depends on no auth
middleware.
The full surface
| Ability | HTTP | Programmatic | Capability |
|---|---|---|---|
| Prove a KEK provider works end to end (auth, network, RBAC, wrap→unwrap) without touching the keyring | POST …/self-test?provider=Local\|AzureKeyVault |
IKekSelfTest.SelfTestAsync |
dataprotection:kek:self_test |
| Are all DEKs on the current KEK, or is a re-wrap outstanding? | GET …/kek-status |
IKekRotationStatus.GetStatusAsync |
dataprotection:kek:read |
| Which tables still hold rows on an older DEK? | GET …/dek-status |
IDekRotationStatus |
dataprotection:dek:read |
| Rotate the DEK now (new generation; previous retired and retained for decrypt) | POST …/dek/rotate |
KeyringDekProvider.RotateAsync |
dataprotection:dek:rotate |
| Re-wrap every DEK onto the current KEK (version bump or a local master swap) | — | ReWrapAllAsync |
— |
| Migrate DEKs onto the active provider (Local↔Azure) | — | ReWrapAllToActiveAsync |
— |
| Prove a KEK is safe to drop — fail-closed | — | AssertNoneWrappedByAsync(token) / CountWrappedByAsync |
— |
| Re-encrypt rows onto the newest DEK so an old generation can be destroyed | — | AddDekForceReconcile |
— |
| Recover a soft-deleted DEK inside its window | — | DekDestroyer.RestoreAsync(keyId) |
— |
| Is rotation overdue? | health endpoint | AddHexaSyncDataProtectionHealthCheck |
— |
Automatic, once AddKeyringMaintenance is wired: bootstrap the first DEK, rotate at MaxDekAge, and re-wrap onto the
current KEK — all leader-elected. The manual levers exist for the cases a timer cannot serve: a suspected exposure, a
rehearsal, a cutover you want logged and watched.
Smoke-testing the whole lifecycle locally
Run this against a local instance with Local custody — no vault needed. It exercises both lifecycles end to end;
each step has an observable result, so a failure tells you which mechanism broke.
BASE=http://localhost:5000/api/v3/admin/dataprotection
AUTH="Authorization: Bearer $TOKEN" # a principal holding the four capabilities above
# 1. KEK reachable? (no keyring writes, no real data)
curl -sX POST "$BASE/self-test?provider=Local" -H "$AUTH" # → { ok: true, kekIdFingerprint, latencyMs }
# 2. Keyring bootstrapped at boot: exactly one active DEK, everything on the current KEK
curl -s "$BASE/kek-status" -H "$AUTH" # → stale: 0, byKek: [ { isCurrent: true, … } ]
# 3. Encrypt something through the app, then read the column: it must start `hxdp:v1.<key_id>.`
# — that key_id is the generation, in the clear, by design.
# 4. Rotate the DEK. New generation; the previous is RETIRED and retained, so step-3 data still decrypts.
curl -sX POST "$BASE/dek/rotate" -H "$AUTH" # → { keyId: "<new>" }
# Re-read the step-3 row (still decrypts, still its old key_id) and write a new row (new key_id).
# 5. Which tables still sit on the old generation?
curl -s "$BASE/dek-status" -H "$AUTH"
Then the KEK half — swap the local master, which is the part with a real failure mode:
# 6. Set DATA_PROTECTION_MASTER_KEY_BASE64_OLD=<current>, DATA_PROTECTION_MASTER_KEY_BASE64=<new>. Restart.
curl -s "$BASE/kek-status" -H "$AUTH" # → every DEK still readable, now reported against TWO kek_ids
# 7. Let maintenance run (or call ReWrapAllAsync). Re-check: one kek_id, stale 0.
# 8. Prove the old master is unused BEFORE deleting it:
# AssertNoneWrappedByAsync("LocalPrevious") — throws if any DEK still needs it.
# 9. Only now remove DATA_PROTECTION_MASTER_KEY_BASE64_OLD and restart.
Step 8 is the one step you cannot skip: dropping a master while a live DEK is still wrapped by it is unrecoverable. The gate is fail-closed for that reason.
What this does not cover. Destruction is deliberately slow and cannot be smoke-tested in one sitting — a retired DEK waits out a quarantine window, then a recoverable soft-delete window, with a fresh drain-veto re-checked at the shred instant. Rehearse it with shortened windows in a scratch environment, never by shortening them in production.
Keep this as a checklist you run by hand against a local instance. It is not a CI job: it asserts operator-visible behaviour of a running deployment, and a green run in a container proves nothing about a cluster.
Operations
KEK-retention, compromise/rotation, encrypted-column rules, and the KV rotation burst budget are in the ops runbook:
docs/data-protection/OPS_RUNBOOK.md. The KEK-retention rule is data-loss-class — read it before rotating a KEK in
production.
Showing the top 20 packages that depend on hexasync.dataprotection.
| Packages | Downloads |
|---|---|
|
hexasync.dataprotection.azurekeyvault
Azure Key Vault KEK custody adapter for hexasync.dataprotection. Wraps/unwraps DEKs via KV (KEK never leaves the vault), secretless auth, version-pinned. The Azure SDK is isolated to this package.
|
43 |
|
hexasync.dataprotection.endpoints
ASP.NET Core admin endpoints for hexasync.dataprotection — the KEK self-test. The ASP.NET dependency is isolated to this package (the core crypto package stays HTTP-free); authorization is consumer-supplied via a configure callback, so this package depends on no auth middleware.
|
30 |
|
hexasync.dataprotection.endpoints
ASP.NET Core admin endpoints for hexasync.dataprotection — the KEK self-test. The ASP.NET dependency is isolated to this package (the core crypto package stays HTTP-free); authorization is consumer-supplied via a configure callback, so this package depends on no auth middleware.
|
27 |
|
hexasync.dataprotection.azurekeyvault
Azure Key Vault KEK custody adapter for hexasync.dataprotection. Wraps/unwraps DEKs via KV (KEK never leaves the vault), secretless auth, version-pinned. The Azure SDK is isolated to this package.
|
26 |
|
hexasync.dataprotection.azurekeyvault
Azure Key Vault KEK custody adapter for hexasync.dataprotection. Wraps/unwraps DEKs via KV (KEK never leaves the vault), secretless auth, version-pinned. The Azure SDK is isolated to this package.
|
24 |
|
hexasync.dataprotection.endpoints
ASP.NET Core admin endpoints for hexasync.dataprotection — the KEK self-test. The ASP.NET dependency is isolated to this package (the core crypto package stays HTTP-free); authorization is consumer-supplied via a configure callback, so this package depends on no auth middleware.
|
24 |
|
hexasync.dataprotection.azurekeyvault
Azure Key Vault KEK custody adapter for hexasync.dataprotection. Wraps/unwraps DEKs via KV (KEK never leaves the vault), secretless auth, version-pinned. The Azure SDK is isolated to this package.
|
19 |
|
hexasync.dataprotection.endpoints
ASP.NET Core admin endpoints for hexasync.dataprotection — the KEK self-test. The ASP.NET dependency is isolated to this package (the core crypto package stays HTTP-free); authorization is consumer-supplied via a configure callback, so this package depends on no auth middleware.
|
19 |
|
hexasync.dataprotection.azurekeyvault
Azure Key Vault KEK custody adapter for hexasync.dataprotection. Wraps/unwraps DEKs via KV (KEK never leaves the vault), secretless auth, version-pinned. The Azure SDK is isolated to this package.
|
15 |
|
hexasync.dataprotection.endpoints
ASP.NET Core admin endpoints for hexasync.dataprotection — the KEK self-test. The ASP.NET dependency is isolated to this package (the core crypto package stays HTTP-free); authorization is consumer-supplied via a configure callback, so this package depends on no auth middleware.
|
15 |
|
hexasync.dataprotection.azurekeyvault
Azure Key Vault KEK custody adapter for hexasync.dataprotection. Wraps/unwraps DEKs via KV (KEK never leaves the vault), secretless auth, version-pinned. The Azure SDK is isolated to this package.
|
12 |
|
hexasync.dataprotection.endpoints
ASP.NET Core admin endpoints for hexasync.dataprotection — the KEK self-test. The ASP.NET dependency is isolated to this package (the core crypto package stays HTTP-free); authorization is consumer-supplied via a configure callback, so this package depends on no auth middleware.
|
12 |
|
hexasync.dataprotection.azurekeyvault
Azure Key Vault KEK custody adapter for hexasync.dataprotection. Wraps/unwraps DEKs via KV (KEK never leaves the vault), secretless auth, version-pinned. The Azure SDK is isolated to this package.
|
10 |
|
hexasync.dataprotection.azurekeyvault
Azure Key Vault KEK custody adapter for hexasync.dataprotection. Wraps/unwraps DEKs via KV (KEK never leaves the vault), secretless auth, version-pinned. The Azure SDK is isolated to this package.
|
8 |
|
hexasync.dataprotection.endpoints
ASP.NET Core admin endpoints for hexasync.dataprotection — the KEK self-test. The ASP.NET dependency is isolated to this package (the core crypto package stays HTTP-free); authorization is consumer-supplied via a configure callback, so this package depends on no auth middleware.
|
8 |
|
hexasync.dataprotection.azurekeyvault
Azure Key Vault KEK custody adapter for hexasync.dataprotection. Wraps/unwraps DEKs via KV (KEK never leaves the vault), secretless auth, version-pinned. The Azure SDK is isolated to this package.
|
6 |
.NET 10.0
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.9)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.9)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.9)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
| Version | Downloads | Last updated |
|---|---|---|
| 1.10.62 | 16 | 08/26/2026 |
| 1.10.61 | 56 | 08/12/2026 |
| 1.10.60 | 10 | 08/11/2026 |
| 1.10.59 | 0 | 08/10/2026 |
| 1.10.58 | 9 | 08/08/2026 |
| 1.10.57 | 19 | 08/05/2026 |
| 1.10.56 | 0 | 08/04/2026 |
| 1.10.55 | 10 | 08/03/2026 |
| 1.10.54 | 0 | 08/03/2026 |
| 1.10.54-pre | 0 | 08/01/2026 |
| 1.10.53 | 6 | 07/31/2026 |
| 1.10.49 | 1 | 07/29/2026 |
| 1.10.48 | 4 | 07/28/2026 |
| 1.10.47 | 12 | 07/27/2026 |
| 1.10.46 | 0 | 07/25/2026 |
| 1.10.45 | 0 | 07/25/2026 |
| 1.10.44 | 27 | 07/22/2026 |
| 1.10.43 | 4 | 07/21/2026 |
| 1.10.42 | 0 | 07/21/2026 |
| 1.10.41 | 0 | 07/21/2026 |
| 1.10.40 | 0 | 07/20/2026 |
| 1.10.39 | 11 | 07/15/2026 |
| 1.10.38 | 0 | 07/14/2026 |
| 1.10.38-pre.1 | 0 | 07/14/2026 |
| 1.10.37 | 0 | 07/13/2026 |
| 1.10.36 | 24 | 07/13/2026 |
| 1.10.35 | 0 | 07/10/2026 |
| 1.10.35-pre.13 | 0 | 07/10/2026 |
| 1.10.35-pre.8 | 6 | 07/09/2026 |
| 1.10.35-pre.7 | 2 | 07/08/2026 |