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

Your keys never leave your process. Your database never sees them.

Encryption happens in the EF value converter, inside your application. The database stores ciphertext it cannot read, and the library makes no network calls of any kind — no phone-home, no telemetry, no key escrow, no vendor infrastructure in the path.

AES-GCM authenticated Nonce per value Key rotation built in Offline always
The primitive

Authenticated encryption, not just encryption.

AES-GCM produces a 128-bit authentication tag alongside the ciphertext. Tampering, bit-flipping, a swapped value from another row, or the wrong key are all detected and raise an error — rather than quietly decrypting into plausible-looking garbage.

Wire format

[version:1][keyId:1][nonce:12][tag:16][ciphertext:n]

Thirty bytes of envelope. The version byte allows the format to evolve without breaking stored data; the key id is what makes rotation possible.

A fresh nonce for every value

Each value gets its own 96-bit random nonce, so encrypting the same Aadhaar number twice produces completely different ciphertext. Without that, anyone reading the table learns which rows share a value — before breaking anything at all.

Nonces are drawn from the system CSPRNG in blocks for speed. Same source, same distribution, same collision bounds.

Losing the key means losing the data. There is no recovery path and no back door — that is the property you are buying. Keep keys in a managed KMS with backups and a rotation policy.
Key lifecycle

Rotate keys without a maintenance window.

Every encrypted value carries the id of the key that produced it. Register the new key as primary, keep the old one for reading, and re-encrypt at whatever pace suits you.

var provider = new AesGcmCryptoProvider(
    primaryKey: new AesGcmKey(keyId: 2, currentKey),        // every new write
    decryptionKeys: [new AesGcmKey(1, previousKey)]);       // old rows still readable

Re-encrypt at leisure

Touching a value rewrites it through the converter under the new key. Sweep in batches, on your schedule, then retire the old key from the ring.

Bind data to a tenant

Optional associated data is mixed into every authentication tag. Values encrypted under one tenant identifier cannot be decrypted under another, even with the right key.

Migrate from AES-CBC

MigrationCryptoProvider writes GCM while still reading legacy CBC rows. The auth tag makes mis-routing cryptographically impossible, so the bridge is safe to run in production.

The honest part

Encrypted columns and queries: pick your trade-off deliberately.

Randomized encryption is the strongest option and the least queryable. There is no scheme that gives you both — anything that lets the database match on a value necessarily reveals that two rows match. Here is the whole menu.

Approach == value in SQL Tamper-evident What it reveals
Randomized AES-GCM
the default
No Yes Nothing beyond length.
Blind index
recommended for lookups
Yes — index seek Yes Equality, confined to a separate keyed column.
Deterministic AES-GCM Yes — index seek Yes Equality, on the column itself.
Leave it plaintext Yes, plus LIKE and sorting No Everything, to anyone with database access.

Blind index — the usual answer

A keyed HMAC of the value in an indexed shadow column. The sensitive column stays randomized; only the derived column is comparable, and attacking it requires a second key you keep separately.

// model
modelBuilder.Entity<User>().Property(u => u.Email).HasBlindIndex();

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

Deterministic mode — and how it's built

Same plaintext, same ciphertext, so ordinary LINQ equality translates to SQL. 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 a GCM nonce across different plaintexts would leak the authentication subkey and permit forgery. Deriving it per value means two different values never share a nonce, so that attack does not arise, and the tag still authenticates.

new AesGcmCryptoProvider(key, deterministic: true);
What deterministic mode costs you, precisely. Equal rows are visibly equal to anyone who can read the table, without any key. On a low-cardinality column — city, status, gender — frequency analysis usually recovers the plaintext outright. On a unique identifier it lets someone 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. Both modes read each other's rows, so a column can move between them in place.
Nothing gives you LIKE, sorting or ranges. Ciphertext preserves neither prefixes nor order. If a column needs those, it needs to be plaintext and protected another way.
Operational posture

What the library does, and refuses to do.

It never handles your keys

There is no code path that encrypts without a provider you constructed with your key. The GenerateKey() helpers are conveniences the library itself never calls, and nothing is written to disk or transmitted.

It never phones home

Licences are signed keys verified offline with ECDSA P-256 against a public key embedded in the package. No network call, no machine fingerprinting, no usage reporting — an air-gapped deployment behaves identically.

It fails loudly, not silently

Bad configuration throws at startup. An unlicensed production deployment refuses to start rather than quietly dropping paid features. Undeliverable alerts are retried, then dead-lettered and counted in the compliance report.

It keeps plaintext out of the plumbing

The scanner samples column data to classify it but never logs a sampled value — findings carry match rates and evidence descriptions, not the data itself. Alert emails and reports HTML-encode every field they render.

Licence enforcement

Environment-aware, and still entirely offline.

An unlicensed production deploy should be caught at startup, not discovered in an audit. Detection reads environment variables and container markers only — KUBERNETES_SERVICE_HOST, DOTNET_RUNNING_IN_CONTAINER, /.dockerenv, cgroups, and the Azure / AWS / GCP / Heroku variables. No metadata endpoint is contacted, ever.

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

Deterministic startup failure

A hosted service registered first validates the licence, so a bad key aborts host startup before a single request is served.

An audited escape hatch

EFCRYPTOML_LICENSE_ENFORCEMENT=WarnOnly unblocks an incident at 3am. It is logged, and you can switch it off with AllowEnvironmentOverride.

Not DRM, and we say so

The core runs in-process and is bypassable by design. This is startup enforcement plus an audit trail to catch accidental unlicensed deploys — nothing more.

Expiry never risks your data. An expired licence degrades or fails startup depending on mode — but every encrypted value stays readable with your key, in every tier, forever.

Read the code before you trust it.

The cryptography is a few hundred lines and it is worth twenty minutes of your security reviewer's time. That is a better basis for adopting it than any claim on this page.