Skip to content

Commit 5093340

Browse files
Merge pull request #62 from ktsu-dev/claude/readme-update
Refresh README for 2.0.0 surface
2 parents 062521c + eab824e commit 5093340

1 file changed

Lines changed: 93 additions & 19 deletions

File tree

README.md

Lines changed: 93 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,42 @@
1111

1212
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:
1313

14-
| Platform | Backing store | API |
14+
| Platform | Backing store | Native API |
1515
|----------|--------------|-----|
16-
| Windows | Windows Credential Manager | `advapi32` (`CredRead`/`CredWrite`/`CredDelete`) |
17-
| macOS | Keychain Services | `Security.framework` (`SecKeychain*`) |
18-
| Linux | freedesktop.org Secret Service | `libsecret-1.so.0` |
16+
| Windows | Credential Manager | `advapi32` — `CredReadW` / `CredWriteW` / `CredDeleteW` / `CredEnumerateW` |
17+
| macOS | Keychain Services | `Security.framework` — `SecKeychainAddGenericPassword` and friends |
18+
| Linux | freedesktop.org Secret Service | `libsecret-1.so.0` — `secret_password_store_sync` / `lookup_sync` / `clear_sync` |
1919
| Other / opt-out | None | `InMemoryCredentialStore` |
2020

21-
Each persona's credential is stored as its own entry in the OS keyring — the library never writes a plaintext blob to disk.
21+
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.
2222

2323
## Installation
2424

2525
```bash
2626
dotnet add package ktsu.CredentialCache
2727
```
2828

29+
Requires .NET 9 or .NET 10.
30+
31+
### Linux runtime prerequisites
32+
33+
The Linux store requires `libsecret` and a running Secret Service implementation (gnome-keyring, KWallet's secret-service bridge, KeePassXC, …). On Debian/Ubuntu:
34+
35+
```bash
36+
sudo apt-get install libsecret-1-0 gnome-keyring
37+
```
38+
39+
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`).
40+
2941
## Quick start
3042

3143
```csharp
3244
using ktsu.CredentialCache;
3345
using ktsu.CredentialCache.Storage;
46+
using ktsu.Semantics.Strings;
3447

3548
// Pick the platform-native store explicitly...
36-
ICredentialStore store = CredentialStoreFactory.CreateDefault();
49+
ICredentialStore store = CredentialStoreFactory.CreateDefault("MyApp");
3750
using CredentialCache cache = new(store);
3851

3952
// ...or just use the singleton, which calls CreateDefault() on first access.
@@ -43,8 +56,8 @@ PersonaGUID persona = CredentialCache.CreatePersonaGUID();
4356

4457
cache.AddOrReplace(persona, new CredentialWithUsernamePassword
4558
{
46-
Username = ktsu.Semantics.Strings.SemanticString<CredentialUsername>.Create("alice"),
47-
Password = ktsu.Semantics.Strings.SemanticString<CredentialPassword>.Create("hunter2"),
59+
Username = SemanticString<CredentialUsername>.Create("alice"),
60+
Password = SemanticString<CredentialPassword>.Create("hunter2"),
4861
});
4962

5063
if (cache.TryGet(persona, out Credential? stored)
@@ -56,19 +69,52 @@ if (cache.TryGet(persona, out Credential? stored)
5669
cache.Remove(persona);
5770
```
5871

72+
### Pick your own service name
73+
74+
`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.
75+
5976
## Credential types
6077

61-
- `CredentialWithNothing` &mdash; sentinel for "no credential required".
62-
- `CredentialWithToken` &mdash; opaque bearer / API token.
63-
- `CredentialWithUsernamePassword` &mdash; classic username + password pair.
78+
The library ships with three concrete `Credential` subclasses:
79+
80+
| Type | Use it for |
81+
|------|-----------|
82+
| `CredentialWithNothing` | Sentinel for "no credential required" |
83+
| `CredentialWithToken` | Opaque bearer or API token |
84+
| `CredentialWithUsernamePassword` | Classic username + password pair |
6485

65-
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.
86+
### Adding your own credential type
87+
88+
`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:
89+
90+
```csharp
91+
// 1. Add the subclass.
92+
public sealed class CredentialWithCertificate : Credential
93+
{
94+
public string Thumbprint { get; init; } = "";
95+
}
96+
97+
// 2. Register it on the base in Credential.cs.
98+
[JsonDerivedType(typeof(CredentialWithCertificate), nameof(CredentialWithCertificate))]
99+
public abstract class Credential { /* ... */ }
100+
101+
// 3. Optional: register a factory so TryCreate<T> works.
102+
public sealed class CertificateFactory : ICredentialFactory<CredentialWithCertificate>
103+
{
104+
public CredentialWithCertificate Create() => new();
105+
}
106+
cache.RegisterCredentialFactory(new CertificateFactory());
107+
```
108+
109+
If a subclass uses `SemanticString<T>` properties, they round-trip through `ktsu.RoundTripStringJsonConverter` automatically.
66110

67111
## Customising the backing store
68112

69-
`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):
113+
`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):
70114

71115
```csharp
116+
public sealed class MyCustomStore : ICredentialStore { /* ... */ }
117+
72118
ICredentialStore store = new MyCustomStore();
73119
CredentialCache.ConfigureStore(store); // must be called before first Instance access
74120
```
@@ -79,12 +125,30 @@ For unit tests, use the in-memory store and skip the singleton entirely:
79125
using CredentialCache cache = new(new InMemoryCredentialStore());
80126
```
81127

128+
### Enumerating stored personas
129+
130+
`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:
131+
132+
```csharp
133+
if (cache.Store is ISearchableCredentialStore searchable)
134+
{
135+
foreach (PersonaGUID key in searchable.EnumerateKeys())
136+
{
137+
// ...
138+
}
139+
}
140+
else
141+
{
142+
// Track persona GUIDs yourself on macOS / Linux.
143+
}
144+
```
145+
82146
## Platform notes
83147

84-
- **Windows Credential Manager** caps the credential blob at 2560 bytes. Tokens larger than that will throw `CredentialStoreException` &mdash; split or compress before storing.
85-
- **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, &hellip;). Headless CI agents typically have neither &mdash; use `InMemoryCredentialStore` there.
148+
- **Windows Credential Manager** caps the credential blob at 2560 bytes (`5 * 512`). Tokens larger than that throw `CredentialStoreException` &mdash; split or compress before storing.
86149
- **macOS** uses the user's default login keychain. The first access from an application prompts the user for permission, as with any keychain client.
87-
- `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).
150+
- **Linux** requires `libsecret-1` plus an active Secret Service. Headless CI agents typically have neither &mdash; use `InMemoryCredentialStore` there, or set up `dbus-run-session` + `gnome-keyring-daemon` as the `cross-platform.yml` workflow does.
151+
- 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 &mdash; treat `Save` / `Remove` as I/O, not as cheap accessors.
88152

89153
## API summary
90154

@@ -94,10 +158,11 @@ using CredentialCache cache = new(new InMemoryCredentialStore());
94158
|--------|-------------|
95159
| `CredentialCache(ICredentialStore store)` | Construct an instance with an explicit store. |
96160
| `static Instance` | Process-wide singleton (lazy, thread-safe). |
161+
| `Store` | The backing store passed to the constructor. |
97162
| `static ConfigureStore(ICredentialStore)` | Override the singleton's store. Must precede first `Instance` access. |
98163
| `static ResetSingletonForTesting()` | Dispose the singleton and clear configuration. Tests only. |
99164
| `static CreatePersonaGUID()` | Allocates a new `PersonaGUID`. |
100-
| `TryGet(persona, out cred)` | Memory-cache lookup with fallthrough to the backing store. |
165+
| `TryGet(persona, out cred)` | Memory-cache lookup with fall-through to the backing store. |
101166
| `AddOrReplace(persona, cred)` | Persists eagerly through the store. |
102167
| `Remove(persona)` | Deletes from both the in-memory cache and the store. |
103168
| `RegisterCredentialFactory<T>(factory)` | Optional factory hook used by `TryCreate<T>`. |
@@ -108,11 +173,20 @@ using CredentialCache cache = new(new InMemoryCredentialStore());
108173

109174
| Member | Description |
110175
|--------|-------------|
111-
| `Name` | Diagnostic identifier (`"Windows Credential Manager"`, `"macOS Keychain"`, `"Linux libsecret (Secret Service)"`, `"InMemory"`). |
176+
| `Name` | Diagnostic identifier (e.g. `"Windows Credential Manager"`, `"macOS Keychain"`, `"Linux libsecret (Secret Service)"`, `"InMemory"`). |
112177
| `TryLoad(persona, out cred)` | Load a single credential. |
113178
| `Save(persona, cred)` | Persist or overwrite a single credential. |
114179
| `Remove(persona)` | Delete a single credential. |
115-
| `EnumerateKeys()` | Enumerate persona keys (Windows only by default). |
180+
181+
### `ISearchableCredentialStore : ICredentialStore`
182+
183+
| Member | Description |
184+
|--------|-------------|
185+
| `EnumerateKeys()` | Enumerate every persona key currently persisted (Windows, in-memory). |
186+
187+
## Don't dispose the singleton
188+
189+
`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)`.
116190

117191
## License
118192

0 commit comments

Comments
 (0)