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:{fieldHash} | bi:customer_email |
The field hash component is derived from the entity type name, property name, and configured transforms. It is stable across application restarts.
Blind index keys are not per-subject
Encryption keys are scoped to a data subject. Blind index HMAC keys are scoped to a field definition - one key per [BlindIndex]-annotated property. This is what makes querying possible: all rows with the same email address must produce the same HMAC.
Key Scopes
Field-Level Scope (Default)
By default, one HMAC key is shared across all subjects for a given field. Every CustomerEntity.Email blind index is computed with the same key:
bi:customer_email → used for ALL rows in the Customers tableThis is the correct behaviour for searchable fields. You want "jane@example.com" to produce the same HMAC regardless of which customer record it belongs to.
Custom Key Name
You can override the auto-derived key name with an explicit value:
[BlindIndex(
IndexPropertyName = nameof(EmailHash),
Transforms = ["lowercase", "trim"],
Scope = "global_email_index")]
public string Email { get; set; } = "";This is useful when multiple entity types share the same conceptual index (e.g., a shared email lookup across Customer, Supplier, and Employee tables).
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);
}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
