diff --git a/README.md b/README.md index 7ddbd07..e6bbf48 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,14 @@ 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 | API | +| Platform | Backing store | Native API | |----------|--------------|-----| -| Windows | Windows Credential Manager | `advapi32` (`CredRead`/`CredWrite`/`CredDelete`) | -| macOS | Keychain Services | `Security.framework` (`SecKeychain*`) | -| Linux | freedesktop.org Secret Service | `libsecret-1.so.0` | +| 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 — the library never writes a plaintext blob to disk. +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. ## Installation @@ -26,14 +26,27 @@ Each persona's credential is stored as its own entry in the OS keyring — t dotnet add package ktsu.CredentialCache ``` +Requires .NET 9 or .NET 10. + +### Linux runtime prerequisites + +The Linux store requires `libsecret` and a running Secret Service implementation (gnome-keyring, KWallet's secret-service bridge, KeePassXC, …). On Debian/Ubuntu: + +```bash +sudo apt-get install libsecret-1-0 gnome-keyring +``` + +On 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`). + ## Quick start ```csharp using ktsu.CredentialCache; using ktsu.CredentialCache.Storage; +using ktsu.Semantics.Strings; // Pick the platform-native store explicitly... -ICredentialStore store = CredentialStoreFactory.CreateDefault(); +ICredentialStore store = CredentialStoreFactory.CreateDefault("MyApp"); using CredentialCache cache = new(store); // ...or just use the singleton, which calls CreateDefault() on first access. @@ -43,8 +56,8 @@ PersonaGUID persona = CredentialCache.CreatePersonaGUID(); cache.AddOrReplace(persona, new CredentialWithUsernamePassword { - Username = ktsu.Semantics.Strings.SemanticString.Create("alice"), - Password = ktsu.Semantics.Strings.SemanticString.Create("hunter2"), + Username = SemanticString.Create("alice"), + Password = SemanticString.Create("hunter2"), }); if (cache.TryGet(persona, out Credential? stored) @@ -56,19 +69,52 @@ if (cache.TryGet(persona, out Credential? stored) cache.Remove(persona); ``` +### Pick your own service name + +`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. + ## Credential types -- `CredentialWithNothing` — sentinel for "no credential required". -- `CredentialWithToken` — opaque bearer / API token. -- `CredentialWithUsernamePassword` — classic username + password pair. +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 | -New credential types must derive from `Credential` and be registered with a `[JsonDerivedType]` attribute on `Credential` so polymorphic serialization round-trips through the keyring entry. +### Adding your own credential type + +`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: + +```csharp +// 1. Add the subclass. +public sealed class CredentialWithCertificate : Credential +{ + public string Thumbprint { get; init; } = ""; +} + +// 2. Register it on the base in Credential.cs. +[JsonDerivedType(typeof(CredentialWithCertificate), nameof(CredentialWithCertificate))] +public abstract class Credential { /* ... */ } + +// 3. Optional: register a factory so TryCreate works. +public sealed class CertificateFactory : ICredentialFactory +{ + public CredentialWithCertificate Create() => new(); +} +cache.RegisterCredentialFactory(new CertificateFactory()); +``` + +If a subclass uses `SemanticString` properties, they round-trip through `ktsu.RoundTripStringJsonConverter` automatically. ## Customising the backing store -`ICredentialStore` is a small CRUD interface (`TryLoad`/`Save`/`Remove`/`EnumerateKeys`). Bring your own implementation when you need a different backend (HashiCorp Vault, an encrypted file, a test double): +`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): ```csharp +public sealed class MyCustomStore : ICredentialStore { /* ... */ } + ICredentialStore store = new MyCustomStore(); CredentialCache.ConfigureStore(store); // must be called before first Instance access ``` @@ -79,12 +125,30 @@ For unit tests, use the in-memory store and skip the singleton entirely: using CredentialCache cache = new(new InMemoryCredentialStore()); ``` +### Enumerating stored personas + +`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: + +```csharp +if (cache.Store is ISearchableCredentialStore searchable) +{ + foreach (PersonaGUID key in searchable.EnumerateKeys()) + { + // ... + } +} +else +{ + // Track persona GUIDs yourself on macOS / Linux. +} +``` + ## Platform notes -- **Windows Credential Manager** caps the credential blob at 2560 bytes. Tokens larger than that will throw `CredentialStoreException` — split or compress before storing. -- **Linux** requires `libsecret-1` (e.g. `apt install libsecret-1-0`) plus a running Secret Service implementation (gnome-keyring, KWallet's secret-service bridge, KeePassXC, …). Headless CI agents typically have neither — use `InMemoryCredentialStore` there. +- **Windows Credential Manager** caps the credential blob at 2560 bytes (`5 * 512`). Tokens larger than that throw `CredentialStoreException` — 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. -- `EnumerateKeys()` is fully implemented on Windows. On macOS and Linux it currently returns an empty sequence (implementing it would require substantially more native marshalling for a use-case most consumers can satisfy by tracking persona GUIDs themselves). +- **Linux** requires `libsecret-1` plus an active Secret Service. Headless CI agents typically have neither — use `InMemoryCredentialStore` there, or set up `dbus-run-session` + `gnome-keyring-daemon` as the `cross-platform.yml` workflow 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 — treat `Save` / `Remove` as I/O, not as cheap accessors. ## API summary @@ -94,10 +158,11 @@ using CredentialCache cache = new(new InMemoryCredentialStore()); |--------|-------------| | `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 fallthrough to the backing store. | +| `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(factory)` | Optional factory hook used by `TryCreate`. | @@ -108,11 +173,20 @@ using CredentialCache cache = new(new InMemoryCredentialStore()); | Member | Description | |--------|-------------| -| `Name` | Diagnostic identifier (`"Windows Credential Manager"`, `"macOS Keychain"`, `"Linux libsecret (Secret Service)"`, `"InMemory"`). | +| `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. | -| `EnumerateKeys()` | Enumerate persona keys (Windows only by default). | + +### `ISearchableCredentialStore : ICredentialStore` + +| Member | Description | +|--------|-------------| +| `EnumerateKeys()` | Enumerate every persona key currently persisted (Windows, in-memory). | + +## Don't dispose the singleton + +`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)`. ## License