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.
The optional Socigy.OpenSource.DB.HashiCorp package integrates Socigy with HashiCorp Vault — or the API-compatible OpenBao fork — for two things:
- Field encryption. It supplies the
IFieldEncryptorfor[Encrypted]columns, keyed from Vault. - Rotating DB credentials. It supplies an
IDbCredentialsProviderthat leases short-lived PostgreSQL credentials from Vault's Database secrets engine, which the generated connection factory consumes automatically.
dotnet add package Socigy.OpenSource.DB.HashiCorpSocigy.OpenSource.DB package and VaultSharp. The two Vault features are independent: register either or both.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";
});Transit data-key envelope — rotation without re-encryption (recommended)
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-dbbuilder.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();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=truebuilder.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-directOnly 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 leasedUsername/Password), cached in memory. The string is built withDbConnectionStringBuilder, 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 (
LeaseDurationSecondsreturned by Vault), so they are refreshed before they expire even when the role issues short leases.RefreshIntervalis 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.
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.DBActivitySource:vault.encryption.key.fetchandvault.encryption.key.rotate(key load / rotation), thevault.transit.*spans for envelope and EaaS Transit calls (datakey,unwrap,encrypt,decrypt,rewrap,key.read),socigy.db.encryption.reencryptfor a bulk re-encryption pass,vault.credentials.lease(withdb.name,vault.database.role, and lease-duration tags), andvault.token.renew/vault.token.reloginfor background auth-token upkeep. Subscribe withAddSource("Socigy.OpenSource.DB")(see Diagnostics & OpenTelemetry). ILoggermessages under categoriesSocigy.OpenSource.DB.Vault.Encryption,Socigy.OpenSource.DB.Vault.Rotation, andSocigy.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, andSocigyFieldEncryption.Configurelogs when an encryptor is installed. EaaS-direct mode logs a one-time warning that every field read/write is a Vault round-trip.