Complete guide to configuring field-level encryption, ML PII scanning, Crypto Sentinel exfiltration alarms, blind index search, and compliance evidence reports.
Targets net8.0 / net9.0 / net10.0, same as the core
package.
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));
}
{
"Smtp": { "Host": "smtp.office365.com", "User": "alerts@yourapp.example", "Pass": "" },
"EfCryptoML": { "LicenseKey": "" }
}
Nothing encrypts unless you mark it with [CryptoEncrypted] or .IsEncrypted().
Full control, zero surprises.
Bank/PII columns encrypt by convention. Staying plaintext becomes the deliberate per-field opt-out.
// 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));
modelBuilder.UseAutoEncryption(monitor.Instrument(Provider));
// Opt extra kinds in deliberately:
options.AutoEncrypt.Kinds.Add(PiiKind.PersonName);
options.AutoEncrypt.MinNameConfidence = 0.8;
// 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; }
options.AutoEncrypt.DryRun = true first and review
what would change. Existing plaintext rows need a one-time backfill before enabling for real.
The scanner walks your DbContext model and samples live data. Detectors include:
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
}
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,
});
// 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");
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.
ICryptoOperationMonitor counts each eventILogger// 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
TrainingWindow intervals (default 120 ≈ 2 minutes)
before statistical alerts fire. Set HardRateLimitPerInterval for an absolute guard that works
from the very first interval.
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:
LIKE/range queries. That's inherent
to hashing. The hash reveals nothing without the key.
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);
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.
| 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 |
[ScanExempt] sign-offs with approver & dateOutput 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));
ComplianceReportGenerator.Generate without a
valid Enterprise license throws LicenseFeatureException — no silent partial output.
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.
Email.To when Report.Recipients is empty; with neither
set, configuration validation fails at startup.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));
CoveragePercent drops. See a rendered
report and the delivery email on the compliance reports page.
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;
});
| 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.
EFCRYPTOML_LICENSE_ENFORCEMENT=WarnOnly unblocks an incident
without a redeploy. It is logged every time, and you can disable it with
AllowEnvironmentOverride = false.
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 |
options.Alerts.DigestWindowoptions.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]",
};
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 |
Run benchmarks on your own hardware: