Skip to content

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:

Mode15M 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-tierEnvelope
Per-subject crypto-shredDelete one DEKDelete one wrapped blob
Shred master keyDelete every key (slow)Destroy the master (atomic)
Cost at 15M subjectsper-secret pricingflat
Hardening footprintevery keystore equally hardenedonly the master keystore
Data keystore deploymentseparate, hardened DB / vaultsame DB as app data, separate schema
Number of DBs to operate2 (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.

csharp
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 (prod vs staging) 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:

  1. IMasterKeyIdResolver already registered in DI before WithXxxMasterKey(...) (Tayra uses TryAddSingleton, so a pre-existing registration is kept).
  2. MasterKeyIdResolverFactory on the options - typed factory, usually set via UseMasterKeyIdResolver<T>().
  3. MasterKeyIdResolver on the options - inline Func<IServiceProvider, string>.
  4. KeyIdTemplate - default TemplateMasterKeyIdResolver with {tenantId} substitution.

1. Template (default)

KeyIdTemplate substitutes {tenantId} with ITenantProvider.GetCurrentTenantId(). This is what runs unless you override it.

csharp
.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:

csharp
.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:

csharp
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 the WithXxxMasterKey call - Tayra uses TryAddSingleton internally, 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:

PackageMaster-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 version

The 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

OperationMechanism
Per-subject erasureDelete one wrapped blob in the data keystore. Master untouched, other subjects unaffected.
Shred master keyDestroy 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 rotationMint a new master version. Old versions retained for unwrap of pre-rotation DEKs.

CLI:

bash
# 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" --confirm

Strict 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

csharp
.UsePostgreSqlKeyStore(connStr, opts =>
{
    opts.Schema = "tayra_keystore";
    opts.TableName = "encryption_keys";
})
.WithAwsSecretsManagerMasterKey(opts => { /* ... */ });

Pair with role separation in Postgres:

sql
-- 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 DELETE keystore 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, \d listings, 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.

See also