New Deterministic queryable ciphertext, scheduled compliance reports, and environment-aware licence enforcement. See what's included →

Documentation

Complete guide to configuring field-level encryption, ML PII scanning, Crypto Sentinel exfiltration alarms, blind index search, and compliance evidence reports.

Install

$ dotnet add package EntityFrameworkCore.Crypto.DataEncryption.ML

Targets net8.0 / net9.0 / net10.0, same as the core package.

Quickstart

Register the service, configure SMTP alerts, and wire Sentinel into EF Core in one call:

builder.Services.AddDataEncryptionML(options =>
{
    options.LicenseKey = builder.Configuration["EfCryptoML:LicenseKey"];

    options.Email = new SmtpEmailOptions
    {
        Host     = builder.Configuration["Smtp:Host"]!,
        Port     = 587,
        Username = builder.Configuration["Smtp:User"],
        Password = builder.Configuration["Smtp:Pass"],
        From     = "security@yourapp.example",
        To       = { "dpo@yourapp.example", "oncall@yourapp.example" },
        SubjectPrefix = "[yourapp/prod]",
    };

    options.Sentinel.MinEventsForAlert = 100;
    options.Alerts.DigestWindow = TimeSpan.FromMinutes(15);
});

// Scan on startup:
builder.Services.AddStartupSensitiveDataScan<AppDbContext>();

// Wire Sentinel into EF:
builder.Services.AddDbContext<AppDbContext>((sp, o) => o
    .UseSqlServer(connectionString)
    .UseCryptoSentinel(sp));

Inside your DbContext, instrument the provider so operations are counted:

public class AppDbContext(
    DbContextOptions<AppDbContext> options,
    ICryptoOperationMonitor monitor) : DbContext(options)
{
    private static readonly AesGcmCryptoProvider Provider = new(LoadKeyFromKeyVault());

    protected override void OnModelCreating(ModelBuilder modelBuilder)
        => modelBuilder.UseEncryption(monitor.Instrument(Provider));
}
appsettings.json — bind secrets from env vars or Key Vault:
{
  "Smtp": { "Host": "smtp.office365.com", "User": "alerts@yourapp.example", "Pass": "" },
  "EfCryptoML": { "LicenseKey": "" }
}

Two Encryption Modes

Manual mode

Nothing encrypts unless you mark it with [CryptoEncrypted] or .IsEncrypted(). Full control, zero surprises.

Manual — attribute or fluent

// Attribute style:
public class Customer
{
    [CryptoEncrypted]
    public string AadhaarNumber { get; set; }

    [CryptoEncrypted(CryptoStorageFormat.Binary)]
    public string PanNumber { get; set; }
}

// Fluent style (inside OnModelCreating):
modelBuilder.Entity<Customer>()
    .Property(c => c.MobileNumber)
    .IsEncrypted();

// Activation — LAST line of OnModelCreating:
modelBuilder.UseEncryption(monitor.Instrument(Provider));

Auto mode — secure by default

modelBuilder.UseAutoEncryption(monitor.Instrument(Provider));

// Opt extra kinds in deliberately:
options.AutoEncrypt.Kinds.Add(PiiKind.PersonName);
options.AutoEncrypt.MinNameConfidence = 0.8;

Per-field opt-out

// Plaintext with a logged reason:
[DoNotEncrypt(Reason = "search screen filters on this column")]
public string LastName { get; set; }

// Plaintext AND silenced in scans:
[DoNotEncrypt, ScanExempt("Search requirement; DLP monitored",
    ApprovedBy = "CISO, 2026-07-01")]
public string City { get; set; }
Brownfield databases: set options.AutoEncrypt.DryRun = true first and review what would change. Existing plaintext rows need a one-time backfill before enabling for real.

What the scanner detects

The scanner walks your DbContext model and samples live data. Detectors include:

GlobalIBAN (70+ countries, mod-97), Payment cards (Luhn), US SSN, Email, E.164 phones, Ethereum addresses, Crypto wallets (Base58Check + Bech32)
IndiaAadhaar (Verhoeff), PAN, IFSC, UPI VPA
ML classifierNames (first/middle/last), addresses, free-text PII — trained on first use, cached
Column vocabularyNINO, SIN, TFN, NRIC, CPF, CURP, DNI, PESEL, passport, routing/sort code, BSB, SWIFT, PayPal, Paytm, PhonePe…

Explicit declaration

public class Employee
{
    [CryptoEncrypted, SensitiveData(PiiKind.BankAccount)]
    public string SalaryAccountNumber { get; set; }  // declared AND protected ✅

    [SensitiveData(PiiKind.MonetaryAmount)]
    public string MonthlyCtc { get; set; }           // declared but unencrypted ⇒ Critical
}

Custom detection rules

options.Detection.NameTokens["employeecode"] = new(PiiKind.FreeTextPii, 0.9);

options.Detection.ValueRules.Add(new CustomValueRule
{
    Name         = "EmployeeId",
    Kind         = PiiKind.FreeTextPii,
    Pattern      = @"^EMP-\d{6}$",
    Confidence   = 0.95,
    Deterministic = true,
});

Scan exemptions

// Documented risk acceptance (recommended):
[ScanExempt("Reconciliation export; DLP monitored",
    ApprovedBy = "CISO, 2026-07-01")]
public string PayerName { get; set; }

// Silent skip (for test tables / framework columns):
options.Scan.ExcludedEntities.Add("ImportStagingRow");
options.Scan.ExcludedProperties.Add("Order.TrackingRef");

How Crypto Sentinel works

Sentinel streams decrypt/materialization telemetry into ML.NET SSA spike detection. A service normally decrypting 40 rows/s suddenly doing 5,000/s raises an alert in seconds — without a single rule to write.

1EF Core materializes encrypted values → ICryptoOperationMonitor counts each event
2Lock-free bounded channel buffers telemetry (drop-on-full — never slows queries)
3SSA spike detector compares current rate to rolling baseline
4Alert fires via email / webhook / ILogger
// Wire Sentinel into EF:
builder.Services.AddDbContext<AppDbContext>((sp, o) => o
    .UseSqlServer(connectionString)
    .UseCryptoSentinel(sp));

// Tune thresholds:
options.Sentinel.MinEventsForAlert   = 100;  // warm-up count before SSA fires
options.Sentinel.HardRateLimitPerInterval = 500; // absolute guard, works from interval 1
options.Sentinel.TrainingWindow      = 120;  // ≈ 2 min at 1s sampling
Warm-up note: SSA needs TrainingWindow intervals (default 120 ≈ 2 minutes) before statistical alerts fire. Set HardRateLimitPerInterval for an absolute guard that works from the very first interval.

Why blind indexes?

AES-GCM uses a random nonce per value — identical plaintexts produce different ciphertexts. Great for security, but WHERE Email = @x can never match server-side. Blind indexes restore equality lookups with a keyed HMAC-SHA256 stored in an auto-indexed shadow column.

// 1. One dedicated key (NOT the encryption key):
var hasher = new BlindIndexHasher(blindIndexKey);

// 2. Configure the property:
modelBuilder.Entity<User>()
    .Property(u => u.Email)
    .IsEncrypted()
    .HasBlindIndex();  // caseInsensitive: true by default

// 3. Register the sync interceptor:
optionsBuilder.AddInterceptors(new BlindIndexInterceptor(hasher));

// 4. Query — index seek, not a scan:
var user = await context.Users
    .WhereBlindEquals(nameof(User.Email), email, hasher)
    .FirstOrDefaultAsync();

After configuring, add a migration and backfill hashes for existing rows:

$ dotnet ef migrations add AddBlindIndexes
Limitation: equality and joins only — no LIKE/range queries. That's inherent to hashing. The hash reveals nothing without the key.

Queryable ciphertext, without a shadow column

Deterministic mode makes the same plaintext produce the same ciphertext, so ordinary LINQ equality translates straight to SQL — an index seek on the encrypted column itself. It's the alternative to a blind index when you'd rather not add a second column and a second key.

// Randomized (default) — strongest, not queryable:
var provider = new AesGcmCryptoProvider(key);

// Deterministic — equality works server-side:
var provider = new AesGcmCryptoProvider(key, deterministic: true);

How it stays safe

The nonce is derived from the value itself — a synthetic IV, the construction AES-GCM-SIV uses — keyed with a value derived from your encryption key. Fixing one nonce across different plaintexts would leak the authentication subkey and allow forgery; deriving it per value means two different values never share a nonce, and the auth tag still verifies.

What it costs you, precisely: equal rows are visibly equal to anyone who can read the table, with no key at all. On a low-cardinality column — city, status, gender — frequency analysis usually recovers the plaintext outright. On a unique identifier, someone can confirm a guess by encrypting a candidate and looking for the ciphertext. Use it on the columns you must filter on; leave the rest randomized.

Choosing between the three

Approach == value in SQL What it reveals
Randomized AES-GCM (default) No Nothing beyond length
Blind index Yes — index seek Equality, confined to a separate keyed column
Deterministic AES-GCM Yes — index seek Equality, on the column itself
Both modes read each other's rows, so a column can move between randomized and deterministic in place — no migration, no downtime. Full write-up on the security design page.

What the report contains

  • Encrypted-property inventory with coverage %
  • All scan findings and their severity
  • Full alert history
  • Accepted risks / [ScanExempt] sign-offs with approver & date
  • Key management narrative you provide

Output formats: JSON (machine-readable) + self-contained HTML (print-to-PDF ready). Designed as evidence for DPDP Act 2023 / GDPR Art. 32 audits.

ScanReport scan = await scanner.ScanAsync(dbContext);

ComplianceReport report = generator.Generate(
    dbContext,
    "AES-256-GCM, random 96-bit nonce per value, key ring rotation",
    scan);

File.WriteAllText("compliance.html", generator.ToHtml(report));
File.WriteAllText("compliance.json", generator.ToJson(report));
Enterprise tier only. Calling ComplianceReportGenerator.Generate without a valid Enterprise license throws LicenseFeatureException — no silent partial output.

The report generates and emails itself

Set a cadence and the evidence pack arrives on its own, with both formats attached. Recipients default to a separate list from your alerts — the report names every unprotected column in your schema, so it belongs with compliance rather than the on-call rota.

{
  "EfCryptoML": {
    "Report": {
      "Enabled":     true,
      "Cadence":     "Weekly",       // Daily | Weekly | Monthly | Custom
      "DayOfWeek":   "Monday",       // Weekly
      "DayOfMonth":  1,              // Monthly (1–28)
      "TimeOfDay":   "09:00:00",
      "TimeZoneId":  "Asia/Kolkata", // omit for UTC, or "Local"
      "Recipients":  [ "dpo@acme.example", "audit@acme.example" ]
    }
  }
}

Unconfigured, it runs monthly on the 1st at 06:00 UTC. Custom uses a plain interval — 30 days by default.

Operational guarantees

  • A missed window isn't lost. Each run is recorded in a ledger; a window missed while the process was down is sent late rather than skipped.
  • One report, not one per replica. Instances sharing a volume claim the run atomically, so three pods send one report between them.
  • A failed send releases the claim, so it retries instead of consuming the month.
  • Recipients fall back to Email.To when Report.Recipients is empty; with neither set, configuration validation fails at startup.

Or generate one on demand

ScanReport scan = await scanner.ScanAsync(context);

ComplianceReport report = generator.Generate(
    context,
    "AES-GCM 256-bit, keys in Azure Key Vault, rotated 2026-06-01",
    scan,
    recentAlerts);

await File.WriteAllTextAsync("compliance.html", generator.ToHtml(report));
await File.WriteAllTextAsync("compliance.json", generator.ToJson(report));
Wire the JSON output into CI and fail the build when CoveragePercent drops. See a rendered report and the delivery email on the compliance reports page.

Offline validation

A licence key is a signed token — RLML1.<payload>.<sig> — verified with ECDSA P-256 against a public key embedded in the package. There is no activation server, no machine fingerprinting and no telemetry, so an air-gapped deployment behaves identically to a connected one.

builder.Services.AddDataEncryptionML(options =>
{
    options.LicenseKey = builder.Configuration["EfCryptoML:LicenseKey"];

    // Auto (default) | Strict | WarnOnly
    options.License.EnforcementMode = LicenseEnforcementMode.Auto;
});

What each mode does where

Mode Local development Container / Kubernetes / managed cloud
Auto (default) Warns once, degrades to Community Startup fails
Strict Startup fails Startup fails
WarnOnly Warns once Warns once

Environment detection reads environment variables and container markers only — KUBERNETES_SERVICE_HOST, the k8s service-account token, DOTNET_RUNNING_IN_CONTAINER, /.dockerenv, container cgroups, and the Azure / AWS / GCP / Heroku variables. No metadata endpoint is contacted. Override it with options.License.TreatAsProduction.

Escape hatch: EFCRYPTOML_LICENSE_ENFORCEMENT=WarnOnly unblocks an incident without a redeploy. It is logged every time, and you can disable it with AllowEnvironmentOverride = false.
Expiry never risks your data. Whatever the mode, every encrypted value stays readable with your key — in every tier, forever. You also get a warning alert once a valid licence has 14 days or fewer remaining.

Your SMTP, your credentials

The package never routes mail through vendor infrastructure. You supply SMTP settings; alerts go out under your identity.

Provider Host Port Notes
Microsoft 365 smtp.office365.com 587 Enable SMTP AUTH for the mailbox
Gmail / Workspace smtp.gmail.com 587 Use an App Password
Amazon SES email-smtp.<region>.amazonaws.com 587 SES SMTP credentials, verify From domain
Zoho smtp.zoho.in 587 India DC; use your region's host

Delivery guarantees

  • Retried on failure: 1 s → 5 s → 30 s
  • Dead-lettered and logged after all retries — never loses the alert silently
  • Critical alerts bypass the digest window and send immediately
  • Digest batching: configurable via options.Alerts.DigestWindow
options.Email = new SmtpEmailOptions
{
    Host          = "smtp.office365.com",
    Port          = 587,
    Username      = configuration["Smtp:User"],
    Password      = configuration["Smtp:Pass"],
    From          = "security@yourapp.example",
    To            = { "dpo@yourapp.example" },
    SubjectPrefix = "[yourapp/prod]",
};

Honest performance numbers

Encryption happens in-process inside EF value converters — the database never sees plaintext or keys.

Path Cost At scale
Encrypt (write) ~1–3 µs + 1 alloc per value (AES-NI) 100k-row bulk insert × 3 encrypted cols ≈ 0.3–1 s CPU
Decrypt (read) ~1–2 µs per materialized value 10k rows × 3 cols ≈ 30–90 ms added
Projections skipping encrypted cols Zero Converters only run for selected properties
Sentinel telemetry One lock-free channel write (~tens of ns) Never blocks or slows the query path
Startup scan Take(SampleSize) per column O(1) w.r.t. table size — same cost on 1k or 500M rows
Storage overhead +30 B envelope; Base64 ×1.33 100-char value → ~175 chars stored
One real cliff: server-side lookups on encrypted columns become full table scans. Solve with Blind Indexes.

Run benchmarks on your own hardware:

$ dotnet run -c Release --project benchmarks/EntityFrameworkCore.Crypto.DataEncryption.Benchmarks