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 type | Key store entry | Example |
|---|---|---|
| Encryption key | {prefix}{subjectId} | cust-42 |
| Blind index HMAC key | bi:{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:
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; }
}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:
// 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));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:
- Generate a new HMAC key in the key store.
- Load all affected rows in batches.
- For each row: decrypt the field, apply transforms, compute new HMAC, write companion column.
- Commit the new key as active.
// 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);
}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 Store | Supported | Notes |
|---|---|---|
InMemory | Yes | Testing only - lost on restart |
SQLite | Yes | Durable, zero-config file-based |
PostgreSQL | Yes | Durable, supports prefix listing |
HashiCorp Vault | Yes | KV v2 secrets engine |
Azure Key Vault | Yes | Stored as Key Vault secrets |
AWS Parameter Store | Yes | Stored 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
- Blind Indexes Overview - How HMAC blind indexes work
- Security Considerations - Threat model and key protection guidance
- Key Stores Overview - Choosing and configuring a key store
- Key Rotation - Rotating encryption keys
