Skip to content

Custom Key Store ​

If none of the built-in key store providers fit your infrastructure, you can implement the IKeyStore interface to build your own. This is useful for integrating with databases or secrets managers that Tayra does not support out of the box (e.g., Redis, Cosmos DB, GCP Secret Manager).

When to Build Custom ​

Consider a custom key store when:

  • You use a storage backend not covered by the built-in providers
  • You have an existing secrets management system you want to reuse
  • You need specialized behavior (e.g., multi-region replication, custom encryption)
  • You want to wrap an existing key store with caching, logging, or metrics

IKeyStore Interface ​

Your custom key store must implement all required methods of the IKeyStore interface. GetManyAsync, ListKeyIdsAsync and GetKeysCreatedBeforeAsync have default implementations:

cs
/// <summary>
/// Abstraction for storing and retrieving encryption keys.
/// Implementations provide persistence (PostgreSQL, Vault, in-memory, etc.).
/// </summary>
public interface IKeyStore
{
    /// <summary>
    /// Stores a key. Implementations must be idempotent and first-writer-wins:
    /// if the key ID already exists, the call is a no-op and the existing key bytes
    /// are kept. Callers that need the authoritative key material must read it back
    /// with <see cref="GetAsync"/> after storing rather than assuming the bytes they
    /// passed were persisted.
    /// </summary>
    Task StoreAsync(string keyId, byte[] key, CancellationToken ct = default);

    /// <summary>
    /// Retrieves a key by ID. Returns null if the key does not exist (e.g., has been shredded).
    /// </summary>
    Task<byte[]?> GetAsync(string keyId, CancellationToken ct = default);

    /// <summary>
    /// Retrieves multiple keys in a single call. Returns a map of found key ID to key bytes.
    /// Key IDs that do not exist (absent or shredded) are simply NOT present in the returned map,
    /// so callers can detect misses by looking up each requested ID.
    /// </summary>
    /// <remarks>
    /// The default implementation loops <see cref="GetAsync"/> so every store works out of the box.
    /// Fast backends (PostgreSQL, SQLite, in-memory) override this with a single batched query to
    /// collapse millions of per-subject round trips into a handful of bulk reads; stores with no
    /// native multi-get (Vault, cloud secret stores) fall back to the loop.
    /// </remarks>
    async Task<IReadOnlyDictionary<string, byte[]>> GetManyAsync(
        IReadOnlyCollection<string> keyIds, CancellationToken ct = default)
    {
        ArgumentNullException.ThrowIfNull(keyIds);

        var result = new Dictionary<string, byte[]>(keyIds.Count);
        foreach (var keyId in keyIds)
        {
            var key = await GetAsync(keyId, ct).ConfigureAwait(false);
            if (key is not null)
            {
                result[keyId] = key;
            }
        }

        return result;
    }

    /// <summary>
    /// Deletes a key by ID. This is the crypto-shredding operation.
    /// </summary>
    Task DeleteAsync(string keyId, CancellationToken ct = default);

    /// <summary>
    /// Checks whether a key exists.
    /// </summary>
    Task<bool> ExistsAsync(string keyId, CancellationToken ct = default);

    /// <summary>
    /// Deletes all keys matching the given prefix. Used for bulk crypto-shredding
    /// (e.g., deleting all keys for a data subject across groups).
    /// </summary>
    Task DeleteByPrefixAsync(string prefix, CancellationToken ct = default);

    /// <summary>
    /// Lists all key IDs matching the given prefix.
    /// Used for key rotation to discover versioned keys.
    /// </summary>
    Task<IReadOnlyList<string>> ListKeyIdsAsync(string prefix, CancellationToken ct = default)
        => throw new NotSupportedException("This key store does not support listing key IDs.");

    /// <summary>
    /// Returns keys that were created before the specified cutoff time.
    /// Used by the key retention background service to find expired keys.
    /// </summary>
    Task<IReadOnlyList<Retention.KeyInfo>> GetKeysCreatedBeforeAsync(
        DateTimeOffset cutoff, int limit = 100, CancellationToken ct = default)
        => throw new NotSupportedException("This key store does not support querying keys by creation time.");
}
anchor

Method Contracts ​

MethodContract
StoreAsyncMust be idempotent and first-writer-wins. If the key already exists, do nothing (do not overwrite) - the existing key bytes are kept.
GetAsyncReturn null if the key does not exist or has been deleted. Never throw for missing keys.
GetManyAsyncOptional. Return the found keys in one call; omit missing keys from the map. The default implementation loops GetAsync, so override it only if your backend supports a batched read.
DeleteAsyncDelete the key permanently. Must be safe to call on non-existent keys (no-op).
ExistsAsyncReturn true if the key exists and has not been deleted.
DeleteByPrefixAsyncDelete all keys whose IDs start with the given prefix. Used for bulk crypto-shredding.
ListKeyIdsAsyncOptional. Return all key IDs matching the prefix. Needed for key rotation.
GetKeysCreatedBeforeAsyncOptional. Return keys older than the cutoff. Needed for data retention policies.

Example Implementation ​

cs
/// <summary>
/// Example custom key store implementation.
/// Replace with your own persistence logic (Redis, Cosmos DB, etc.).
/// </summary>
public class MyCustomKeyStore : IKeyStore
{
    private readonly Dictionary<string, byte[]> _store = new();
    private readonly object _lock = new();

    public Task StoreAsync(string keyId, byte[] key, CancellationToken ct = default)
    {
        lock (_lock)
        {
            _store.TryAdd(keyId, key);
        }

        return Task.CompletedTask;
    }

    public Task<byte[]?> GetAsync(string keyId, CancellationToken ct = default)
    {
        lock (_lock)
        {
            return Task.FromResult(_store.GetValueOrDefault(keyId));
        }
    }

    public Task DeleteAsync(string keyId, CancellationToken ct = default)
    {
        lock (_lock)
        {
            _store.Remove(keyId);
        }

        return Task.CompletedTask;
    }

    public Task<bool> ExistsAsync(string keyId, CancellationToken ct = default)
    {
        lock (_lock)
        {
            return Task.FromResult(_store.ContainsKey(keyId));
        }
    }

    public Task DeleteByPrefixAsync(string prefix, CancellationToken ct = default)
    {
        lock (_lock)
        {
            var keysToRemove = _store.Keys
                .Where(k => k.StartsWith(prefix, StringComparison.Ordinal))
                .ToList();

            foreach (var key in keysToRemove)
            {
                _store.Remove(key);
            }
        }

        return Task.CompletedTask;
    }
}
anchor

Registration ​

Register your custom key store with the DI container:

cs
var services = new ServiceCollection();

services.AddTayra(opts => opts.LicenseKey = licenseKey);

// Register your custom IKeyStore implementation
services.AddSingleton<IKeyStore, MyCustomKeyStore>();
anchor

Singleton Lifetime

Register your key store as a singleton (AddSingleton). Tayra resolves IKeyStore once at startup and uses it for the lifetime of the application. If your key store holds external connections, implement IDisposable and the DI container will dispose it on shutdown.

Implementation Guidelines ​

Thread Safety

Your IKeyStore implementation must be thread-safe. Tayra may call key store methods concurrently from multiple threads. Use thread-safe collections, synchronization primitives, or inherently thread-safe clients (e.g., HttpClient, most database clients).

Idempotent, First-Writer-Wins Store

StoreAsync must be idempotent and first-writer-wins. If called twice with the same keyId, the second call must be a no-op that keeps the existing key bytes. This is because Tayra may retry operations or call StoreAsync defensively. Overwriting an existing key with a different value would break decryption of previously encrypted data.

Tayra's callers honor this contract: after storing a key, the DefaultCryptoEngine (and the blind-index key provider) read the key back with GetAsync and use whatever the store actually holds, rather than assuming the bytes they passed were persisted. This eliminates a cross-process race where data could otherwise be encrypted with a key that was never stored. Your implementation just needs to make the store-then-read sequence consistent: once StoreAsync returns, GetAsync for that key ID must return the winning bytes.

Best Practices ​

  • Handle transient errors - Implement retry logic with exponential backoff for network-dependent backends.
  • Respect cancellation tokens - Pass the CancellationToken through to all async calls.
  • Log appropriately - Inject ILogger<T> and log warnings for retries, errors for permanent failures.
  • Secure key material - Ensure keys are encrypted at rest in your chosen backend. Never log key bytes.
  • Clean up on delete - When DeleteAsync is called, the key must be permanently removed. This is the crypto-shredding guarantee.
  • Support prefix operations - DeleteByPrefixAsync is critical for GDPR right-to-erasure. Ensure your backend supports efficient prefix-based queries or scans.

Wrapping an Existing Key Store ​

You can also create a decorator that wraps an existing IKeyStore to add cross-cutting concerns. Forward every member to the inner store, including the optional ones: if the decorator relies on an interface default, ListKeyIdsAsync and GetKeysCreatedBeforeAsync throw NotSupportedException even when the inner store supports them.

cs
public sealed class CachingKeyStore : IKeyStore, IDisposable
{
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);

    private readonly IKeyStore _inner;

    // A dedicated cache, so a prefix delete can safely evict every entry.
    private readonly MemoryCache _cache = new(new MemoryCacheOptions());

    public CachingKeyStore(IKeyStore inner)
    {
        _inner = inner;
    }

    public async Task<byte[]?> GetAsync(string keyId, CancellationToken ct = default)
    {
        if (_cache.TryGetValue(keyId, out byte[]? cached))
        {
            return cached;
        }

        var key = await _inner.GetAsync(keyId, ct);
        if (key is not null)
        {
            _cache.Set(keyId, key, CacheDuration);
        }

        return key;
    }

    public async Task DeleteAsync(string keyId, CancellationToken ct = default)
    {
        await _inner.DeleteAsync(keyId, ct);

        // Evict, or a shredded key keeps decrypting until the entry expires.
        _cache.Remove(keyId);
    }

    public async Task DeleteByPrefixAsync(string prefix, CancellationToken ct = default)
    {
        await _inner.DeleteByPrefixAsync(prefix, ct);

        // MemoryCache cannot enumerate keys by prefix, so evict everything.
        _cache.Compact(1.0);
    }

    // Delegate everything else to _inner, including the optional members:
    // an interface default here would throw NotSupportedException even when
    // the inner store supports the operation.
    public Task StoreAsync(string keyId, byte[] key, CancellationToken ct = default)
        => _inner.StoreAsync(keyId, key, ct);

    public Task<bool> ExistsAsync(string keyId, CancellationToken ct = default)
        => _inner.ExistsAsync(keyId, ct);

    public Task<IReadOnlyDictionary<string, byte[]>> GetManyAsync(
        IReadOnlyCollection<string> keyIds, CancellationToken ct = default)
        => _inner.GetManyAsync(keyIds, ct);

    public Task<IReadOnlyList<string>> ListKeyIdsAsync(string prefix, CancellationToken ct = default)
        => _inner.ListKeyIdsAsync(prefix, ct);

    public Task<IReadOnlyList<KeyInfo>> GetKeysCreatedBeforeAsync(
        DateTimeOffset cutoff, int limit = 100, CancellationToken ct = default)
        => _inner.GetKeysCreatedBeforeAsync(cutoff, limit, ct);

    public void Dispose() => _cache.Dispose();
}
anchor

Cache Invalidation

If you add a caching layer, make sure DeleteAsync evicts the key from the cache. Otherwise, crypto-shredding will appear to succeed but decryption will continue to work until the cache entry expires.

See Also ​