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:

csharp
public interface IKeyStore
{
    // Required — core operations
    Task StoreAsync(string keyId, byte[] key, CancellationToken ct = default);
    Task<byte[]?> GetAsync(string keyId, CancellationToken ct = default);
    Task DeleteAsync(string keyId, CancellationToken ct = default);
    Task<bool> ExistsAsync(string keyId, CancellationToken ct = default);
    Task DeleteByPrefixAsync(string prefix, CancellationToken ct = default);

    // Optional — default implementations throw NotSupportedException
    Task<IReadOnlyList<string>> ListKeyIdsAsync(
        string prefix, CancellationToken ct = default);
    Task<IReadOnlyList<KeyInfo>> GetKeysCreatedBeforeAsync(
        DateTimeOffset cutoff, int limit = 100, CancellationToken ct = default);
}

Method Contracts

MethodContract
StoreAsyncMust be idempotent. If the key already exists, do nothing (do not overwrite).
GetAsyncReturn null if the key does not exist or has been deleted. Never throw for missing keys.
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 Store

StoreAsync must be idempotent. If called twice with the same keyId, the second call should be a no-op. 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.

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:

csharp
public class CachingKeyStore : IKeyStore
{
    private readonly IKeyStore _inner;
    private readonly IMemoryCache _cache;

    public CachingKeyStore(IKeyStore inner, IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    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, TimeSpan.FromMinutes(5));

        return key;
    }

    // Delegate all other methods to _inner...
}

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