Skip to content

Blind Index Key Management ​

Blind indexes use HMAC-SHA256 to produce deterministic fingerprints. The HMAC operation requires a secret key. This page covers how that key is stored, scoped, and managed over time.

Key Storage ​

Blind index HMAC keys are stored in the same IKeyStore as encryption keys. They are distinguished by a bi: prefix in the key name.

Key typeKey store entryExample
Encryption key{prefix}{subjectId}cust-42
Blind index HMAC keybi:{scope}bi:default, bi:email
Blind index HMAC key, per tenant{tenant}:bi:{scope}orgA:bi:default

Blind index keys are not per-subject

Encryption keys are scoped to a data subject. Blind index HMAC keys are scoped to a Scope name, shared by every row. This is what makes querying possible: all rows with the same email address must produce the same HMAC.

Key Scopes ​

Every [BlindIndex], [ArrayBlindIndex], and [CompoundBlindIndex] has a Scope, which names its HMAC key. The default scope is "default", so unless you set one, every blind index in the application shares the single key bi:default, across all fields and all entity types:

bi:default  →  Customer.Email, Customer.Phone, Supplier.TaxId, ...

Querying still works, because the index name picks the field and the key only has to match between write and query. What a shared key costs is separation: rotating it means recomputing every index in the application, and leaking it exposes all of them.

Give an index its own key by naming a scope:

cs
public class ScopedEmailCustomer
{
    [DataSubjectId]
    public string CustomerId { get; set; } = "";

    // Its own HMAC key (bi:email) instead of the application-wide bi:default.
    [PersonalData]
    [BlindIndex(
        IndexPropertyName = nameof(EmailHash),
        Transforms = ["lowercase", "trim"],
        Scope = "email")]
    public string Email { get; set; } = "";
    public string? EmailHash { get; set; }
}
anchor

Indexes that name the same scope share a key, which is what you want when several entity types hold the same conceptual value (a shared email lookup across Customer, Supplier, and Employee). Separate scopes can be rotated, and recomputed, independently.

Per-Tenant Keys ​

In a multi-tenant store, a key per scope is still one key for every tenant, so the same value produces the same index in every tenant and records can be linked across tenants through the index columns. With DataEmbedded tenancy, WithMultiTenancy(t => t.PartitionBlindIndexKeys = true) gives each tenant its own key per scope, stored as {tenant}:bi:{scope} and wrapped by that tenant's envelope master key. Queries then name the tenant (ComputeBlindIndexForTenantAsync), and an index that must match across tenants opts out with SharedAcrossTenants. See Blind indexes across tenants.

With envelope encryption and a master key derived from the subject prefix (DeriveMasterKeyIdFromSubjectPrefix, or DataEmbedded tenancy), an unpartitioned key such as bi:default has bi as its leading segment, so it is wrapped by a master named for bi (tayra:master:bi with the default template) rather than by any tenant's master.

Key Generation ​

HMAC keys are generated automatically the first time ComputeBlindIndexAsync is called for a field that has no existing key in the key store. The generated key is a 32-byte cryptographically random value.

First-use creation is race-safe. Within a process, creation is serialized per key ID so concurrent callers do not each generate a key. Across processes, IKeyStore.StoreAsync is first-writer-wins and the provider reads the key back after storing, so every instance ends up computing hashes with the key the store actually holds.

You can still pre-generate keys during application startup to avoid the first-use key store round trip in high-concurrency environments:

cs
// Pre-warm HMAC keys during application startup.
// ComputeBlindIndexAsync triggers key generation if the HMAC key doesn't exist yet.
// This avoids a write-on-first-use race condition in high-concurrency environments.
await biTayra.ComputeBlindIndexAsync("warmup", "EmailHash", typeof(BlindIndexedCustomer));
anchor

Key Caching ​

Retrieved HMAC keys are cached in memory with an absolute TTL (default: 5 minutes, matching the crypto engine's KeyCacheDuration default). This means a rotation or deletion performed by another application instance is observed within the TTL window rather than persisting until restart.

Key Rotation ​

Rotating an HMAC key invalidates all existing blind index values. After rotation, no existing row can be found by a blind index query until its companion column has been recomputed.

Rotation is a multi-step operation:

  1. Generate a new HMAC key in the key store.
  2. Load all affected rows in batches.
  3. For each row: decrypt the field, apply transforms, compute new HMAC, write companion column.
  4. Commit the new key as active.
cs
// Rotating an HMAC key invalidates all existing blind index values.
// After rotation, recompute companion columns for all affected rows.

// Step 1: Delete the existing HMAC key
await biTayra.ShredByPrefixAsync("bi:");

// Step 2: Recompute blind indexes for all affected rows
// In practice, load rows from your database in batches.
var rows = new[] { indexed }; // Replace with batch query
foreach (var row in rows)
{
    // Decrypt to restore plaintext values
    await biTayra.DecryptAsync(row);

    // Re-encrypt - HMAC recomputed with the new key (auto-generated on first use)
    await biTayra.EncryptAsync(row);
}
anchor

With per-tenant keys, rotate one tenant's key with RotateKeyForTenantAsync(scope, tenant); only that tenant's indexes need recomputing.

Internally, because IKeyStore.StoreAsync is first-writer-wins (it never overwrites), RotateKeyAsync deletes the existing key, stores the replacement, and then reads it back to verify the store now holds the new key. If the read-back does not match (for example, another writer raced the rotation), it throws so you can retry - the stored key is never silently left unrotated.

Crash window during rotation

If the process crashes between the delete and the store, the scope's HMAC key is lost. This is recoverable: rotate again (which generates a fresh key) and recompute the affected blind indexes - a recompute is required after any rotation anyway.

Queries will return no results during rotation

Between step 1 (new key active) and step 2 (all rows recomputed), blind index queries will fail to find rows whose companion columns still contain old HMAC values. Schedule rotation during a maintenance window or implement a dual-read strategy (query with new key, fall back to old key) for zero-downtime rotation.

When to Rotate ​

HMAC key rotation is less urgent than encryption key rotation because the HMAC key is not directly used to encrypt personal data. Consider rotating when:

  • You have reason to believe the HMAC key has been exposed.
  • You are rotating all secrets as part of a periodic security review.
  • A key store migration requires regenerating all keys.
  • You are changing the transforms on a field (which effectively requires a full recompute anyway).

Key Deletion ​

Deleting an HMAC key removes the ability to:

  • Query by the blind index (new searches return no results).
  • Recompute companion columns for existing rows (the key is gone).

Unlike encryption key deletion (which is the mechanism for crypto-shredding), HMAC key deletion has no GDPR purpose. Do not delete HMAC keys unless you intend to permanently disable querying by that field.

Key Store Compatibility ​

All Tayra key store backends support blind index HMAC keys:

Key StoreSupportedNotes
InMemoryYesTesting only - lost on restart
SQLiteYesDurable, zero-config file-based
PostgreSQLYesDurable, supports prefix listing
HashiCorp VaultYesKV v2 secrets engine
Azure Key VaultYesStored as Key Vault secrets
AWS Parameter StoreYesStored as SecureString parameters

HMAC keys are small (32 bytes each) and rarely change. They do not contribute meaningfully to key store storage costs.

See Also ​