OrionVault
OrionVault
Column-level transparent data encryption at rest for EF Core. AES-256-GCM, key rotation, searchable blind index, bundled Roslyn analyzer, OpenTelemetry.
What it does
OrionVault encrypts individual EF Core columns at rest. You mark a property with [Encrypted] (or call IsEncrypted() in OnModelCreating); OrionVault wires a value converter that encrypts on the way to the database and decrypts on the way back. The cipher is AES-256-GCM with a key id prefix so you can rotate keys without re-encrypting historical rows up front.
The threat model is narrow and explicit: an attacker who obtains a database backup, dumps the storage volume, or reads a replica's disk cannot read the protected columns without the active key set. Plaintext exists only inside authorized application processes that hold those keys.
This is not full-database TDE. It is not key management. It is the EF Core integration layer that sits on top of System.Security.Cryptography.AesGcm and a pluggable IKeyProvider. The in-config key provider (UseStaticKeys) ships in the box. Key-provider integrations for AWS KMS, Azure Key Vault, GCP KMS, and HashiCorp Vault are implemented as separate OrionVault.* projects in the repository but are not yet published to NuGet; a DPAPI provider remains on the roadmap.
The current release is 0.5.0. Searchable encryption arrived in 0.3.0: a deterministic HMAC-SHA256 blind index (IBlindIndexProvider) computed alongside the randomized ciphertext, so you can run equality search over an encrypted column without decrypting it. See Searchable encrypted columns below.
How it works
EF Core sees the property as string (or byte[]); the storage column is byte[]. A value converter sits between the two and routes through OrionVault's encryptor on write and decryptor on read.
The on-disk layout decoded above is fixed: a two-byte big-endian key id, a 12-byte AES-GCM nonce, a 16-byte authentication tag, then the ciphertext body. The reader pulls the key id first so it can ask the key provider for the exact key that wrote the row, which is what makes online key rotation work.
What's in the box
| Package | Description |
|---|---|
OrionVault | Core: IEncryptor, IKeyProvider, IEncryptionConfigurator, AES-256-GCM cipher, static key provider, searchable blind index (IBlindIndexProvider), telemetry. Bundles the Roslyn analyzer (analyzers/dotnet/cs/). |
OrionVault.EntityFrameworkCore | EF Core integration: [Encrypted] attribute, IsEncrypted() fluent API, value converter factory, IModelCustomizer wiring, UseOrionVault() extension. |
OrionVault.Testing | Test helpers: AddOrionVaultForTesting() DI extension, DangerousTestKeyProvider (zero key, explicit opt-in required), EncryptionAssertions. Reference it with PrivateAssets="all". |
OrionVault.AwsKms (in repo, not yet on NuGet) | AWS KMS IKeyProvider — unwraps data keys from AWS Key Management Service. |
OrionVault.AzureKeyVault (in repo, not yet on NuGet) | Azure Key Vault IKeyProvider — unwraps data keys from Azure Key Vault. |
OrionVault.GcpKms (in repo, not yet on NuGet) | Google Cloud KMS IKeyProvider — unwraps data keys from GCP Key Management. |
OrionVault.HashiCorpVault (in repo, not yet on NuGet) | HashiCorp Vault IKeyProvider — unwraps data keys from HashiCorp Vault's Transit engine. |
The three published packages multi-target net8.0 / net9.0 / net10.0. The analyzer ships inside the core package; there is no separate analyzers nupkg to install. The cloud-KMS / HashiCorp provider projects listed above are implemented in the repository but are not yet published to NuGet.
30-second quick start
Install the two runtime packages:
dotnet add package OrionVault
dotnet add package OrionVault.EntityFrameworkCore
Register OrionVault and bind it to your DbContext:
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Moongazing.OrionVault.DependencyInjection;
using Moongazing.OrionVault.EntityFrameworkCore.DependencyInjection;
services.AddOrionVault(o =>
{
o.UseStaticKeys(k =>
k.Add(keyId: 1, base64Key: Environment.GetEnvironmentVariable("ORIONVAULT_KEY_1")!));
o.ActiveKeyId = 1;
})
.UseEntityFrameworkCore<AppDbContext>();
services.AddDbContext<AppDbContext>((sp, opt) =>
opt.UseNpgsql(connectionString).UseOrionVault(sp));
Mark the columns you want encrypted:
using Moongazing.OrionVault.EntityFrameworkCore;
public class Customer
{
public Guid Id { get; set; }
public string FullName { get; set; } = null!;
[Encrypted]
public string Email { get; set; } = null!;
[Encrypted]
public string IbanLast4 { get; set; } = null!;
}
Or use the fluent API in OnModelCreating:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>().Property(c => c.Email).IsEncrypted();
modelBuilder.Entity<Customer>().Property(c => c.IbanLast4).IsEncrypted();
}
That's it. SaveChanges writes ciphertext, queries read it back as plaintext. The column type in the database becomes byte[] (varbinary / bytea / BLOB depending on provider).
The cipher format
Every encrypted value lives in the database as a single byte[] with a fixed 30-byte header followed by the ciphertext body:
+---------+----------+----------+--------------------+
| keyId | nonce | tag | ciphertext |
| 2 bytes | 12 bytes | 16 bytes | N bytes (= len(pt))|
| BE | | | |
+---------+----------+----------+--------------------+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
30-byte fixed overhead payload
keyIdis the big-endian 16-bit identifier of the key used to encrypt this row. The decryptor reads it first and asksIKeyProviderfor that exact key.nonceis freshly generated per encryption viaRandomNumberGenerator. The AES-GCM nonce reuse rule (never reuse(key, nonce)) is honored because every encryption draws a new nonce.tagis the 128-bit GCM authentication tag.ciphertextis the same length as the original plaintext.
A UTF-8 email like [email protected] (15 bytes) becomes 45 bytes on disk: 30 header + 15 body.
Key rotation
OrionVault supports multi-key read, single-key write. You declare every key the host might encounter; ActiveKeyId chooses which one is used for new writes:
services.AddOrionVault(o =>
{
o.UseStaticKeys(k =>
{
k.Add(keyId: 1, base64Key: oldKeyBase64);
k.Add(keyId: 2, base64Key: newKeyBase64);
});
o.ActiveKeyId = 2;
});
After this configuration:
- New rows are encrypted under key 2.
- Existing rows that were written under key 1 are still decrypted correctly because key 1 is still registered.
- A row encrypted under a key that is not registered throws
OrionVaultKeyNotFoundExceptionon read.
To actually retire key 1, re-encrypt existing rows by running them through SaveChanges once (load entity, mark a tracked property modified, save). The value converter encrypts under the current ActiveKeyId. For bulk migration, register the background ReEncryptionHostedService (UseReEncryptionService()) together with an IReEncryptionTarget that enumerates and rewrites the rows for your model; the hosted service drives that target on a schedule rather than walking the table itself (the default target is a no-op).
Searchable encrypted columns
AES-GCM is randomized: encrypting the same plaintext twice produces different ciphertext. That means SQL WHERE Email = @p does not work against an encrypted column. The Roslyn analyzer fails the build on this at compile time (OV0002), including the invocation shapes - Contains, StartsWith, string.Equals, EF.Functions.Like, emails.Contains(u.Email) - that look nothing like == but reach SQL the same way.
v0.3.0 adds a first-class blind index for exactly this case. A blind index is a deterministic, keyed HMAC-SHA256 digest of a normalized value: equal plaintexts always produce equal indexes, the index cannot be reversed to the plaintext without the key, and the stored ciphertext stays randomized and non-deterministic. You store the index in a separate, non-encrypted byte[] column and query it with an equality predicate.
Opt in with UseBlindIndex, then resolve IBlindIndexProvider from DI:
using Moongazing.OrionVault.Abstractions;
using Moongazing.OrionVault.DependencyInjection;
services.AddOrionVault(o =>
{
o.UseStaticKeys(k => k.Add(keyId: 1, base64Key: encryptionKeyBase64));
o.ActiveKeyId = 1;
// Index keys are independent from the encryption keys and must use different secret
// material. Minimum 16 bytes; 32 is recommended.
o.UseBlindIndex(b => b.Add(version: 1, base64Key: indexKeyBase64));
o.ActiveBlindIndexVersion = 1;
})
.UseEntityFrameworkCore<AppDbContext>();
Add a plain byte[] column for the index next to the encrypted property and populate it from the provider. The provider normalizes before hashing (default: trim and invariant-lowercase), so you do not lowercase by hand:
public class Customer
{
public Guid Id { get; set; }
[Encrypted]
public string Email { get; set; } = null!;
// Blind index token from IBlindIndexProvider.Compute(...).Bytes. Searchable,
// irreversible, self-describing (carries its key version). NOT encrypted.
public byte[] EmailIndex { get; set; } = null!;
}
// Write path: compute the index under the active version.
customer.Email = email;
customer.EmailIndex = index.Compute(email).Bytes;
// Read path: probe with the same provider and run an equality query server-side.
byte[] probe = index.Compute(needle).Bytes;
var hit = await db.Customers.SingleOrDefaultAsync(c => c.EmailIndex == probe);
Matches(value, storedIndex) verifies a candidate against a stored token in constant time, resolving the key version from the token itself.
Index key rotation
Index keys are versioned, mirroring encryption key rotation: new writes use ActiveBlindIndexVersion, and retained older versions still match rows indexed under them. Register both versions and mark the new one active:
o.UseBlindIndex(b =>
{
b.Add(version: 1, base64Key: oldIndexKeyBase64); // keep so old rows still match
b.Add(version: 2, base64Key: newIndexKeyBase64); // new key for new writes
});
o.ActiveBlindIndexVersion = 2;
Until a re-index sweep rewrites old rows under the active version, search must probe every retained version. ComputeAllVersions returns one token per version (newest first) for an OR-probe:
var probes = index.ComputeAllVersions(needle);
var hit = await db.Customers.SingleOrDefaultAsync(
c => c.EmailIndex == probes[0].Bytes || c.EmailIndex == probes[1].Bytes);
The index key should be separate from the encryption key: the blind index trades a little confidentiality (equal values become linkable) for searchability, so leaking the index key must not weaken the encryption key. A runnable end-to-end example, including the rotation OR-probe, is in demo/Moongazing.OrionVault.Demo/BlindIndexDemo.cs.
Roslyn analyzer
Three diagnostics ship inside the core nupkg's analyzers/dotnet/cs/ directory. No separate install.
| Id | Severity | Catches |
|---|---|---|
| OV0001 | Error | [Encrypted] on a property whose type is not string or byte[]. |
| OV0002 | Error | LINQ predicate filtering on an encrypted column (matches no rows, reports success). |
| OV0003 | Info | LINQ OrderBy / GroupBy on an encrypted column (executes client-side after decryption). |
OV0002 covers the invocation shapes as well as ==: col.Contains(x), StartsWith, EndsWith, string.Equals(col, x), EF.Functions.Like(col, ...), and emails.Contains(col). It is deliberately not raised when an operand is null - col == null, string.Equals(col, null), object.Equals(col, null), col.Equals(null), ReferenceEquals(col, null) - because those all translate to IS NULL, which is evaluated on the column rather than its contents and works correctly against ciphertext. Nor is it raised for in-memory IEnumerable queries, where the value converter has already decrypted the column.
It is an error rather than a warning because the predicate is false for every row and fails silently: the screen renders empty, and a Where(...).ExecuteDelete() erasure deletes nothing and reports success. Suppress per-call site with #pragma warning disable OV0002 when you know what you are doing (for example, fetching a single row by primary key and filtering in memory).
Telemetry
OrionVault publishes one ActivitySource and one Meter, both named Moongazing.OrionVault:
- Counters:
orion.vault.encryptions,orion.vault.decryptions,orion.vault.decryption.failures,orion.vault.key_lookups,orion.vault.key_not_found. - Histogram:
orion.vault.encryption.duration_ms.
Subscribe with the standard OpenTelemetry .NET helpers:
using OpenTelemetry.Metrics;
services.AddOpenTelemetry().WithMetrics(m => m
.AddMeter("Moongazing.OrionVault")
.AddPrometheusExporter());
Spans wrap individual encrypt and decrypt operations and are useful when correlating slow SaveChanges calls or unexpected decryption failures.
Benchmarks
See benchmarks.md for the scenarios we measure (encrypt and decrypt throughput across payload sizes, value-converter overhead vs. a manual ValueConverter, key-lookup contention) and the comparison baselines. The BenchmarkDotNet project lives at benchmarks/Moongazing.OrionVault.Benchmarks.
Testing
The Moongazing.OrionVault.Testing package wires a deterministic key provider and the real AES-GCM encryptor for fast unit tests. Reference it with PrivateAssets="all", and opt in once - the key it serves is 32 zero bytes, so the provider refuses to construct until a process says out loud that it is a test process:
<PackageReference Include="OrionVault.Testing" Version="..." PrivateAssets="all" />
using System.Runtime.CompilerServices;
using Moongazing.OrionVault.Testing;
internal static class TestSetup
{
// Runs before the first test in the assembly.
[ModuleInitializer]
internal static void Enable() => DangerousTestKeyProvider.Enable();
}
Then the wiring is ordinary:
using Moongazing.OrionVault.Testing.DependencyInjection;
using Moongazing.OrionVault.EntityFrameworkCore.DependencyInjection;
var services = new ServiceCollection()
.AddOrionVaultForTesting()
.UseEntityFrameworkCore<TestDbContext>()
.Services
.AddDbContext<TestDbContext>((sp, opt) =>
opt.UseSqlite("Data Source=:memory:").UseOrionVault(sp))
.BuildServiceProvider();
Inspect raw column bytes with EncryptionAssertions:
var raw = await db.Database.SqlQuery<byte[]>($"SELECT Email AS Value FROM Customers").SingleAsync();
EncryptionAssertions.IsEncrypted(raw);
EncryptionAssertions.IsEncryptedWithKey(raw, expectedKeyId: 1);
OrionVault.Testing is test-only and says so in three places, because a test double that reaches production is indistinguishable from no encryption at all:
- Reference it with
PrivateAssets="all"so it cannot flow into a dependent's build. DangerousTestKeyProviderserves an all-zero AES key and refuses to construct until the process opts in - callDangerousTestKeyProvider.Enable()from test setup (a[ModuleInitializer]works well) or set theMoongazing.OrionVault.Testing.EnableDangerousTestKeysAppContext switch in the test project.- Using it raises
OV9000, which you must suppress explicitly.
The package ships no fake IEncryptor. Tests that need to inspect the envelope layout read it with EncryptionAssertions against real ciphertext instead.
Veil vs OrionVault
These two libraries solve adjacent but different problems and a project may use one, the other, or both:
-
Moongazing.Veilmasks PII in outputs (logs, API responses, serialized DTOs). A value like[email protected]is stored as plaintext in the database and shows up asa**@e******.comin serialized output. The threat being mitigated is shoulder-surfing, accidental log exposure, and overly chatty error responses. -
Moongazing.OrionVaultencrypts PII in storage. The value is ciphertext on disk; the application sees plaintext after the value converter decrypts it. The threat being mitigated is a leaked backup, a stolen disk, or unauthorized direct database access.
Veil does not protect against a database leak. OrionVault does not protect against a chatty log statement. Use Veil for what humans see, use OrionVault for what disks hold.
How it compares
| Feature | OrionVault | EntityFrameworkCore.DataEncryption | Manual ValueConverter | AspNetCore.DataProtection |
|---|---|---|---|---|
| AES-256-GCM (AEAD) | Yes | AES-CBC by default | You choose | Yes |
| Key rotation (multi-read, single-write) | Yes | Partial | You build | Yes |
| Per-row key id in ciphertext | Yes | No | You build | Yes (in payload) |
[Encrypted] attribute | Yes | Yes | - | - |
Fluent IsEncrypted() API | Yes | Yes | - | - |
| Roslyn analyzer (type + query) | Yes | No | - | - |
| OpenTelemetry counters and spans | Yes | No | No | Limited |
| Test helpers package | Yes | No | - | - |
| Target frameworks | net8/9/10 | net6+ | - | net6+ |
| Designed for EF Core specifically | Yes | Yes | Yes | No |
| Cloud KMS providers | In repo | No | - | Via extensions |
OrionVault is not the only column-encryption story in the .NET ecosystem; it is the one that ships an analyzer, telemetry, and a Testing package out of the box, with a deliberately small API surface. If you already have a working EntityFrameworkCore.DataEncryption setup and are happy with it, there is no urgent reason to migrate.
Orion family
OrionVault is one of several standalone .NET libraries. None depend on another at runtime.
- OrionGuard - input validation, guard clauses, DDD primitives.
- OrionAudit - EF Core audit trail with JSON Patch diffs and time-travel reconstruction.
- OrionLock - distributed lock primitive with auto-renewing leases.
- OrionKey - source-generated strongly-typed IDs.
- OrionPatch - transactional outbox primitive with pluggable sinks.
Each ships separately on NuGet.
Roadmap
See ROADMAP.md for the full 12-month plan. Highlights:
- v0.2 - background re-encryption hosted service (
ReEncryptionHostedService) and multi-DbContext support shipped in the core package. AWS KMS, Azure Key Vault, GCP KMS, and HashiCorp Vault provider projects are implemented but not yet published to NuGet. - v0.3 (shipped) - First-class searchable blind index (
IBlindIndexProvider) with versioned key rotation. - v0.3.x (2027-Q1) - Numeric / DateTime / decimal column types; migration helper for converting existing plaintext columns.
- v0.4 (2027-Q1/Q2) - Windows DPAPI provider, HashiCorp Vault provider, per-tenant key partitioning.
- v1.0 (2027-Q2) - Public API surface freeze and compliance documentation (KVKK, GDPR, PCI-DSS mapping).
If something on the list matters to you, open an issue with the roadmap label.
See it in a real app
Moongazing.OrionShowcase is a production-shaped banking sample integrating all six Orion packages end-to-end. OrionVault encrypts customer PII columns (TCKN, email, phone) as bytea on Postgres. The integration test reads raw bytes directly and verifies the [keyId|nonce|tag|ciphertext] header layout. Concrete usage:
- src/Moongazing.OrionShowcase.Infrastructure/Persistence/Configurations/CustomerConfiguration.cs
- test/Moongazing.OrionShowcase.IntegrationTests/Scenarios/PiiEncryptionTests.cs
License
MIT. See LICENSE.
Contributing
Issues and pull requests welcome. Please read CONTRIBUTING.md and the Code of Conduct before opening one.
Packages
| Package | Version | Downloads |
|---|---|---|
| OrionVault | 0.5.0 | 6,271 |
| OrionVault.EntityFrameworkCore | 0.5.0 | 4,491 |
| OrionVault.Testing | 0.5.0 | 4,483 |
| Moongazing.OrionVault | 0.1.1 | 358 |
| Moongazing.OrionVault.EntityFrameworkCore | 0.1.1 | 273 |
| Moongazing.OrionVault.Testing | 0.1.1 | 271 |