Envelope Encryption
Envelope encryption splits Tayra's key handling into two roles, each backed by its own keystore:
- Master keystore (hardened, expensive). Holds one master key per organization. A single secret per tenant - the entire population of subjects sits under it.
- Data keystore (cheap, scalable). Holds N per-subject blobs. Each blob is the per-subject DEK wrapped (AES-256-GCM) by the master. Without the master, the blobs are inert.
Both roles use existing Tayra.KeyStore.* packages - pick any combination.
Why use envelope encryption
For a customer with 15 million data subjects on AWS Secrets Manager, the per-secret pricing model breaks the bank:
| Mode | 15M subjects on AWS Secrets Manager |
|---|---|
| Single-tier (one secret per subject) | ~$6,000,000 / month |
| Envelope (one master in Secrets Manager + 15M wrapped DEKs in Postgres) | ~$1 / month |
Both modes preserve per-subject crypto-shredding (Art. 17). Envelope mode adds shredding the master key - a single master destruction cryptographically erases every subject's data in one operation.
| Single-tier | Envelope | |
|---|---|---|
| Per-subject crypto-shred | Delete one DEK | Delete one wrapped blob |
| Shred master key | Delete every key (slow) | Destroy the master (atomic) |
| Cost at 15M subjects | per-secret pricing | flat |
| Hardening footprint | every keystore equally hardened | only the master keystore |
| Data keystore deployment | separate, hardened DB / vault | same DB as app data, separate schema |
| Number of DBs to operate | 2 (app + keystore) | 1 (app + keystore co-located) + master keystore |
Configuration
Two flat chain calls. The customer keeps using UseXxxKeyStore(...) for the data role exactly as before; the new .WithXxxMasterKey(...) extension adds the master role and flips on envelope encryption.
builder.Services
.AddTayra(o => o.LicenseKey = config["Tayra:License"]!)
// Data role - per-subject wrapped DEKs, cheap and scalable.
.UsePostgreSqlKeyStore(config.GetConnectionString("KeyStore")!)
// Master role - one hardened key per org. Activates envelope mode.
.WithAwsSecretsManagerMasterKey(opts =>
{
opts.Region = "us-east-1";
opts.SecretNamePrefix = "tayra/master/";
opts.KeyIdTemplate = "tayra:master:{tenantId}";
opts.CacheDuration = TimeSpan.FromMinutes(30);
});
builder.Services.AddTayraMultiTenancy<HttpHeaderTenantProvider>();The {tenantId} placeholder in KeyIdTemplate is replaced with the current tenant from ITenantProvider at runtime, giving you per-organization master keys out of the box.
Customizing the master key id
The default KeyIdTemplate covers the common case - one master per tenant, resolved from ITenantProvider. Real deployments often need more:
- key by org id, not tenant id;
- combine org + tenant + region for cell-based isolation;
- key by environment (
prodvsstaging) when dev and prod coexist on shared infrastructure; - pull the scope from a custom ambient context (HTTP claim, header, ambient logging scope).
Tayra exposes three layers for this, in increasing order of power. When more than one is configured, the highest-precedence wins:
IMasterKeyIdResolveralready registered in DI beforeWithXxxMasterKey(...)(Tayra usesTryAddSingleton, so a pre-existing registration is kept).MasterKeyIdResolverFactoryon the options - typed factory, usually set viaUseMasterKeyIdResolver<T>().MasterKeyIdResolveron the options - inlineFunc<IServiceProvider, string>.KeyIdTemplate- defaultTemplateMasterKeyIdResolverwith{tenantId}substitution.
1. Template (default)
KeyIdTemplate substitutes {tenantId} with ITenantProvider.GetCurrentTenantId(). This is what runs unless you override it.
.WithAwsSecretsManagerMasterKey(opts =>
{
opts.KeyIdTemplate = "tayra:master:{tenantId}"; // default
});Single-tenant fallback
With no ITenantProvider registered - or one that returns null - the {tenantId} placeholder is substituted with the literal "default". So the default template resolves to tayra:master:default in single-tenant deployments, and that's the id used to wrap every DEK. Register an ITenantProvider (or set a resolver) before enabling envelope mode if you want anything else.
2. Inline delegate
Set MasterKeyIdResolver on the master-key options to supply a function. The function receives the request IServiceProvider, so it can pull any service. Set inside the WithXxxMasterKey(opts => ...) lambda - it overrides KeyIdTemplate:
.WithAwsSecretsManagerMasterKey(opts =>
{
opts.Region = "us-east-1";
opts.MasterKeyIdResolver = sp =>
{
var tenant = sp.GetRequiredService<ITenantProvider>().GetCurrentTenantId();
var org = sp.GetRequiredService<IOrgContext>().OrgId;
var region = sp.GetRequiredService<IRegionContext>().Region;
return $"tayra:master:{org}:{tenant}:{region}";
};
});This is the recommended path when the resolution logic fits in a single function - composite ids, environment prefixes, etc.
3. Custom IMasterKeyIdResolver
For DI-resolved dependencies, async-state probing, or structured fallback logic - implement IMasterKeyIdResolver and tell the master-key options to use it:
public sealed class OrgScopedMasterKeyIdResolver(IOrgContext org, ITenantProvider tenants)
: IMasterKeyIdResolver
{
public string Resolve()
=> $"tayra:master:{org.OrgId}:{tenants.GetCurrentTenantId() ?? "shared"}";
}
builder.Services
.AddTayra(o => o.LicenseKey = config["Tayra:License"]!)
.UsePostgreSqlKeyStore(connStr)
.WithAwsSecretsManagerMasterKey(opts =>
{
opts.Region = "us-east-1";
opts.UseMasterKeyIdResolver<OrgScopedMasterKeyIdResolver>();
});UseMasterKeyIdResolver<T>() is a shorthand on every master-key options class. The resolver is built via ActivatorUtilities.CreateInstance on first use, so its constructor parameters are filled from DI just like any other service. You don't need a separate services.AddSingleton<IMasterKeyIdResolver, ...> registration.
Lower-level alternatives
opts.MasterKeyIdResolverFactory = sp => ...- bring your own factory closure if you want manual construction.services.AddSingleton<IMasterKeyIdResolver, MyResolver>()before theWithXxxMasterKeycall - Tayra usesTryAddSingletoninternally, so a pre-existing DI registration wins. Useful when the resolver is shared across libraries that don't share the master-key options object.
Validation rules
Whatever your resolver returns is validated by Tayra at envelope time:
- must not be null, empty, or whitespace;
- must not contain control characters or SQL wildcards (
%,_).
Use only ASCII identifier-safe characters (letters, digits, :, -, .). The resolved id is bound into the AAD of every wrapped DEK, so it's part of the cryptographic context - keep it stable for a given scope.
Available master-key extensions
Each existing keystore package adds a parallel With...MasterKey(...) extension:
| Package | Master-role extension |
|---|---|
Tayra.KeyStore.PostgreSql | .WithPostgreSqlMasterKey(connStr, ...) |
Tayra.KeyStore.AwsSecretsManager | .WithAwsSecretsManagerMasterKey(...) |
Tayra.KeyStore.AwsParameterStore | .WithAwsParameterStoreMasterKey(...) |
Tayra.KeyStore.AwsAurora | .WithAwsAuroraMasterKey(...) |
Tayra.KeyStore.AzureKeyVault | .WithAzureKeyVaultMasterKey(vaultUri, ...) |
Tayra.KeyStore.Vault | .WithVaultMasterKey(addr, token, ...) |
Tayra.KeyStore.Sqlite | .WithSqliteMasterKey(...) (development only) |
Tayra.KeyStore.InMemory | .WithInMemoryMasterKey(...) (tests only) |
SQLite is development only - in either role
SQLite stores raw key bytes in a local file with no envelope encryption, no HSM backing, and no access auditing. It is never appropriate for a production deployment, regardless of whether it plays the data role, the master role, or both. For production master keys use AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, or AWS Parameter Store; see Key Stores Overview.
You can mix and match. For example: master in AWS Secrets Manager, data in Postgres; or master in Vault, data in AWS Parameter Store.
What's stored, where
After enabling envelope encryption, the data keystore contains opaque envelope blobs:
[0xE1] format version
[flags(2B)] reserved
[master_key_id_len(2B)]
[master_key_id ...] UTF-8, e.g. "tayra:master:acme"
[wrapped_dek ...] AES-GCM V3 ciphertext, contains master versionThe wrapped DEK is bound to its key id and master via AAD; cross-tenant blob splicing fails to unwrap.
The master keystore contains versioned master keys - tayra:master:acme:v1, tayra:master:acme:v2, etc. Older versions are retained so previously-wrapped DEKs can still be unwrapped after a master rotation.
Crypto-shred semantics
| Operation | Mechanism |
|---|---|
| Per-subject erasure | Delete one wrapped blob in the data keystore. Master untouched, other subjects unaffected. |
| Shred master key | Destroy every version of the master in the master keystore. Every wrapped DEK becomes permanently unrecoverable without touching the data keystore. NIST SP 800-88 Rev 1 Cryptographic Erase. |
| Master rotation | Mint a new master version. Old versions retained for unwrap of pre-rotation DEKs. |
CLI:
# Per-subject shred (deletes that subject's DEKs)
dotnet tayra shred-data-key --subject "user:42" --confirm
# Tenant-wide shred (destroys the master, all wrapped DEKs become inert)
dotnet tayra shred-master-key --tenant "acme" --confirmStrict format enforcement
Tayra has no production customers; envelope mode is a clean break. Every blob in the data keystore must be in envelope format from day one. A non-envelope blob in the data store causes:
EnvelopeFormatException: Expected envelope format 0xE1; got 0x..There is no migration mode and no legacy-raw read path. If you ever switch from single-tier to envelope on existing data, the only supported approach is to start fresh with envelope active from registration.
Co-locating the data keystore with application data
In envelope mode, the data keystore does not need to be hardened separately. It can sit in the same Postgres database as your application data, in a different schema.
This is a deliberate consequence of the threat model - and a meaningful infrastructure-cost reduction over single-tier deployments.
Threat model
The data keystore in envelope mode contains only opaque envelope blobs:
[0xE1][flags][master_key_id_len][master_key_id][AES-GCM ciphertext bound to AAD]Each blob is bound by AAD to (tenantId, keyId, masterKeyId, masterVersion). Without the master key - which lives in a different keystore - the blobs are cryptographically inert. An attacker who exfiltrates the entire data keystore (table dump, backup tape, replica) gets a pile of ciphertext from which not a single plaintext DEK can be derived.
The data keystore is therefore not a privileged target. The strict isolation that single-tier deployments require - because there the data keystore holds raw DEKs - does not apply.
Recipe: same DB, separate schema and role
.UsePostgreSqlKeyStore(connStr, opts =>
{
opts.Schema = "tayra_keystore";
opts.TableName = "encryption_keys";
})
.WithAwsSecretsManagerMasterKey(opts => { /* ... */ });Pair with role separation in Postgres:
-- One-time setup
CREATE SCHEMA tayra_keystore;
CREATE ROLE tayra_app LOGIN PASSWORD '...';
-- Tayra app role: full access to keystore schema only.
GRANT USAGE, CREATE ON SCHEMA tayra_keystore TO tayra_app;
-- App data role gets nothing on tayra_keystore;
-- keystore role gets nothing on app data tables.
REVOKE ALL ON SCHEMA tayra_keystore FROM your_app_role;Why schema + role separation is still worth doing
Even though the wrapped DEKs are inert without the master, role-level separation buys you defense in depth at zero cost:
- SQL-injection containment. A SQLi against an application endpoint with the app role cannot read or
DELETEkeystore rows. Mass deletion of wrapped DEKs is a denial-of-service vector - equivalent to mass crypto-shredding - even though the rows leak nothing. - Audit clarity. Postgres logs attribute keystore reads/writes to a distinct role, making forensic analysis cleaner.
- Operational isolation. Schema-scoped migrations,
\dlistings, and access grants stay in their own namespace. - Atomicity bonus. Same DB means same transaction boundary - crypto-shredding a key and the corresponding application-data delete can commit atomically. A genuine win for shred integrity in modular monoliths.
What does NOT change
- The master keystore must remain hardened and isolated. Every security guarantee of envelope mode rests on the master being unreachable to anyone who breaches the data DB. AWS Secrets Manager, Aurora with IAM auth, Vault, Azure Key Vault - pick whatever your team can defend. Co-locating the master with application data defeats the entire model.
- Single-tier mode still needs separation. This co-location guidance is specific to envelope mode. In single-tier the data keystore holds raw DEKs - a DB compromise leaks the keys that protect the data sitting in the same DB. Don't co-locate single-tier keystores.
Caching
The plaintext master is cached in-process for CacheDuration (default 15 minutes). Async daemon rebuilds and high-throughput projection workloads typically pay one master-store fetch per process start, then ride the cache.
IMasterKeyStore.DestroyAsync invalidates the local cache immediately. Other process instances retain their cached copies until their TTL expires, which defines the shred-master-key propagation window.
