Skip to content

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 ──→ plaintext

The 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:

cs
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; }
}
anchor

Fluent Configuration ​

If you prefer to configure blind indexes without attributes, use the fluent API:

cs
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);
    });
});
anchor

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:

cs
// 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}");
anchor

3. Query by the blind index ​

Apply the same transforms to the search term, compute the HMAC, and query the companion column:

cs
// 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}");
anchor

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:

  1. The member named by IndexPropertyName, if that is set.
  2. The member named by IndexName, if the type has a member with that name.
  3. {PropertyName}Index.

The index name is IndexName if set, otherwise IndexPropertyName if set, otherwise {PropertyName}Index.

[BlindIndex] on EmailIndex nameCompanion
Neither setEmailIndexEmailIndex
IndexName = "Login", the type has a Login memberLoginLogin
IndexName = "Login", no Login memberLoginEmailIndex
IndexPropertyName = "EmailHash"EmailHashEmailHash
IndexName = "Login", IndexPropertyName = "EmailHash"LoginEmailHash

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 is IndexPropertyName if set and {PropertyName}Index otherwise. IndexName never moves it.
  • The fluent API: WithIndexName("Login") chooses the companion of a BlindIndex(...) by the same three steps (and never that of an ArrayBlindIndex(...)), but StoredIn(x => x.EmailHash) only sets the companion. The index name stays EmailIndex unless WithIndexName is 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 EmailHash is not the same key that encrypts Email. They are stored under different prefixes in the key store.
  • Transforms normalize input before hashing. Applying lowercase and trim means "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 ​