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
/// <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.");
}Method Reference
| Method | Required | Purpose |
|---|---|---|
StoreAsync | Yes | Stores 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. |
GetAsync | Yes | Retrieves a key by ID. Returns null if the key does not exist (has been deleted/shredded). |
GetManyAsync | Optional | Retrieves several keys in one call; missing keys are absent from the result. The default implementation loops GetAsync. |
DeleteAsync | Yes | Deletes a key. This is the crypto-shredding primitive. Must be idempotent. |
ExistsAsync | Yes | Returns true if the key exists, false otherwise. |
DeleteByPrefixAsync | Yes | Deletes all keys matching a prefix. Used for bulk crypto-shredding (e.g., all groups for a subject). |
ListKeyIdsAsync | Optional | Lists key IDs matching a prefix. Used for key rotation to discover versioned keys. Throws NotSupportedException by default. |
GetKeysCreatedBeforeAsync | Optional | Returns 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:
| Operation | Behavior |
|---|---|
StoreAsync with existing key ID | No-op. Does not overwrite the existing key. Does not throw. |
DeleteAsync with non-existent key ID | No-op. Does not throw. |
DeleteByPrefixAsync with no matching keys | No-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
StoreAsyncthe engine reads the key back withGetAsyncand 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
| Package | Backend | Thread-Safe | ListKeyIdsAsync | GetKeysCreatedBeforeAsync |
|---|---|---|---|---|
Tayra.Core (built-in) | ConcurrentDictionary | Yes | Yes | Yes |
Tayra.KeyStore.PostgreSql | PostgreSQL table | Yes | Yes | Yes |
Tayra.KeyStore.Vault | HashiCorp Vault KV v2 | Yes | Yes | No |
Tayra.KeyStore.AzureKeyVault | Azure Key Vault secrets | Yes | Yes | No |
Tayra.KeyStore.AwsParameterStore | AWS SSM Parameter Store | Yes | Yes | No |
Registration
By default, AddTayra() uses the built-in InMemoryKeyStore. For production, each key store package provides an extension method that chains from AddTayra():
// 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 */ });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:
/// <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;
}
}var services = new ServiceCollection();
services.AddTayra(opts => opts.LicenseKey = licenseKey);
// Register your custom IKeyStore implementation
services.AddSingleton<IKeyStore, MyCustomKeyStore>();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
- Crypto Engine - How the key store is used by the crypto engine
- Encryption - What the keys protect
- Field Encrypter - The high-level encryption API
- Installation - Package list and key store options
