Blind Indexes
Tayra encrypts [PersonalData] fields with AES-256-GCM, which produces a different ciphertext every time the same plaintext is encrypted. This is the correct security behaviour - but it means you cannot query the database for records by an encrypted value. A WHERE email = @email clause on a ciphertext column will never match.
Blind indexes solve this problem by generating a deterministic, one-way fingerprint - an HMAC - of the plaintext value and storing it in a separate companion column. Queries target the HMAC column instead of the encrypted column.
Tayra ships three blind-index attributes:
[BlindIndex]- one HMAC over a single encrypted member. Works on any type Tayra can canonicalize:string,int,long,DateOnly,DateTime,Guid, enums, and their nullable forms.[CompoundBlindIndex]- one HMAC over several fields combined, for multi-field lookups.[ArrayBlindIndex]- one HMAC per element of an encrypted string collection (string[],List<string>,HashSet<string>, ...), so you can query "contains this value". See Array Blind Indexes.
How It Works
Write path
──────────
plaintext ──→ transforms ──→ HMAC-SHA256(key, normalized) ──→ companion column
plaintext ──→ AES-256-GCM encrypt ──→ encrypted column
Query path
──────────
search term ──→ same transforms ──→ HMAC-SHA256(key, normalized) ──→ WHERE companion_col = ?
──→ load row ──→ decrypt ──→ plaintextThe HMAC key is separate from the encryption key. Destroying the encryption key shreds the plaintext data; the HMAC column retains an uninvertible fingerprint.
Quick Start
1. Annotate the model
Apply [BlindIndex] alongside [PersonalData] and add a companion property to hold the HMAC:
public class BlindIndexedCustomer
{
[DataSubjectId]
public Guid Id { get; set; }
[PersonalData]
[BlindIndex(
IndexPropertyName = nameof(EmailHash),
Transforms = ["lowercase", "trim"])]
public string Email { get; set; } = "";
/// <summary>
/// Companion property - stores the HMAC hash of Email for queries.
/// </summary>
public string? EmailHash { get; set; }
}Fluent Configuration
If you prefer to configure blind indexes without attributes, use the fluent API:
var biFluentServices = new ServiceCollection();
biFluentServices.AddTayra(opts =>
{
opts.LicenseKey = licenseKey;
opts.Entity<FluentCustomer>(e =>
{
e.DataSubjectId(c => c.CustomerId);
e.PersonalData(c => c.Email);
e.BlindIndex(c => c.Email)
.WithLowercase().WithTrim()
.StoredIn(c => c.EmailIndex);
});
});Both attribute and fluent configurations produce identical behavior. See Fluent API for more details.
2. Save a record
When EncryptAsync is called, Tayra automatically computes and writes the HMAC into the companion property before persisting:
// Create a customer and encrypt - Tayra computes the HMAC automatically
var indexed = new BlindIndexedCustomer
{
Id = Guid.NewGuid(),
Email = "jane@example.com",
};
await biTayra.EncryptAsync(indexed);
// indexed.Email is now AES-256-GCM ciphertext
// indexed.EmailHash is the HMAC-SHA256 hash of "jane@example.com" (lowercased, trimmed)
Console.WriteLine($" EmailHash: {indexed.EmailHash}");3. Query by the blind index
Apply the same transforms to the search term, compute the HMAC, and query the companion column:
// To query, compute the same HMAC for the search term
string searchHash = await biTayra.ComputeBlindIndexAsync(
"jane@example.com", "EmailHash", typeof(BlindIndexedCustomer));
// Use the hash in a WHERE clause:
// db.Query<Customer>().Where(c => c.EmailHash == searchHash)
Console.WriteLine($" Search hash matches: {searchHash == indexed.EmailHash}");Index Name and Companion
Every blind index has two names: the index name, which a query passes to ComputeBlindIndexAsync, and the companion, the property or field the hash is written to. Both default to {PropertyName}Index.
For [BlindIndex], the companion is the first of these that applies:
- The member named by
IndexPropertyName, if that is set. - The member named by
IndexName, if the type has a member with that name. {PropertyName}Index.
The index name is IndexName if set, otherwise IndexPropertyName if set, otherwise {PropertyName}Index.
[BlindIndex] on Email | Index name | Companion |
|---|---|---|
| Neither set | EmailIndex | EmailIndex |
IndexName = "Login", the type has a Login member | Login | Login |
IndexName = "Login", no Login member | Login | EmailIndex |
IndexPropertyName = "EmailHash" | EmailHash | EmailHash |
IndexName = "Login", IndexPropertyName = "EmailHash" | Login | EmailHash |
So IndexName alone moves the companion only when a member of that name exists: the hash then goes to Login, and an EmailIndex member, if there is one as well, stays empty. To name the index and say where the hash goes, set both. An explicit IndexPropertyName is taken at its word: if no member has that name, nothing is stored, and analyzer TAYRA005 reports it.
Two cases differ:
[ArrayBlindIndex]: the companion isIndexPropertyNameif set and{PropertyName}Indexotherwise.IndexNamenever moves it.- The fluent API:
WithIndexName("Login")chooses the companion of aBlindIndex(...)by the same three steps (and never that of anArrayBlindIndex(...)), butStoredIn(x => x.EmailHash)only sets the companion. The index name staysEmailIndexunlessWithIndexNameis called.
For [CompoundBlindIndex] there is no single source member, so the companion defaults to the IndexName.
Changing in Tayra 3.0
From 3.0 the index name and the companion will be independent, each defaulting to {PropertyName}Index, for [BlindIndex], [ArrayBlindIndex] and the fluent API alike: step 2 above goes away, and IndexPropertyName alone no longer renames the index. A model that names both explicitly behaves the same before and after. Analyzer TAYRA018 reports the attribute configurations that will change, and a fluent BlindIndex(...) whose companion is the member named by WithIndexName(...) logs a warning at startup.
Security Model
- HMAC-SHA256 is one-way. You cannot reverse a blind index back to plaintext.
- HMAC keys are separate from encryption keys. The key used to compute
EmailHashis not the same key that encryptsEmail. They are stored under different prefixes in the key store. - Transforms normalize input before hashing. Applying
lowercaseandtrimmeans"jane@example.com","Jane@example.com", and" jane@example.com "all produce the same HMAC. This prevents trivial enumeration bypasses. - Frequency analysis is possible. If a field has low cardinality (e.g., a status field with three possible values), an attacker who can read the database can learn the distribution. See Security Considerations for mitigation strategies.
Blind indexes reveal that two rows share the same value
If two customers have the same email address, their EmailHash values will be identical. This is intentional - it is what makes querying possible - but it is a security trade-off. Do not use blind indexes on highly sensitive fields with very low cardinality.
NuGet Package
Blind index support is included in Tayra.Core. No additional package is required.
In This Section
- Querying - EF Core and Marten query examples
- Array Blind Indexes - Searchable encrypted string collections with
[ArrayBlindIndex] - Transforms - Built-in normalisation transforms and custom transforms
- Security - Threat model, cardinality risks, and guidance
- Key Management - HMAC key storage, scoping, and rotation
See Also
[PersonalData]- Encryption attribute- EF Core Integration - Transparent encryption with EF Core
- Marten Integration - Document store encryption
