Ana içeriğe geç

OrionVault

OrionVault

OrionVault

Column-level transparent data encryption at rest for EF Core. AES-256-GCM, key rotation, searchable blind index, bundled Roslyn analyzer, OpenTelemetry.

NuGet Downloads License Target


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​

PackageDescription
OrionVaultCore: IEncryptor, IKeyProvider, IEncryptionConfigurator, AES-256-GCM cipher, static key provider, searchable blind index (IBlindIndexProvider), telemetry. Bundles the Roslyn analyzer (analyzers/dotnet/cs/).
OrionVault.EntityFrameworkCoreEF Core integration: [Encrypted] attribute, IsEncrypted() fluent API, value converter factory, IModelCustomizer wiring, UseOrionVault() extension.
OrionVault.TestingTest 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
  • keyId is the big-endian 16-bit identifier of the key used to encrypt this row. The decryptor reads it first and asks IKeyProvider for that exact key.
  • nonce is freshly generated per encryption via RandomNumberGenerator. The AES-GCM nonce reuse rule (never reuse (key, nonce)) is honored because every encryption draws a new nonce.
  • tag is the 128-bit GCM authentication tag.
  • ciphertext is the same length as the original plaintext.

A UTF-8 email like ali@example.com (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 OrionVaultKeyNotFoundException on 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.

IdSeverityCatches
OV0001Error[Encrypted] on a property whose type is not string or byte[].
OV0002ErrorLINQ predicate filtering on an encrypted column (matches no rows, reports success).
OV0003InfoLINQ 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.
  • DangerousTestKeyProvider serves an all-zero AES key and refuses to construct until the process opts in - call DangerousTestKeyProvider.Enable() from test setup (a [ModuleInitializer] works well) or set the Moongazing.OrionVault.Testing.EnableDangerousTestKeys AppContext 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.Veil masks PII in outputs (logs, API responses, serialized DTOs). A value like ali@example.com is stored as plaintext in the database and shows up as a**@e******.com in serialized output. The threat being mitigated is shoulder-surfing, accidental log exposure, and overly chatty error responses.

  • Moongazing.OrionVault encrypts 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​

FeatureOrionVaultEntityFrameworkCore.DataEncryptionManual ValueConverterAspNetCore.DataProtection
AES-256-GCM (AEAD)YesAES-CBC by defaultYou chooseYes
Key rotation (multi-read, single-write)YesPartialYou buildYes
Per-row key id in ciphertextYesNoYou buildYes (in payload)
[Encrypted] attributeYesYes--
Fluent IsEncrypted() APIYesYes--
Roslyn analyzer (type + query)YesNo--
OpenTelemetry counters and spansYesNoNoLimited
Test helpers packageYesNo--
Target frameworksnet8/9/10net6+-net6+
Designed for EF Core specificallyYesYesYesNo
Cloud KMS providersIn repoNo-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:

License​

MIT. See LICENSE.

Contributing​

Issues and pull requests welcome. Please read CONTRIBUTING.md and the Code of Conduct before opening one.

Packages​

PackageVersionDownloads
OrionVault0.5.06,271
OrionVault.EntityFrameworkCore0.5.04,491
OrionVault.Testing0.5.04,483
Moongazing.OrionVault0.1.1358
Moongazing.OrionVault.EntityFrameworkCore0.1.1273
Moongazing.OrionVault.Testing0.1.1271