/DB

HashiCorp Vault / OpenBao

Optional Socigy.OpenSource.DB.HashiCorp package: field encryption keyed from HashiCorp Vault or OpenBao and rotating PostgreSQL credentials from the Database secrets engine, wired with one DI call each.

updated 26 Jun 20266 min readv0.3.3View as Markdown

The optional Socigy.OpenSource.DB.HashiCorp package integrates Socigy with HashiCorp Vault — or the API-compatible OpenBao fork — for two things:

  1. Field encryption. It supplies the IFieldEncryptor for [Encrypted] columns, keyed from Vault.
  2. Rotating DB credentials. It supplies an IDbCredentialsProvider that leases short-lived PostgreSQL credentials from Vault's Database secrets engine, which the generated connection factory consumes automatically.
dotnet add package Socigy.OpenSource.DB.HashiCorp
NOTE
This package depends on the main Socigy.OpenSource.DB package and VaultSharp. The two Vault features are independent: register either or both.
NOTE
OpenBao — the open-source fork of Vault — works as a drop-in. Point the same options (Address, Token/AppRole) at your OpenBao server; its KV-v2, Transit and Database secrets-engine APIs are wire-compatible. The integration test suite passes against both Vault and OpenBao.

Field encryption

The package supplies the ambient IFieldEncryptor for [Encrypted] columns in one of three modes. Each installs at host start, so once registered every [Encrypted] column just works.

KV-direct — local key from a KV-v2 secret

The data-encryption key is read from a KV-v2 secret at startup and used for fast, local AES-256-CBC + HMAC encryption. Per-field crypto stays synchronous with no round-trip per row. Store a Base64-encoded 32-byte key:

vault kv put secret/socigy/db-encryption-key key="$(openssl rand -base64 32)"
builder.Services.AddSocigyVaultEncryption(o =>
{
    o.Address = "https://vault.example.com:8200";
    o.Token = builder.Configuration["Vault:Token"];     // or AppRoleId + AppRoleSecretId
    o.KvMountPoint = "secret";
    o.KeySecretPath = "socigy/db-encryption-key";
    o.KeyField = "key";
});
NOTE
Rotating a KV-direct key means updating the KV secret and re-encrypting existing rows, because the ciphertext carries no key identifier. For rotation without re-encryption, use the envelope mode below.

Vault's Transit engine holds a key-encryption key; the package keeps a versioned keyring of Transit-wrapped data-encryption keys (DEKs) in a KV-v2 secret. At startup (and on rotation) the DEKs are unwrapped via Transit into memory, and per-field crypto stays local — Transit is only contacted at startup and rotation, never per row. Each encrypted value embeds the id of the DEK that produced it, so after a rotation old rows stay readable (their DEK is retained) while new writes use the newest DEK.

Enable Transit and create the key (it must not be derived — the package generates random DEKs from it):

vault secrets enable transit
vault write -f transit/keys/socigy-db
builder.Services.AddSocigyVaultEnvelopeEncryption(o =>
{
    o.Address = "https://vault.example.com:8200";
    o.AppRoleId = builder.Configuration["Vault:RoleId"];
    o.AppRoleSecretId = builder.Configuration["Vault:SecretId"];
    o.TransitMountPoint = "transit";
    o.TransitKeyName = "socigy-db";
    o.KvMountPoint = "secret";
    o.KeyringSecretPath = "socigy/db-keyring";   // where the wrapped-DEK keyring is stored
    o.EnableBackgroundRotation = true;            // optional; default off
    o.RotationInterval = TimeSpan.FromDays(90);
});

Rotation mints a new DEK and advances the current key version. Run it on the interval above, or trigger it manually by resolving the encryptor:

await app.Services.GetRequiredService<VaultEnvelopeEncryptor>().RotateAsync();
NOTE
After a rotation, any row your app re-saves is re-encrypted to the newest key automatically (writes always use the current DEK); untouched rows stay readable via the keyring. To proactively rewrite old rows, see Key rotation and re-encryption.

Transit EaaS-direct — per-field Vault encryption

In EaaS-direct mode Vault encrypts and decrypts each field value directly (ciphertext is a vault:vN:… string), so rotating the Transit key is enough to move new writes forward, and old ciphertext can be upgraded with transit/rewrap. This makes a Vault round-trip per field, so it suits a few highly-sensitive columns (typically via a profile), not bulk scans. The key must be created with derived=true so the table:column context binds:

vault write transit/keys/socigy-eaas derived=true
builder.Services.AddSocigyVaultTransitEncryption(o =>
{
    o.Address = "https://vault.example.com:8200";
    o.Token = builder.Configuration["Vault:Token"];
    o.TransitKeyName = "socigy-eaas";
    o.Profile = "transit";          // route only [Encrypted(Profile = "transit")] columns here
    o.DecryptCacheSize = 10_000;    // bounded in-memory cache to soften repeated reads
});

Per-column profiles

Run one mode as the default and route specific columns to another by giving the registration a Profile and marking the column with it. A common setup is local/envelope by default with Transit EaaS for the most sensitive fields:

[Encrypted] public string Email { get; set; }                     // default encryptor (e.g. envelope)
[Encrypted(Profile = "transit")] public string Ssn { get; set; }   // EaaS-direct

Only the registration whose Profile is unset becomes the default; profiled registrations are reached by name. See Encrypted columns → Profiles.

Key rotation and re-encryption

Old rows always stay readable after a rotation (envelope keyring / retained Transit versions), and rows re-saved through normal writes migrate to the current key for free. To proactively rewrite old rows — e.g. to retire an old key version — use the byte-level FieldReencryptor (in Socigy.OpenSource.DB.Core.Encryption.Reencryption). It re-encrypts (envelope) or rewraps (EaaS) without exposing plaintext, and covers generated, dynamic, and [TableType] tables:

await new FieldReencryptor()
    .Add<User>()                              // statically-named generated table
    .AddDynamic<Event>("events_2026_06")      // dynamic / [TableType] table bound to a runtime name
    .RunAsync(connection, new ReencryptOptions { BatchSize = 500, DryRun = false });

It is keyset-paginated and transactional per batch (so it is resumable), skips values already at the current version, and reports the rows scanned and cells upgraded per table.

Rotating database credentials

Configure Vault's Database secrets engine with a role per database, then map each Socigy database name to its Vault role:

builder.Services.AddSocigyVaultCredentials(o =>
{
    o.Address = "https://vault.example.com:8200";
    o.AppRoleId = builder.Configuration["Vault:RoleId"];
    o.AppRoleSecretId = builder.Configuration["Vault:SecretId"];
    o.DatabaseMountPoint = "database";
    o.BaseConnectionString = "Host=db.internal;Port=5432;Pooling=true";   // no user/pass
    o.DatabaseRoles["AuthDb"] = "auth-db-role";
    o.DatabaseRoles["UserDb"] = "user-db-role";
    o.RefreshInterval = TimeSpan.FromMinutes(30);   // fallback only; renewal normally tracks the lease TTL
});

builder.AddAuthDb();   // the generated factory picks up IDbCredentialsProvider automatically
builder.AddUserDb();

How it fits together:

  • At startup the package leases credentials for each configured database and composes a connection string (BaseConnectionString + the leased Username/Password), cached in memory. The string is built with DbConnectionStringBuilder, so leased passwords containing ;, =, quotes or spaces are escaped correctly.
  • The generated connection factory calls the provider synchronously for the current connection string each time it opens a connection. See Connections & DI.
  • A background timer renews credentials at ~2/3 of the actual lease TTL (LeaseDurationSeconds returned by Vault), so they are refreshed before they expire even when the role issues short leases. RefreshInterval is only a fallback used when the lease TTL is unknown. When credentials rotate, new connections use the new string and Npgsql's old pool drains naturally.

Authentication

Both Add… calls accept either a token (Token) or AppRole (AppRoleId + AppRoleSecretId). AppRole is recommended for production workloads.

The Vault auth token is kept alive automatically by a background service: it renews the token (renew-self) before it expires, and when renewal can no longer extend it (max TTL) and AppRole credentials are configured, it re-logs-in for a fresh token. A static, non-renewable token cannot be kept alive — for long-running services use AppRole, a periodic token, or a renewable token. The library logs a clear error if it detects a token it cannot keep alive.

WARNING
Use an https:// Vault Address in production. If the address is plaintext http:// to a non-loopback host, the library logs a warning — tokens, keys, and leased credentials would otherwise travel unencrypted. Plain http:// to loopback is fine for local development.

Diagnostics

All background actions are observable so admins can track what the library does:

  • OpenTelemetry spans under the existing Socigy.OpenSource.DB ActivitySource: vault.encryption.key.fetch and vault.encryption.key.rotate (key load / rotation), the vault.transit.* spans for envelope and EaaS Transit calls (datakey, unwrap, encrypt, decrypt, rewrap, key.read), socigy.db.encryption.reencrypt for a bulk re-encryption pass, vault.credentials.lease (with db.name, vault.database.role, and lease-duration tags), and vault.token.renew / vault.token.relogin for background auth-token upkeep. Subscribe with AddSource("Socigy.OpenSource.DB") (see Diagnostics & OpenTelemetry).
  • ILogger messages under categories Socigy.OpenSource.DB.Vault.Encryption, Socigy.OpenSource.DB.Vault.Rotation, and Socigy.OpenSource.DB.Vault.Credentials: key/keyring load, rotation, each credential lease (database, role, user, lease seconds), renewal ticks, and failures. The connection factory also logs when it refreshes rotating credentials, and SocigyFieldEncryption.Configure logs when an encryptor is installed. EaaS-direct mode logs a one-time warning that every field read/write is a Vault round-trip.