A cross-platform credential cache for .NET that stores secrets in the host's native keyring.
CredentialCache keeps credentials in memory for fast lookup during the lifetime of a process and persists each one through an ICredentialStore whose default implementation delegates to the platform-native secret manager:
| Platform | Backing store | Native API |
|---|---|---|
| Windows | Credential Manager | advapi32 — CredReadW / CredWriteW / CredDeleteW / CredEnumerateW |
| macOS | Keychain Services | Security.framework — SecKeychainAddGenericPassword and friends |
| Linux | freedesktop.org Secret Service | libsecret-1.so.0 — secret_password_store_sync / lookup_sync / clear_sync |
| Other / opt-out | None | InMemoryCredentialStore |
Each persona's credential is stored as its own entry in the OS keyring scoped by a service name — the library never writes a plaintext blob to disk.
dotnet add package ktsu.CredentialCacheRequires .NET 9 or .NET 10.
The Linux store requires libsecret and a running Secret Service implementation (gnome-keyring, KWallet's secret-service bridge, KeePassXC, …). On Debian/Ubuntu:
sudo apt-get install libsecret-1-0 gnome-keyringOn headless or CI hosts you'll typically need a session bus and an unlocked keyring — see the cross-platform.yml workflow in this repo for the dbus-run-session + gnome-keyring-daemon incantation. If a Secret Service isn't available in your deployment, fall back to InMemoryCredentialStore (or roll your own ICredentialStore).
usingktsu.CredentialCache;usingktsu.CredentialCache.Storage;usingktsu.Semantics.Strings;// Pick the platform-native store explicitly...ICredentialStorestore=CredentialStoreFactory.CreateDefault("MyApp");usingCredentialCachecache=new(store);// ...or just use the singleton, which calls CreateDefault() on first access.CredentialCachesingleton=CredentialCache.Instance;PersonaGUIDpersona=CredentialCache.CreatePersonaGUID();cache.AddOrReplace(persona,newCredentialWithUsernamePassword{Username=SemanticString<CredentialUsername>.Create("alice"),Password=SemanticString<CredentialPassword>.Create("hunter2"),});if(cache.TryGet(persona,outCredential?stored)&&storedisCredentialWithUsernamePasswordcreds){Console.WriteLine($"Hello, {creds.Username}");}cache.Remove(persona);CredentialStoreFactory.CreateDefault(serviceName) scopes entries by a logical service name (defaults to "ktsu.CredentialCache"). If two applications share a host, pass per-app names so their keyring entries don't collide.
The library ships with three concrete Credential subclasses:
| Type | Use it for |
|---|---|
CredentialWithNothing | Sentinel for "no credential required" |
CredentialWithToken | Opaque bearer or API token |
CredentialWithUsernamePassword | Classic username + password pair |
Credential is a polymorphic record class round-tripped through System.Text.Json. New subclasses need a [JsonDerivedType] on the base so deserialization can resolve them:
// 1. Add the subclass.publicsealedclassCredentialWithCertificate:Credential{publicstringThumbprint{get;init;}="";}// 2. Register it on the base in Credential.cs.[JsonDerivedType(typeof(CredentialWithCertificate),nameof(CredentialWithCertificate))]publicabstractclassCredential{/* ... */}// 3. Optional: register a factory so TryCreate<T> works.publicsealedclassCertificateFactory:ICredentialFactory<CredentialWithCertificate>{publicCredentialWithCertificateCreate()=>new();}cache.RegisterCredentialFactory(newCertificateFactory());If a subclass uses SemanticString<T> properties, they round-trip through ktsu.RoundTripStringJsonConverter automatically.
ICredentialStore is a small CRUD interface (TryLoad / Save / Remove). Bring your own implementation when you need a different backend (HashiCorp Vault, an encrypted file, a test double):
publicsealedclassMyCustomStore:ICredentialStore{/* ... */}ICredentialStorestore=newMyCustomStore();CredentialCache.ConfigureStore(store);// must be called before first Instance accessFor unit tests, use the in-memory store and skip the singleton entirely:
usingCredentialCachecache=new(newInMemoryCredentialStore());ICredentialStore deliberately has no EnumerateKeys method, because macOS Keychain and libsecret require substantially more native marshalling for enumeration than the simple key-value ops. The optional ISearchableCredentialStore interface adds it, and only the Windows and in-memory stores implement it:
if(cache.StoreisISearchableCredentialStoresearchable){foreach(PersonaGUIDkeyinsearchable.EnumerateKeys()){// ...}}else{// Track persona GUIDs yourself on macOS / Linux.}- Windows Credential Manager caps the credential blob at 2560 bytes (
5 * 512). Tokens larger than that throwCredentialStoreException— split or compress before storing. - macOS uses the user's default login keychain. The first access from an application prompts the user for permission, as with any keychain client.
- Linux requires
libsecret-1plus an active Secret Service. Headless CI agents typically have neither — useInMemoryCredentialStorethere, or set updbus-run-session+gnome-keyring-daemonas thecross-platform.ymlworkflow does. - All native calls happen on the thread the API is invoked from. The library's in-memory cache is thread-safe (
ConcurrentDictionary); the native APIs themselves are documented as thread-safe by their respective platform owners, but blocking calls (especially libsecret) are not cheap — treatSave/Removeas I/O, not as cheap accessors.
| Member | Description |
|---|---|
CredentialCache(ICredentialStore store) | Construct an instance with an explicit store. |
static Instance | Process-wide singleton (lazy, thread-safe). |
Store | The backing store passed to the constructor. |
static ConfigureStore(ICredentialStore) | Override the singleton's store. Must precede first Instance access. |
static ResetSingletonForTesting() | Dispose the singleton and clear configuration. Tests only. |
static CreatePersonaGUID() | Allocates a new PersonaGUID. |
TryGet(persona, out cred) | Memory-cache lookup with fall-through to the backing store. |
AddOrReplace(persona, cred) | Persists eagerly through the store. |
Remove(persona) | Deletes from both the in-memory cache and the store. |
RegisterCredentialFactory<T>(factory) | Optional factory hook used by TryCreate<T>. |
TryCreate<T>(out cred) | Constructs a credential via a registered factory. |
Dispose() | Releases in-memory state. The OS store is left untouched. |
| Member | Description |
|---|---|
Name | Diagnostic identifier (e.g. "Windows Credential Manager", "macOS Keychain", "Linux libsecret (Secret Service)", "InMemory"). |
TryLoad(persona, out cred) | Load a single credential. |
Save(persona, cred) | Persist or overwrite a single credential. |
Remove(persona) | Delete a single credential. |
| Member | Description |
|---|---|
EnumerateKeys() | Enumerate every persona key currently persisted (Windows, in-memory). |
CredentialCache.Instance returns a process-wide singleton. Calling Dispose() on it (e.g. via using var c = CredentialCache.Instance;) puts the singleton in a disposed state and the next consumer in the process gets ObjectDisposedException. If you need disposal semantics, construct your own instance with new CredentialCache(store).
MIT — see LICENSE.md.