Skip to content

Key Store ​

The IKeyStore interface is Tayra's public extension point for key persistence. This is the one interface you implement when building a custom key store. Tayra never manages key storage directly - it delegates entirely to the key store implementation, which can be backed by PostgreSQL, HashiCorp Vault, Azure Key Vault, AWS Parameter Store, or a simple in-memory dictionary.

TIP

IKeyStore is the only Tayra interface you need to implement directly. For all other operations (encryption, shredding, rotation), use ITayra.

IKeyStore Interface ​

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 Reference ​

MethodRequiredPurpose
StoreAsyncYesStores a key. Must be idempotent and first-writer-wins - storing an existing key ID is a no-op that keeps the existing key bytes, not an error.
GetAsyncYesRetrieves a key by ID. Returns null if the key does not exist (has been deleted/shredded).
GetManyAsyncOptionalRetrieves several keys in one call; missing keys are absent from the result. The default implementation loops GetAsync.
DeleteAsyncYesDeletes a key. This is the crypto-shredding primitive. Must be idempotent.
ExistsAsyncYesReturns true if the key exists, false otherwise.
DeleteByPrefixAsyncYesDeletes all keys matching a prefix. Used for bulk crypto-shredding (e.g., all groups for a subject).
ListKeyIdsAsyncOptionalLists key IDs matching a prefix. Used for key rotation to discover versioned keys. Throws NotSupportedException by default.
GetKeysCreatedBeforeAsyncOptionalReturns keys created before a cutoff time. Used by the key retention background service. Throws NotSupportedException by default.

Idempotency Contract ​

Key store implementations must follow these idempotency rules:

OperationBehavior
StoreAsync with existing key IDNo-op. Does not overwrite the existing key. Does not throw.
DeleteAsync with non-existent key IDNo-op. Does not throw.
DeleteByPrefixAsync with no matching keysNo-op. Does not throw.

This is critical because the DefaultCryptoEngine calls StoreAsync as part of GetOrCreateKeyAsync. A race condition between two writers creating the same key must not corrupt the key - the first writer wins, and subsequent calls are no-ops.

The engine completes the contract from its side:

  • In-process, key creation is serialized per key ID, so concurrent callers in the same process do not each generate a key.
  • Cross-process, after every StoreAsync the engine reads the key back with GetAsync and uses what the store actually holds - never the bytes it generated locally. A losing writer therefore encrypts with the winning key, eliminating a race that could otherwise encrypt data with a key that was never persisted.

Built-in Implementations ​

PackageBackendThread-SafeListKeyIdsAsyncGetKeysCreatedBeforeAsync
Tayra.Core (built-in)ConcurrentDictionaryYesYesYes
Tayra.KeyStore.PostgreSqlPostgreSQL tableYesYesYes
Tayra.KeyStore.VaultHashiCorp Vault KV v2YesYesNo
Tayra.KeyStore.AzureKeyVaultAzure Key Vault secretsYesYesNo
Tayra.KeyStore.AwsParameterStoreAWS SSM Parameter StoreYesYesNo

Registration ​

By default, AddTayra() uses the built-in InMemoryKeyStore. For production, each key store package provides an extension method that chains from AddTayra():

cs
// Default: uses InMemoryKeyStore (suitable for development and testing)
services.AddTayra(opts => opts.LicenseKey = licenseKey);

// Production key stores - choose one:
services.AddTayra(opts => opts.LicenseKey = licenseKey)
    .UsePostgreSqlKeyStore(connectionString);

services.AddTayra(opts => opts.LicenseKey = licenseKey)
    .UseVaultKeyStore(vaultAddress, vaultToken, opts => { /* Vault options */ });

services.AddTayra(opts => opts.LicenseKey = licenseKey)
    .UseAzureKeyVaultKeyStore(vaultUri, opts => { /* Azure options */ });

services.AddTayra(opts => opts.LicenseKey = licenseKey)
    .UseAwsParameterStoreKeyStore(opts => { /* AWS options */ });
anchor

One Key Store Per Application

Register exactly one IKeyStore implementation. If multiple are registered, the last one wins (standard DI behavior). Tayra does not support multi-store routing out of the box.

Custom Implementation ​

To create a custom key store, implement IKeyStore and register it as a singleton. For example, a simple dictionary-backed store:

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
cs
var services = new ServiceCollection();

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

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

Your implementation must be:

  • Thread-safe - Tayra registers key stores as singletons and calls them concurrently.
  • Idempotent - Follow the idempotency contract described above.
  • Secure - Encryption keys are sensitive material. Store them in encrypted-at-rest storage where possible.

Required vs. Optional Methods ​

GetManyAsync has a default implementation that loops GetAsync, so override it only for a batched read. The last two methods in IKeyStore have default implementations that throw NotSupportedException. They are only needed for specific features:

  • ListKeyIdsAsync - Required for key rotation (ITayra.RotateKeyAsync). If your key store does not support this, key rotation will fail at runtime.
  • GetKeysCreatedBeforeAsync - Required for the key retention background service. If your key store does not support this, automatic key expiration is not available.

All five built-in key stores listed above implement ListKeyIdsAsync. Of those, the in-memory and PostgreSQL key stores implement GetKeysCreatedBeforeAsync (they record a creation time per key).

See Also ​