From bd7ae6ad470fc58b478c839bae15b53438705c4d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 09:45:24 +0000 Subject: [PATCH 1/3] Rewrite persistence layer to use platform-native credential stores The library previously claimed in its README to "encrypt sensitive credentials using platform-specific security features" but actually serialized credentials as plaintext JSON to AppData via ktsu.PersistenceProvider. The upstream ktsu.UniversalSerializer / ktsu.FileSystemProvider / ktsu.PersistenceProvider namespaces have also shifted, so the project no longer even restored against the netstandard2.0/2.1 targets the SDK was producing. Highlights: - Introduce ICredentialStore with one implementation per supported OS, each storing one entry per PersonaGUID in the host's native keyring: Windows -> Credential Manager via advapi32 (CredRead/CredWrite/CredDelete) macOS -> Keychain Services via Security.framework (SecKeychain*) Linux -> libsecret (Secret Service) via libsecret-1.so.0 Plus an InMemoryCredentialStore for tests and explicit opt-out. - Drop the broken AppData / UniversalSerializer / FileSystemProvider deps and serialize Credential subclasses directly via System.Text.Json, wiring ktsu.RoundTripStringJsonConverter so SemanticString values round-trip correctly (previously broke as IEnumerable). - Make CredentialCache constructible with an explicit ICredentialStore in addition to the singleton, expose Remove / Dispose semantics, and add ResetSingletonForTesting so test runs no longer pollute user AppData. - Constrain TargetFrameworks to net9.0;net10.0 (matches what the remaining ktsu deps actually publish) and add a cross-platform CI workflow that builds and tests on ubuntu/macos/windows. - Rewrite README so it documents the real API (PersonaGUID-keyed AddOrReplace/TryGet/Remove) and the actual at-rest behaviour per platform. https://claude.ai/code/session_017B9mN9F7C3pWGZRyoKBd6R --- .github/workflows/cross-platform.yml | 56 ++++ .../CredentialCache.Test.csproj | 6 +- CredentialCache.Test/CredentialCacheTests.cs | 262 ++++++++++-------- CredentialCache/CredentialCache.cs | 258 ++++++++--------- CredentialCache/CredentialCache.csproj | 22 +- .../CredentialWithUsernamePassword.cs | 2 + CredentialCache/ICredentialFactory.cs | 2 +- .../Storage/CredentialSerialization.cs | 85 ++++++ .../Storage/CredentialStoreException.cs | 24 ++ .../Storage/CredentialStoreFactory.cs | 47 ++++ CredentialCache/Storage/ICredentialStore.cs | 39 +++ .../Storage/InMemoryCredentialStore.cs | 49 ++++ .../LinuxSecretServiceCredentialStore.cs | 213 ++++++++++++++ .../Storage/MacOsCredentialStore.cs | 242 ++++++++++++++++ .../Storage/WindowsCredentialStore.cs | 212 ++++++++++++++ Directory.Packages.props | 2 +- README.md | 213 +++++--------- 17 files changed, 1338 insertions(+), 396 deletions(-) create mode 100644 .github/workflows/cross-platform.yml create mode 100644 CredentialCache/Storage/CredentialSerialization.cs create mode 100644 CredentialCache/Storage/CredentialStoreException.cs create mode 100644 CredentialCache/Storage/CredentialStoreFactory.cs create mode 100644 CredentialCache/Storage/ICredentialStore.cs create mode 100644 CredentialCache/Storage/InMemoryCredentialStore.cs create mode 100644 CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs create mode 100644 CredentialCache/Storage/MacOsCredentialStore.cs create mode 100644 CredentialCache/Storage/WindowsCredentialStore.cs diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml new file mode 100644 index 0000000..09099d1 --- /dev/null +++ b/.github/workflows/cross-platform.yml @@ -0,0 +1,56 @@ +name: Cross-platform verification + +on: + push: + branches: [main, develop] + paths-ignore: + - "**.md" + - ".github/ISSUE_TEMPLATE/**" + - ".github/pull_request_template.md" + pull_request: + paths-ignore: + - "**.md" + - ".github/ISSUE_TEMPLATE/**" + - ".github/pull_request_template.md" + workflow_dispatch: + +concurrency: + group: cross-platform-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET SDK 10.0 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Install libsecret (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-0 + + - name: Restore + run: dotnet restore CredentialCache.sln + + - name: Build + run: dotnet build CredentialCache.sln --configuration Release --no-restore + + - name: Test + run: dotnet test CredentialCache.Test/CredentialCache.Test.csproj --configuration Release --no-build --logger "console;verbosity=normal" diff --git a/CredentialCache.Test/CredentialCache.Test.csproj b/CredentialCache.Test/CredentialCache.Test.csproj index 623db95..c9615e8 100644 --- a/CredentialCache.Test/CredentialCache.Test.csproj +++ b/CredentialCache.Test/CredentialCache.Test.csproj @@ -1,4 +1,4 @@ - + @@ -6,6 +6,10 @@ true net10.0 + + false diff --git a/CredentialCache.Test/CredentialCacheTests.cs b/CredentialCache.Test/CredentialCacheTests.cs index 49e7f34..48ff8b2 100644 --- a/CredentialCache.Test/CredentialCacheTests.cs +++ b/CredentialCache.Test/CredentialCacheTests.cs @@ -4,21 +4,22 @@ namespace ktsu.CredentialCache.Test; +using ktsu.CredentialCache.Storage; +using ktsu.Semantics.Strings; + [TestClass] -//[DoNotParallelize] public class CredentialCacheTests { + private static CredentialCache NewCache() => new(new InMemoryCredentialStore()); + [TestMethod] public void TryGetReturnsFalseWhenCredentialNotFound() { - // Arrange - var cache = CredentialCache.Instance; - var guid = CredentialCache.CreatePersonaGUID(); + using CredentialCache cache = NewCache(); + PersonaGUID guid = CredentialCache.CreatePersonaGUID(); - // Act - var result = cache.TryGet(guid, out var credential); + bool result = cache.TryGet(guid, out Credential? credential); - // Assert Assert.IsFalse(result, "TryGet should return false when credential is not found"); Assert.IsNull(credential); } @@ -26,65 +27,75 @@ public void TryGetReturnsFalseWhenCredentialNotFound() [TestMethod] public void AddOrReplaceAddsCredentialSuccessfully() { - // Arrange - var cache = CredentialCache.Instance; - var guid = CredentialCache.CreatePersonaGUID(); - var credential = new CredentialWithNothing(); + using CredentialCache cache = NewCache(); + PersonaGUID guid = CredentialCache.CreatePersonaGUID(); + CredentialWithNothing credential = new(); - // Act cache.AddOrReplace(guid, credential); - var result = cache.TryGet(guid, out var retrievedCredential); + bool result = cache.TryGet(guid, out Credential? retrievedCredential); - // Assert Assert.IsTrue(result, "TryGet should return true after adding a credential"); Assert.AreEqual(credential, retrievedCredential); } [TestMethod] - public void TryCreateReturnsFalseWhenFactoryNotRegistered() + public void AddOrReplacePersistsCredentialToBackingStore() { - // Arrange - var cache = CredentialCache.Instance; - cache.UnregisterCredentialFactory(); //ensure factory is not registered by some previous test + InMemoryCredentialStore store = new(); + PersonaGUID guid = CredentialCache.CreatePersonaGUID(); + CredentialWithUsernamePassword credential = new() + { + Username = SemanticString.Create("alice"), + Password = SemanticString.Create("hunter2"), + }; - // Act - var result = cache.TryCreate(out var credential); + using (CredentialCache writer = new(store)) + { + writer.AddOrReplace(guid, credential); + } - // Assert - Assert.IsFalse(result, "TryCreate should return false when factory is not registered"); - Assert.IsNull(credential); + using CredentialCache reader = new(store); + Assert.IsTrue(reader.TryGet(guid, out Credential? roundTripped)); + CredentialWithUsernamePassword? typed = roundTripped as CredentialWithUsernamePassword; + Assert.IsNotNull(typed); + Assert.AreEqual("alice", typed!.Username.ToString()); + Assert.AreEqual("hunter2", typed.Password.ToString()); } [TestMethod] - public void TryCreateCreatesCredentialSuccessfullyWhenFactoryRegistered() + public void RemoveDeletesCredentialFromBothLayers() { - // Arrange - var cache = CredentialCache.Instance; - var factory = new CredentialWithNothingFactory(); - cache.RegisterCredentialFactory(factory); + InMemoryCredentialStore store = new(); + PersonaGUID guid = CredentialCache.CreatePersonaGUID(); + CredentialWithNothing credential = new(); - // Act - var result = cache.TryCreate(out var credential); + using CredentialCache cache = new(store); + cache.AddOrReplace(guid, credential); + Assert.IsTrue(cache.Remove(guid)); + Assert.IsFalse(cache.TryGet(guid, out _)); + Assert.IsFalse(store.TryLoad(guid, out _)); + } - // Assert - Assert.IsTrue(result, "TryCreate should return true when factory is registered"); - Assert.IsNotNull(credential); - Assert.IsInstanceOfType(credential); + [TestMethod] + public void TryCreateReturnsFalseWhenFactoryNotRegistered() + { + using CredentialCache cache = NewCache(); + + bool result = cache.TryCreate(out Credential? credential); + + Assert.IsFalse(result, "TryCreate should return false when factory is not registered"); + Assert.IsNull(credential); } [TestMethod] - public void RegisterCredentialFactoryRegistersFactorySuccessfully() + public void TryCreateCreatesCredentialSuccessfullyWhenFactoryRegistered() { - // Arrange - var cache = CredentialCache.Instance; - var factory = new CredentialWithNothingFactory(); + using CredentialCache cache = NewCache(); + cache.RegisterCredentialFactory(new CredentialWithNothingFactory()); - // Act - cache.RegisterCredentialFactory(factory); - var result = cache.TryCreate(out var credential); + bool result = cache.TryCreate(out Credential? credential); - // Assert - Assert.IsTrue(result, "TryCreate should return true after registering factory"); + Assert.IsTrue(result, "TryCreate should return true when factory is registered"); Assert.IsNotNull(credential); Assert.IsInstanceOfType(credential); } @@ -92,16 +103,12 @@ public void RegisterCredentialFactoryRegistersFactorySuccessfully() [TestMethod] public void UnregisterCredentialFactoryUnregistersFactorySuccessfully() { - // Arrange - var cache = CredentialCache.Instance; - var factory = new CredentialWithNothingFactory(); - cache.RegisterCredentialFactory(factory); + using CredentialCache cache = NewCache(); + cache.RegisterCredentialFactory(new CredentialWithNothingFactory()); - // Act cache.UnregisterCredentialFactory(); - var result = cache.TryCreate(out var credential); + bool result = cache.TryCreate(out Credential? credential); - // Assert Assert.IsFalse(result, "TryCreate should return false after unregistering factory"); Assert.IsNull(credential); } @@ -109,123 +116,152 @@ public void UnregisterCredentialFactoryUnregistersFactorySuccessfully() [TestMethod] public void UnregisterCredentialFactoryDoesNothingWhenFactoryNotRegistered() { - // Arrange - var cache = CredentialCache.Instance; + using CredentialCache cache = NewCache(); - // Act cache.UnregisterCredentialFactory(); - var result = cache.TryCreate(out var credential); + bool result = cache.TryCreate(out Credential? credential); - // Assert - Assert.IsFalse(result, "TryCreate should return false when factory was never registered"); + Assert.IsFalse(result); Assert.IsNull(credential); } [TestMethod] public void RegisterCredentialFactoryThrowsArgumentNullExceptionWhenFactoryIsNull() { - // Arrange - var cache = CredentialCache.Instance; + using CredentialCache cache = NewCache(); ICredentialFactory? factory = null; - // Act & Assert - Assert.ThrowsException(() => cache.RegisterCredentialFactory(factory!)); + Assert.Throws(() => cache.RegisterCredentialFactory(factory!)); } [TestMethod] public void AddOrReplaceThrowsArgumentNullExceptionWhenCredentialIsNull() { - // Arrange - var cache = CredentialCache.Instance; - var guid = CredentialCache.CreatePersonaGUID(); + using CredentialCache cache = NewCache(); + PersonaGUID guid = CredentialCache.CreatePersonaGUID(); CredentialWithNothing? credential = null; - // Act & Assert - Assert.ThrowsException(() => cache.AddOrReplace(guid, credential!)); + Assert.Throws(() => cache.AddOrReplace(guid, credential!)); + } + + [TestMethod] + public void ConstructorThrowsWhenStoreIsNull() + { + ICredentialStore? store = null; + Assert.Throws(() => new CredentialCache(store!)); + } + + [TestMethod] + public void OperationsThrowAfterDispose() + { + CredentialCache cache = NewCache(); + cache.Dispose(); + + Assert.Throws(() => + cache.AddOrReplace(CredentialCache.CreatePersonaGUID(), new CredentialWithNothing())); + Assert.Throws(() => + cache.TryGet(CredentialCache.CreatePersonaGUID(), out _)); + } + + [TestMethod] + public void CredentialSerializationRoundTripsAllKnownTypes() + { + Credential nothing = new CredentialWithNothing(); + Credential token = new CredentialWithToken + { + Token = SemanticString.Create("opaque-token"), + }; + Credential creds = new CredentialWithUsernamePassword + { + Username = SemanticString.Create("u"), + Password = SemanticString.Create("p"), + }; + + foreach (Credential original in new[] { nothing, token, creds }) + { + byte[] bytes = CredentialSerialization.Serialize(original); + Credential? deserialized = CredentialSerialization.Deserialize(bytes); + Assert.IsNotNull(deserialized); + Assert.AreEqual(original.GetType(), deserialized!.GetType()); + } } [TestMethod] public void RegisterMultipleCredentialFactoriesCreatesCredentialsCorrectly() { - // Arrange - var cache = CredentialCache.Instance; - var factory1 = new CredentialWithNothingFactory(); - var factory2 = new AnotherCredentialFactory(); + using CredentialCache cache = NewCache(); + CredentialWithNothingFactory factory1 = new(); + AnotherCredentialFactory factory2 = new(); cache.RegisterCredentialFactory(factory1); cache.RegisterCredentialFactory(factory2); - var guid1 = CredentialCache.CreatePersonaGUID(); - var guid2 = CredentialCache.CreatePersonaGUID(); + PersonaGUID guid1 = CredentialCache.CreatePersonaGUID(); + PersonaGUID guid2 = CredentialCache.CreatePersonaGUID(); - // Act cache.AddOrReplace(guid1, factory1.Create()); cache.AddOrReplace(guid2, factory2.Create()); - var result1 = cache.TryGet(guid1, out var credential1); - var result2 = cache.TryGet(guid2, out var credential2); + bool result1 = cache.TryGet(guid1, out Credential? credential1); + bool result2 = cache.TryGet(guid2, out Credential? credential2); - // Assert - Assert.IsTrue(result1, "TryGet should return true for first credential type"); - Assert.IsNotNull(credential1); + Assert.IsTrue(result1); Assert.IsInstanceOfType(credential1); - Assert.IsTrue(result2, "TryGet should return true for second credential type"); - Assert.IsNotNull(credential2); + Assert.IsTrue(result2); Assert.IsInstanceOfType(credential2); } [TestMethod] public void CredentialCacheIsThreadSafeUnderConcurrentAccess() { - // Arrange - var cache = CredentialCache.Instance; - var factory = new CredentialWithNothingFactory(); - cache.RegisterCredentialFactory(factory); - var numberOfThreads = 10; - var operationsPerThread = 100; + using CredentialCache cache = NewCache(); + cache.RegisterCredentialFactory(new CredentialWithNothingFactory()); + const int numberOfThreads = 8; + const int operationsPerThread = 100; List tasks = []; - // Act - for (var i = 0; i < numberOfThreads; i++) + for (int i = 0; i < numberOfThreads; i++) { tasks.Add(Task.Run(() => { - for (var j = 0; j < operationsPerThread; j++) + for (int j = 0; j < operationsPerThread; j++) { - var guid = CredentialCache.CreatePersonaGUID(); - var credential = factory.Create(); + PersonaGUID guid = CredentialCache.CreatePersonaGUID(); + CredentialWithNothing credential = new(); cache.AddOrReplace(guid, credential); - var result = cache.TryGet(guid, out var retrievedCredential); - Assert.IsTrue(result, "TryGet should return true under concurrent access"); - Assert.AreEqual(credential, retrievedCredential); + Assert.IsTrue(cache.TryGet(guid, out Credential? retrieved)); + Assert.AreEqual(credential, retrieved); } })); } Task.WaitAll([.. tasks]); - - // Assert - // All assertions within tasks are validated } [TestMethod] - public void UnregisterCredentialFactoryPreventsCredentialCreation() + public void ConfigureStoreCannotBeCalledAfterInstanceConstructed() { - // Arrange - var cache = CredentialCache.Instance; - var factory = new CredentialWithNothingFactory(); - cache.RegisterCredentialFactory(factory); - var guid = CredentialCache.CreatePersonaGUID(); - cache.AddOrReplace(guid, factory.Create()); + CredentialCache.ResetSingletonForTesting(); + CredentialCache.ConfigureStore(new InMemoryCredentialStore()); + _ = CredentialCache.Instance; + Assert.Throws( + () => CredentialCache.ConfigureStore(new InMemoryCredentialStore())); + CredentialCache.ResetSingletonForTesting(); + } - // Act - cache.UnregisterCredentialFactory(); - var creationResult = cache.TryCreate(out var credentialAfterUnregister); - var retrievalResult = cache.TryGet(guid, out var retrievedCredential); - - // Assert - Assert.IsFalse(creationResult, "TryCreate should return false after unregistering factory"); - Assert.IsNull(credentialAfterUnregister); - Assert.IsTrue(retrievalResult, "TryGet should return true for previously stored credential"); - Assert.IsInstanceOfType(retrievedCredential); + [TestMethod] + public void SingletonUsesConfiguredStore() + { + CredentialCache.ResetSingletonForTesting(); + InMemoryCredentialStore store = new(); + CredentialCache.ConfigureStore(store); + try + { + CredentialCache instance = CredentialCache.Instance; + Assert.AreSame(store, instance.Store); + } + finally + { + CredentialCache.ResetSingletonForTesting(); + } } } diff --git a/CredentialCache/CredentialCache.cs b/CredentialCache/CredentialCache.cs index 4cebcf4..f8d2508 100644 --- a/CredentialCache/CredentialCache.cs +++ b/CredentialCache/CredentialCache.cs @@ -5,14 +5,8 @@ namespace ktsu.CredentialCache; using System.Collections.Concurrent; -using System.Text.Json.Serialization; - -using ktsu.Extensions; -using ktsu.FileSystemProvider; -using ktsu.PersistenceProvider; +using ktsu.CredentialCache.Storage; using ktsu.Semantics.Strings; -using ktsu.UniversalSerializer.Json; -using ktsu.UniversalSerializer.Services; /// /// Represents a globally unique identifier for a persona. @@ -20,65 +14,53 @@ namespace ktsu.CredentialCache; public sealed record class PersonaGUID : SemanticString { } /// -/// Data model for credential cache persistence. -/// -internal sealed class CredentialCacheData -{ - /// - /// Gets or sets the dictionary of credentials. - /// - [JsonInclude] - public ConcurrentDictionary Credentials { get; set; } = new(); -} - -/// -/// Manages the caching of credentials and their associated factories. +/// Caches instances in memory and persists each one through +/// an . The default store routes to the platform-native +/// secret manager: +/// +/// Windows Credential Manager on Windows +/// Keychain on macOS +/// libsecret (Secret Service) on Linux +/// /// public sealed class CredentialCache : IDisposable { - private const string CacheKey = "CredentialCache"; - private static readonly object _lock = new(); + private static readonly Lock _instanceLock = new(); private static CredentialCache? _instance; - private static IPersistenceProvider? _persistenceProvider; + private static ICredentialStore? _configuredStore; - /// - /// Gets the dictionary of credential factories. - /// - private ConcurrentDictionary CredentialFactories { get; } = new(); + private readonly ConcurrentDictionary _credentials = new(); + private readonly ConcurrentDictionary _factories = new(); + private bool _disposed; /// - /// Gets the underlying data model. + /// Creates a new credential cache backed by . /// - private CredentialCacheData Data { get; set; } - - /// - /// Gets the persistence provider used for storage operations. - /// - private IPersistenceProvider PersistenceProvider { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The persistence provider for storage operations. - private CredentialCache(IPersistenceProvider persistenceProvider) + /// The store responsible for persistence. Must not be null. + public CredentialCache(ICredentialStore store) { - PersistenceProvider = persistenceProvider ?? throw new ArgumentNullException(nameof(persistenceProvider)); - Data = LoadOrCreateData().GetAwaiter().GetResult(); + ArgumentNullException.ThrowIfNull(store); + Store = store; } /// - /// Gets the singleton instance of the class. + /// Gets the process-wide singleton instance, constructed on first access. /// + /// + /// To override the backing store call before + /// touching this property. The first access constructs the singleton; later + /// reconfiguration requires . + /// public static CredentialCache Instance { get { - lock (_lock) + lock (_instanceLock) { if (_instance is null) { - _persistenceProvider ??= CreateDefaultPersistenceProvider(); - _instance = new CredentialCache(_persistenceProvider); + ICredentialStore store = _configuredStore ?? CredentialStoreFactory.CreateDefault(); + _instance = new CredentialCache(store); } return _instance; } @@ -86,70 +68,72 @@ public static CredentialCache Instance } /// - /// Configures the persistence provider for the credential cache. - /// This must be called before accessing the Instance property if you want to use a custom provider. + /// Gets the backing store used by this instance. /// - /// The persistence provider to use. - public static void ConfigurePersistenceProvider(IPersistenceProvider persistenceProvider) + public ICredentialStore Store { get; } + + /// + /// Configures the used by the singleton + /// . Must be called before the singleton is first accessed. + /// + /// The store to use. + /// + /// Thrown when the singleton has already been constructed. + /// + public static void ConfigureStore(ICredentialStore store) { - lock (_lock) + ArgumentNullException.ThrowIfNull(store); + lock (_instanceLock) { if (_instance is not null) { - throw new InvalidOperationException("Cannot configure persistence provider after instance has been created. Call this method before accessing Instance."); + throw new InvalidOperationException( + $"Cannot configure store after {nameof(Instance)} has been constructed. " + + $"Call {nameof(ConfigureStore)} before first access, or " + + $"{nameof(ResetSingletonForTesting)} to reset (tests only)."); } - _persistenceProvider = persistenceProvider ?? throw new ArgumentNullException(nameof(persistenceProvider)); + _configuredStore = store; } } /// - /// Tries to get a credential associated with the specified persona GUID. + /// Resets the singleton state so a subsequent access constructs + /// a fresh cache. Intended for test isolation only. /// - /// The GUID of the persona. - /// When this method returns, contains the credential associated with the specified GUID, if the GUID is found; otherwise, null. - /// true if the credential was found; otherwise, false. - public bool TryGet(PersonaGUID providerGuid, out Credential? gitCredential) => - Data.Credentials.TryGetValue(providerGuid, out gitCredential); - - /// - /// Adds or replaces a credential associated with the specified persona GUID. - /// - /// The GUID of the persona. - /// The credential to add or replace. - /// Thrown when is null. - public async Task AddOrReplaceAsync(PersonaGUID providerGuid, Credential gitCredential, CancellationToken cancellationToken = default) + public static void ResetSingletonForTesting() { - ArgumentNullException.ThrowIfNull(gitCredential); - Data.Credentials[providerGuid] = gitCredential; - await SaveAsync(cancellationToken).ConfigureAwait(false); + lock (_instanceLock) + { + _instance?.Dispose(); + _instance = null; + _configuredStore = null; + } } /// - /// Adds or replaces a credential associated with the specified persona GUID synchronously. + /// Creates a fresh . /// - /// The GUID of the persona. - /// The credential to add or replace. - /// Thrown when is null. - public void AddOrReplace(PersonaGUID providerGuid, Credential gitCredential) - { - AddOrReplaceAsync(providerGuid, gitCredential).GetAwaiter().GetResult(); - } + public static PersonaGUID CreatePersonaGUID() => + SemanticString.Create(Guid.NewGuid().ToString()); /// - /// Tries to create a credential of the specified type. + /// Attempts to retrieve the credential associated with , + /// loading it from the backing store if it has not yet been cached in memory. /// - /// The type of the credential. - /// When this method returns, contains the created credential, if the factory is found; otherwise, null. - /// true if the credential was created; otherwise, false. - public bool TryCreate(out Credential? credential) where T : Credential + public bool TryGet(PersonaGUID persona, out Credential? credential) { - if (CredentialFactories.TryGetValue(typeof(T), out var credentialFactory)) + ArgumentNullException.ThrowIfNull(persona); + ThrowIfDisposed(); + + if (_credentials.TryGetValue(persona, out credential)) { - if (credentialFactory is ICredentialFactory factory) - { - credential = factory.Create(); - return true; - } + return true; + } + + if (Store.TryLoad(persona, out Credential? loaded) && loaded is not null) + { + credential = _credentials.GetOrAdd(persona, loaded); + return true; } credential = null; @@ -157,80 +141,82 @@ public bool TryCreate(out Credential? credential) where T : Credential } /// - /// Creates a new persona GUID. - /// - /// A new . - public static PersonaGUID CreatePersonaGUID() => Guid.NewGuid().ToString().As(); - - /// - /// Registers a credential factory for the specified credential type. + /// Adds or replaces the credential for and persists it. /// - /// The type of the credential. - /// The factory to register. - /// Thrown when is null. - public void RegisterCredentialFactory(ICredentialFactory factory) where T : Credential + public void AddOrReplace(PersonaGUID persona, Credential credential) { - ArgumentNullException.ThrowIfNull(factory); - CredentialFactories[typeof(T)] = factory; - } + ArgumentNullException.ThrowIfNull(persona); + ArgumentNullException.ThrowIfNull(credential); + ThrowIfDisposed(); - /// - /// Unregisters a credential factory for the specified credential type. - /// - /// The type of the credential. - public void UnregisterCredentialFactory() where T : Credential => - CredentialFactories.TryRemove(typeof(T), out _); + _credentials[persona] = credential; + Store.Save(persona, credential); + } /// - /// Saves the current credential cache data to persistent storage. + /// Removes the credential associated with from both + /// the in-memory cache and the backing store. /// - /// A cancellation token to cancel the operation. - /// A task that represents the asynchronous save operation. - public async Task SaveAsync(CancellationToken cancellationToken = default) + public bool Remove(PersonaGUID persona) { - await PersistenceProvider.StoreAsync(CacheKey, Data, cancellationToken).ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(persona); + ThrowIfDisposed(); + + bool removedInMemory = _credentials.TryRemove(persona, out _); + bool removedFromStore = Store.Remove(persona); + return removedInMemory || removedFromStore; } /// - /// Saves the current credential cache data to persistent storage synchronously. + /// Registers a factory for credentials of type . /// - public void Save() + public void RegisterCredentialFactory(ICredentialFactory factory) where T : Credential { - SaveAsync().GetAwaiter().GetResult(); + ArgumentNullException.ThrowIfNull(factory); + ThrowIfDisposed(); + _factories[typeof(T)] = factory; } /// - /// Loads or creates the credential cache data from persistent storage. + /// Unregisters any factory previously registered for type . /// - /// A cancellation token to cancel the operation. - /// The loaded or newly created credential cache data. - private async Task LoadOrCreateData(CancellationToken cancellationToken = default) + public void UnregisterCredentialFactory() where T : Credential { - return await PersistenceProvider.RetrieveOrCreateAsync(CacheKey, cancellationToken).ConfigureAwait(false); + ThrowIfDisposed(); + _factories.TryRemove(typeof(T), out _); } /// - /// Creates the default persistence provider for the credential cache. + /// Attempts to create a credential of type using a previously + /// registered factory. /// - /// A new instance of the default persistence provider. - private static IPersistenceProvider CreateDefaultPersistenceProvider() + public bool TryCreate(out Credential? credential) where T : Credential { - var fileSystemProvider = new FileSystemProvider(); - var jsonSerializer = new JsonSerializer(); - var serializationProvider = new UniversalSerializationProvider(jsonSerializer, providerName: "CredentialCache.Json"); - return new AppDataPersistenceProvider( - fileSystemProvider, - serializationProvider, - applicationName: "ktsu", - subdirectory: "CredentialCache"); + ThrowIfDisposed(); + if (_factories.TryGetValue(typeof(T), out ICredentialFactory? factory) && factory is ICredentialFactory typed) + { + credential = typed.Create(); + return true; + } + credential = null; + return false; } + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + /// - /// Disposes the credential cache instance and saves any pending changes. + /// Releases the in-memory state. Stored credentials remain in the backing + /// store; nothing is persisted or deleted at dispose-time because every + /// mutation is already flushed eagerly in . /// public void Dispose() { - Save(); + if (_disposed) + { + return; + } + _disposed = true; + _credentials.Clear(); + _factories.Clear(); } } - diff --git a/CredentialCache/CredentialCache.csproj b/CredentialCache/CredentialCache.csproj index 4c010a9..01fc731 100644 --- a/CredentialCache/CredentialCache.csproj +++ b/CredentialCache/CredentialCache.csproj @@ -1,15 +1,23 @@ - + + + net9.0;net10.0 + true + + $(NoWarn);KTSU0003;SYSLIB1054;CA5392;CA1031;CA1416 + + - - - - - - + diff --git a/CredentialCache/CredentialWithUsernamePassword.cs b/CredentialCache/CredentialWithUsernamePassword.cs index dfd2c12..cbcf345 100644 --- a/CredentialCache/CredentialWithUsernamePassword.cs +++ b/CredentialCache/CredentialWithUsernamePassword.cs @@ -10,10 +10,12 @@ namespace ktsu.CredentialCache; /// Represents a credential with a username. /// public sealed record class CredentialUsername : SemanticString { } + /// /// Represents a credential with a password. /// public sealed record class CredentialPassword : SemanticString { } + /// /// Represents a credential that includes both a username and a password. /// diff --git a/CredentialCache/ICredentialFactory.cs b/CredentialCache/ICredentialFactory.cs index 3e50088..47a9885 100644 --- a/CredentialCache/ICredentialFactory.cs +++ b/CredentialCache/ICredentialFactory.cs @@ -7,7 +7,7 @@ namespace ktsu.CredentialCache; /// /// Represents a factory for creating credentials. /// -[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1040:Avoid empty interfaces", Justification = "I'm using this as the base for the generic")] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1040:Avoid empty interfaces", Justification = "Base for the generic factory interface.")] public interface ICredentialFactory { } /// diff --git a/CredentialCache/Storage/CredentialSerialization.cs b/CredentialCache/Storage/CredentialSerialization.cs new file mode 100644 index 0000000..dfc69d0 --- /dev/null +++ b/CredentialCache/Storage/CredentialSerialization.cs @@ -0,0 +1,85 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.CredentialCache.Storage; + +using System.Text.Json; +using ktsu.RoundTripStringJsonConverter; + +/// +/// Serializes instances to and from UTF-8 JSON. Custom +/// implementations should round-trip credentials +/// through these helpers so polymorphic subclasses are +/// preserved. +/// +public static class CredentialSerialization +{ + private static readonly JsonSerializerOptions Options = BuildOptions(); + + private static JsonSerializerOptions BuildOptions() + { + JsonSerializerOptions options = new() + { + WriteIndented = false, + }; + // Persuade System.Text.Json to use the SemanticString factory methods (Create / FromString) + // rather than treating SemanticString as an IEnumerable collection. + options.Converters.Add(new RoundTripStringJsonConverterFactory()); + return options; + } + + /// + /// Serializes the credential to a UTF-8 JSON byte array. + /// + public static byte[] Serialize(Credential credential) => + JsonSerializer.SerializeToUtf8Bytes(credential, Options); + + /// + /// Serializes the credential to a UTF-8 JSON string. + /// + public static string SerializeToString(Credential credential) => + JsonSerializer.Serialize(credential, Options); + + /// + /// Deserializes a credential from a UTF-8 JSON byte array. Returns null if the bytes + /// do not represent a known credential. + /// + public static Credential? Deserialize(byte[] utf8Json) + { + if (utf8Json is null || utf8Json.Length == 0) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(utf8Json, Options); + } + catch (JsonException) + { + return null; + } + } + + /// + /// Deserializes a credential from a UTF-8 JSON string. Returns null if the value + /// does not represent a known credential. + /// + public static Credential? DeserializeFromString(string utf8Json) + { + if (string.IsNullOrEmpty(utf8Json)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(utf8Json, Options); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/CredentialCache/Storage/CredentialStoreException.cs b/CredentialCache/Storage/CredentialStoreException.cs new file mode 100644 index 0000000..d371a11 --- /dev/null +++ b/CredentialCache/Storage/CredentialStoreException.cs @@ -0,0 +1,24 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.CredentialCache.Storage; + +using System.Diagnostics.CodeAnalysis; + +/// +/// Raised when an operation against the underlying OS credential store fails. +/// +[SuppressMessage("Design", "CA1032:Implement standard exception constructors", Justification = "Always raised with detail.")] +public sealed class CredentialStoreException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public CredentialStoreException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class. + /// + public CredentialStoreException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/CredentialCache/Storage/CredentialStoreFactory.cs b/CredentialCache/Storage/CredentialStoreFactory.cs new file mode 100644 index 0000000..c18d574 --- /dev/null +++ b/CredentialCache/Storage/CredentialStoreFactory.cs @@ -0,0 +1,47 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.CredentialCache.Storage; + +using System.Runtime.InteropServices; + +/// +/// Selects the appropriate implementation for the +/// current operating system. +/// +public static class CredentialStoreFactory +{ + /// + /// Returns the default platform-native credential store for the current OS. + /// + /// + /// A logical service name used to scope the stored credentials. Defaults to + /// ktsu.CredentialCache. Use a per-application name when several + /// applications share a host to avoid collisions. + /// + /// + /// Thrown when the current platform is not Windows, macOS, or Linux. + /// + public static ICredentialStore CreateDefault(string serviceName = "ktsu.CredentialCache") + { + ArgumentException.ThrowIfNullOrEmpty(serviceName); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new WindowsCredentialStore(serviceName); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return new MacOsCredentialStore(serviceName); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return new LinuxSecretServiceCredentialStore(serviceName); + } + + throw new PlatformNotSupportedException( + $"CredentialCache has no native credential store for '{RuntimeInformation.OSDescription}'. " + + $"Use {nameof(InMemoryCredentialStore)} or supply a custom {nameof(ICredentialStore)} implementation."); + } +} diff --git a/CredentialCache/Storage/ICredentialStore.cs b/CredentialCache/Storage/ICredentialStore.cs new file mode 100644 index 0000000..14726e3 --- /dev/null +++ b/CredentialCache/Storage/ICredentialStore.cs @@ -0,0 +1,39 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.CredentialCache.Storage; + +/// +/// Persists instances keyed by . +/// Implementations are expected to delegate the at-rest protection of secrets +/// to the platform's native credential store (Windows Credential Manager, macOS +/// Keychain, Linux libsecret) or to an explicit in-memory backing. +/// +public interface ICredentialStore +{ + /// + /// Gets a human-readable name identifying the backing store (for diagnostics). + /// + public string Name { get; } + + /// + /// Attempts to load the credential associated with . + /// + public bool TryLoad(PersonaGUID persona, out Credential? credential); + + /// + /// Stores or replaces the credential associated with . + /// + public void Save(PersonaGUID persona, Credential credential); + + /// + /// Removes any credential associated with . + /// + public bool Remove(PersonaGUID persona); + + /// + /// Enumerates every persona key currently stored. + /// + public IEnumerable EnumerateKeys(); +} diff --git a/CredentialCache/Storage/InMemoryCredentialStore.cs b/CredentialCache/Storage/InMemoryCredentialStore.cs new file mode 100644 index 0000000..47bb57a --- /dev/null +++ b/CredentialCache/Storage/InMemoryCredentialStore.cs @@ -0,0 +1,49 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.CredentialCache.Storage; + +using System.Collections.Concurrent; + +/// +/// A non-persistent credential store backed by an in-memory dictionary. Intended for +/// tests and applications that explicitly opt out of platform-level persistence. +/// +public sealed class InMemoryCredentialStore : ICredentialStore +{ + + private readonly ConcurrentDictionary _items = new(); + + /// + + public string Name => "InMemory"; + + /// + public bool TryLoad(PersonaGUID persona, out Credential? credential) + { + ArgumentNullException.ThrowIfNull(persona); + return _items.TryGetValue(persona, out credential); + + } + + /// + public void Save(PersonaGUID persona, Credential credential) + { + ArgumentNullException.ThrowIfNull(persona); + ArgumentNullException.ThrowIfNull(credential); + _items[persona] = credential; + + } + + /// + public bool Remove(PersonaGUID persona) + { + ArgumentNullException.ThrowIfNull(persona); + return _items.TryRemove(persona, out _); + + } + + /// + public IEnumerable EnumerateKeys() => [.. _items.Keys]; +} diff --git a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs new file mode 100644 index 0000000..291e43c --- /dev/null +++ b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs @@ -0,0 +1,213 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.CredentialCache.Storage; + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +/// +/// A credential store backed by the freedesktop.org Secret Service API via +/// libsecret-1.so.0. Each is stored as an item in the +/// default collection (typically the user's login keyring) with attributes +/// service and account identifying it. +/// +/// +/// Requires libsecret to be installed on the host. On headless systems without +/// a running secret-service implementation (e.g. minimal containers, build agents) +/// this provider will fail at the first operation; consumers should detect this +/// and fall back to if appropriate. +/// +[SupportedOSPlatform("linux")] +internal sealed class LinuxSecretServiceCredentialStore : ICredentialStore +{ + internal const string DefaultServiceName = "ktsu.CredentialCache"; + + private readonly string _serviceName; + + public LinuxSecretServiceCredentialStore(string serviceName = DefaultServiceName) + { + ArgumentException.ThrowIfNullOrEmpty(serviceName); + _serviceName = serviceName; + } + + /// + public string Name => "Linux libsecret (Secret Service)"; + + /// + public bool TryLoad(PersonaGUID persona, out Credential? credential) + { + ArgumentNullException.ThrowIfNull(persona); + credential = null; + + IntPtr error = IntPtr.Zero; + IntPtr passwordPtr = NativeMethods.secret_password_lookup_sync( + Schema.Handle, + IntPtr.Zero, + ref error, + "service", _serviceName, + "account", persona.ToString(), + IntPtr.Zero); + + ThrowIfError(error, "secret_password_lookup_sync"); + + if (passwordPtr == IntPtr.Zero) + { + return false; + } + + try + { + string? value = Marshal.PtrToStringUTF8(passwordPtr); + if (string.IsNullOrEmpty(value)) + { + return false; + } + credential = CredentialSerialization.DeserializeFromString(value); + return credential is not null; + } + finally + { + NativeMethods.secret_password_free(passwordPtr); + } + } + + /// + public void Save(PersonaGUID persona, Credential credential) + { + ArgumentNullException.ThrowIfNull(persona); + ArgumentNullException.ThrowIfNull(credential); + + string value = CredentialSerialization.SerializeToString(credential); + string label = $"{_serviceName}:{persona}"; + + IntPtr error = IntPtr.Zero; + bool stored = NativeMethods.secret_password_store_sync( + Schema.Handle, + IntPtr.Zero, + label, + value, + IntPtr.Zero, + ref error, + "service", _serviceName, + "account", persona.ToString(), + IntPtr.Zero); + + ThrowIfError(error, "secret_password_store_sync"); + + if (!stored) + { + throw new CredentialStoreException($"secret_password_store_sync returned false for '{persona}'."); + } + } + + /// + public bool Remove(PersonaGUID persona) + { + ArgumentNullException.ThrowIfNull(persona); + + IntPtr error = IntPtr.Zero; + bool removed = NativeMethods.secret_password_clear_sync( + Schema.Handle, + IntPtr.Zero, + ref error, + "service", _serviceName, + "account", persona.ToString(), + IntPtr.Zero); + + ThrowIfError(error, "secret_password_clear_sync"); + return removed; + } + + /// + public IEnumerable EnumerateKeys() + { + // libsecret search_sync requires GLib list/hash-table marshalling. Callers + // needing enumeration can track keys themselves or rely on the in-memory + // snapshot maintained by . + yield break; + } + + private static void ThrowIfError(IntPtr error, string operation) + { + if (error == IntPtr.Zero) + { + return; + } + string? message = null; + try + { + IntPtr messagePtr = Marshal.ReadIntPtr(error, IntPtr.Size * 2); + message = Marshal.PtrToStringUTF8(messagePtr); + } + catch + { + // Best-effort: message extraction shouldn't mask the real failure. + } + finally + { + NativeMethods.g_error_free(error); + } + + throw new CredentialStoreException($"{operation} failed: {message ?? ""}"); + } + + private static class Schema + { + internal static readonly IntPtr Handle = NativeMethods.secret_schema_new( + "dev.ktsu.CredentialCache", + flags: 0, + "service", 0, + "account", 0, + IntPtr.Zero); + } + + private static class NativeMethods + { + private const string Lib = "libsecret-1.so.0"; + + [DllImport(Lib, CharSet = CharSet.Ansi)] + internal static extern IntPtr secret_schema_new( + string name, int flags, + string attribute1Name, int attribute1Type, + string attribute2Name, int attribute2Type, + IntPtr terminator); + + [DllImport(Lib, CharSet = CharSet.Ansi)] + internal static extern IntPtr secret_password_lookup_sync( + IntPtr schema, + IntPtr cancellable, + ref IntPtr error, + string attribute1Name, string attribute1Value, + string attribute2Name, string attribute2Value, + IntPtr terminator); + + [DllImport(Lib, CharSet = CharSet.Ansi)] + internal static extern void secret_password_free(IntPtr password); + + [DllImport(Lib, CharSet = CharSet.Ansi)] + internal static extern bool secret_password_store_sync( + IntPtr schema, + IntPtr collection, + string label, + string password, + IntPtr cancellable, + ref IntPtr error, + string attribute1Name, string attribute1Value, + string attribute2Name, string attribute2Value, + IntPtr terminator); + + [DllImport(Lib, CharSet = CharSet.Ansi)] + internal static extern bool secret_password_clear_sync( + IntPtr schema, + IntPtr cancellable, + ref IntPtr error, + string attribute1Name, string attribute1Value, + string attribute2Name, string attribute2Value, + IntPtr terminator); + + [DllImport("libglib-2.0.so.0")] + internal static extern void g_error_free(IntPtr error); + } +} diff --git a/CredentialCache/Storage/MacOsCredentialStore.cs b/CredentialCache/Storage/MacOsCredentialStore.cs new file mode 100644 index 0000000..c40050a --- /dev/null +++ b/CredentialCache/Storage/MacOsCredentialStore.cs @@ -0,0 +1,242 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.CredentialCache.Storage; + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +/// +/// A credential store backed by the macOS Keychain Services API. Each +/// is stored as a kSecClassGenericPassword item with the +/// service name set to (overridable) and the account +/// name set to the persona GUID. +/// +[SupportedOSPlatform("osx")] +internal sealed class MacOsCredentialStore : ICredentialStore +{ + internal const string DefaultServiceName = "ktsu.CredentialCache"; + + private readonly string _serviceName; + + public MacOsCredentialStore(string serviceName = DefaultServiceName) + { + ArgumentException.ThrowIfNullOrEmpty(serviceName); + _serviceName = serviceName; + } + + /// + public string Name => "macOS Keychain"; + + /// + public bool TryLoad(PersonaGUID persona, out Credential? credential) + { + ArgumentNullException.ThrowIfNull(persona); + credential = null; + + byte[] account = Encoding.UTF8.GetBytes(persona.ToString()); + byte[] service = Encoding.UTF8.GetBytes(_serviceName); + + int status = NativeMethods.SecKeychainFindGenericPassword( + keychainOrArray: IntPtr.Zero, + serviceNameLength: (uint)service.Length, serviceName: service, + accountNameLength: (uint)account.Length, accountName: account, + passwordLength: out uint length, passwordData: out IntPtr passwordPtr, + itemRef: IntPtr.Zero); + + if (status == NativeMethods.errSecItemNotFound) + { + return false; + } + if (status != NativeMethods.errSecSuccess) + { + throw new CredentialStoreException($"SecKeychainFindGenericPassword failed ({status})."); + } + + try + { + byte[] blob = new byte[length]; + Marshal.Copy(passwordPtr, blob, 0, (int)length); + credential = CredentialSerialization.Deserialize(blob); + Array.Clear(blob, 0, blob.Length); + return credential is not null; + } + finally + { + _ = NativeMethods.SecKeychainItemFreeContent(IntPtr.Zero, passwordPtr); + } + } + + /// + public void Save(PersonaGUID persona, Credential credential) + { + ArgumentNullException.ThrowIfNull(persona); + ArgumentNullException.ThrowIfNull(credential); + + byte[] account = Encoding.UTF8.GetBytes(persona.ToString()); + byte[] service = Encoding.UTF8.GetBytes(_serviceName); + byte[] blob = CredentialSerialization.Serialize(credential); + + try + { + int findStatus = NativeMethods.SecKeychainFindGenericPasswordWithRef( + keychainOrArray: IntPtr.Zero, + serviceNameLength: (uint)service.Length, serviceName: service, + accountNameLength: (uint)account.Length, accountName: account, + passwordLength: out _, passwordData: out IntPtr existingPtr, + itemRef: out IntPtr itemRef); + + if (findStatus == NativeMethods.errSecSuccess) + { + try + { + int modifyStatus = NativeMethods.SecKeychainItemModifyAttributesAndData( + itemRef, attrList: IntPtr.Zero, + length: (uint)blob.Length, data: blob); + if (modifyStatus != NativeMethods.errSecSuccess) + { + throw new CredentialStoreException( + $"SecKeychainItemModifyAttributesAndData failed ({modifyStatus})."); + } + } + finally + { + if (existingPtr != IntPtr.Zero) + { + _ = NativeMethods.SecKeychainItemFreeContent(IntPtr.Zero, existingPtr); + } + if (itemRef != IntPtr.Zero) + { + NativeMethods.CFRelease(itemRef); + } + } + return; + } + if (findStatus != NativeMethods.errSecItemNotFound) + { + throw new CredentialStoreException($"SecKeychainFindGenericPassword failed ({findStatus})."); + } + + int addStatus = NativeMethods.SecKeychainAddGenericPassword( + keychain: IntPtr.Zero, + serviceNameLength: (uint)service.Length, serviceName: service, + accountNameLength: (uint)account.Length, accountName: account, + passwordLength: (uint)blob.Length, passwordData: blob, + itemRef: IntPtr.Zero); + + if (addStatus != NativeMethods.errSecSuccess) + { + throw new CredentialStoreException($"SecKeychainAddGenericPassword failed ({addStatus})."); + } + } + finally + { + Array.Clear(blob, 0, blob.Length); + } + } + + /// + public bool Remove(PersonaGUID persona) + { + ArgumentNullException.ThrowIfNull(persona); + + byte[] account = Encoding.UTF8.GetBytes(persona.ToString()); + byte[] service = Encoding.UTF8.GetBytes(_serviceName); + + int findStatus = NativeMethods.SecKeychainFindGenericPasswordWithRef( + keychainOrArray: IntPtr.Zero, + serviceNameLength: (uint)service.Length, serviceName: service, + accountNameLength: (uint)account.Length, accountName: account, + passwordLength: out _, passwordData: out IntPtr passwordPtr, + itemRef: out IntPtr itemRef); + + if (findStatus == NativeMethods.errSecItemNotFound) + { + return false; + } + if (findStatus != NativeMethods.errSecSuccess) + { + throw new CredentialStoreException($"SecKeychainFindGenericPassword failed ({findStatus})."); + } + + try + { + int deleteStatus = NativeMethods.SecKeychainItemDelete(itemRef); + if (deleteStatus != NativeMethods.errSecSuccess) + { + throw new CredentialStoreException($"SecKeychainItemDelete failed ({deleteStatus})."); + } + return true; + } + finally + { + if (passwordPtr != IntPtr.Zero) + { + _ = NativeMethods.SecKeychainItemFreeContent(IntPtr.Zero, passwordPtr); + } + if (itemRef != IntPtr.Zero) + { + NativeMethods.CFRelease(itemRef); + } + } + } + + /// + public IEnumerable EnumerateKeys() + { + // SecItemCopyMatching with a CFDictionary is required for enumeration. Implementing + // the CoreFoundation marshalling for an enumerate-only path adds substantial native + // surface; callers that need enumeration can track keys themselves or rely on the + // in-memory snapshot maintained by . + yield break; + } + + private static class NativeMethods + { + internal const int errSecSuccess = 0; + internal const int errSecItemNotFound = -25300; + + private const string Security = "/System/Library/Frameworks/Security.framework/Security"; + private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + + [DllImport(Security)] + internal static extern int SecKeychainFindGenericPassword( + IntPtr keychainOrArray, + uint serviceNameLength, byte[] serviceName, + uint accountNameLength, byte[] accountName, + out uint passwordLength, out IntPtr passwordData, + IntPtr itemRef); + + [DllImport(Security, EntryPoint = "SecKeychainFindGenericPassword")] + internal static extern int SecKeychainFindGenericPasswordWithRef( + IntPtr keychainOrArray, + uint serviceNameLength, byte[] serviceName, + uint accountNameLength, byte[] accountName, + out uint passwordLength, out IntPtr passwordData, + out IntPtr itemRef); + + [DllImport(Security)] + internal static extern int SecKeychainAddGenericPassword( + IntPtr keychain, + uint serviceNameLength, byte[] serviceName, + uint accountNameLength, byte[] accountName, + uint passwordLength, byte[] passwordData, + IntPtr itemRef); + + [DllImport(Security)] + internal static extern int SecKeychainItemModifyAttributesAndData( + IntPtr itemRef, IntPtr attrList, + uint length, byte[] data); + + [DllImport(Security)] + internal static extern int SecKeychainItemDelete(IntPtr itemRef); + + [DllImport(Security)] + internal static extern int SecKeychainItemFreeContent(IntPtr attrList, IntPtr data); + + [DllImport(CoreFoundation)] + internal static extern void CFRelease(IntPtr cf); + } +} diff --git a/CredentialCache/Storage/WindowsCredentialStore.cs b/CredentialCache/Storage/WindowsCredentialStore.cs new file mode 100644 index 0000000..87e6946 --- /dev/null +++ b/CredentialCache/Storage/WindowsCredentialStore.cs @@ -0,0 +1,212 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.CredentialCache.Storage; + +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using ktsu.Semantics.Strings; + +/// +/// A credential store backed by Windows Credential Manager (advapi32). Each +/// is stored as a generic credential whose target name is +/// {ServicePrefix}:{persona} and whose CredentialBlob is the UTF-8 JSON +/// representation of the . +/// +[SupportedOSPlatform("windows")] +internal sealed class WindowsCredentialStore : ICredentialStore +{ + internal const string DefaultServicePrefix = "ktsu.CredentialCache"; + + private readonly string _servicePrefix; + + public WindowsCredentialStore(string servicePrefix = DefaultServicePrefix) + { + ArgumentException.ThrowIfNullOrEmpty(servicePrefix); + _servicePrefix = servicePrefix; + } + + /// + public string Name => "Windows Credential Manager"; + + private string TargetFor(PersonaGUID persona) => $"{_servicePrefix}:{persona}"; + + /// + public bool TryLoad(PersonaGUID persona, out Credential? credential) + { + ArgumentNullException.ThrowIfNull(persona); + credential = null; + + if (!NativeMethods.CredRead(TargetFor(persona), NativeMethods.CRED_TYPE_GENERIC, 0, out nint handle)) + { + int err = Marshal.GetLastWin32Error(); + if (err == NativeMethods.ERROR_NOT_FOUND) + { + return false; + } + throw new CredentialStoreException($"CredRead failed for '{persona}'.", new Win32Exception(err)); + } + + try + { + NativeMethods.CREDENTIAL cred = Marshal.PtrToStructure(handle); + if (cred.CredentialBlob == IntPtr.Zero || cred.CredentialBlobSize == 0) + { + return false; + } + + byte[] blob = new byte[cred.CredentialBlobSize]; + Marshal.Copy(cred.CredentialBlob, blob, 0, blob.Length); + credential = CredentialSerialization.Deserialize(blob); + return credential is not null; + } + finally + { + NativeMethods.CredFree(handle); + } + } + + /// + public void Save(PersonaGUID persona, Credential credential) + { + ArgumentNullException.ThrowIfNull(persona); + ArgumentNullException.ThrowIfNull(credential); + + byte[] blob = CredentialSerialization.Serialize(credential); + if (blob.Length > NativeMethods.CRED_MAX_CREDENTIAL_BLOB_SIZE) + { + throw new CredentialStoreException( + $"Credential exceeds the Windows Credential Manager blob size limit of " + + $"{NativeMethods.CRED_MAX_CREDENTIAL_BLOB_SIZE} bytes (was {blob.Length})."); + } + + IntPtr blobPtr = Marshal.AllocHGlobal(blob.Length); + try + { + Marshal.Copy(blob, 0, blobPtr, blob.Length); + + NativeMethods.CREDENTIAL native = new() + { + Type = NativeMethods.CRED_TYPE_GENERIC, + TargetName = TargetFor(persona), + CredentialBlob = blobPtr, + CredentialBlobSize = blob.Length, + Persist = NativeMethods.CRED_PERSIST_LOCAL_MACHINE, + UserName = Environment.UserName, + }; + + if (!NativeMethods.CredWrite(ref native, 0)) + { + int err = Marshal.GetLastWin32Error(); + throw new CredentialStoreException($"CredWrite failed for '{persona}'.", new Win32Exception(err)); + } + } + finally + { + Marshal.FreeHGlobal(blobPtr); + Array.Clear(blob, 0, blob.Length); + } + } + + /// + public bool Remove(PersonaGUID persona) + { + ArgumentNullException.ThrowIfNull(persona); + if (NativeMethods.CredDelete(TargetFor(persona), NativeMethods.CRED_TYPE_GENERIC, 0)) + { + return true; + } + int err = Marshal.GetLastWin32Error(); + if (err == NativeMethods.ERROR_NOT_FOUND) + { + return false; + } + throw new CredentialStoreException($"CredDelete failed for '{persona}'.", new Win32Exception(err)); + } + + /// + public IEnumerable EnumerateKeys() + { + string filter = $"{_servicePrefix}:*"; + if (!NativeMethods.CredEnumerate(filter, 0, out int count, out IntPtr credentialsPtr)) + { + int err = Marshal.GetLastWin32Error(); + if (err == NativeMethods.ERROR_NOT_FOUND) + { + return []; + } + throw new CredentialStoreException("CredEnumerate failed.", new Win32Exception(err)); + } + + try + { + List keys = new(count); + int ptrSize = Marshal.SizeOf(); + string prefix = $"{_servicePrefix}:"; + + for (int i = 0; i < count; i++) + { + IntPtr credPtr = Marshal.ReadIntPtr(credentialsPtr, i * ptrSize); + NativeMethods.CREDENTIAL native = Marshal.PtrToStructure(credPtr); + string? target = native.TargetName; + if (target is null || !target.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + string personaText = target[prefix.Length..]; + keys.Add(SemanticString.Create(personaText)); + } + return keys; + } + finally + { + NativeMethods.CredFree(credentialsPtr); + } + } + + private static class NativeMethods + { + internal const int CRED_TYPE_GENERIC = 1; + internal const int CRED_PERSIST_LOCAL_MACHINE = 2; + internal const int ERROR_NOT_FOUND = 1168; + internal const int CRED_MAX_CREDENTIAL_BLOB_SIZE = 5 * 512; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct CREDENTIAL + { + public int Flags; + public int Type; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [MarshalAs(UnmanagedType.LPWStr)] public string? Comment; + public long LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + [MarshalAs(UnmanagedType.LPWStr)] public string? TargetAlias; + [MarshalAs(UnmanagedType.LPWStr)] public string? UserName; + } + + [DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CredWrite([In] ref CREDENTIAL credential, [In] uint flags); + + [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CredDelete(string target, int type, int reservedFlag); + + [DllImport("advapi32.dll", EntryPoint = "CredEnumerateW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CredEnumerate(string? filter, int flag, out int count, out IntPtr credentialsPtr); + + [DllImport("advapi32.dll")] + internal static extern void CredFree([In] IntPtr cred); + } +} diff --git a/Directory.Packages.props b/Directory.Packages.props index 1cb7911..87291d3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,7 +23,7 @@ - + diff --git a/README.md b/README.md index d2b7226..7ddbd07 100644 --- a/README.md +++ b/README.md @@ -1,180 +1,119 @@ # ktsu.CredentialCache -> A secure credential storage and management system for .NET applications. +> A cross-platform credential cache for .NET that stores secrets in the host's native keyring. [![License](https://img.shields.io/github/license/ktsu-dev/CredentialCache)](https://github.com/ktsu-dev/CredentialCache/blob/main/LICENSE.md) [![NuGet](https://img.shields.io/nuget/v/ktsu.CredentialCache.svg)](https://www.nuget.org/packages/ktsu.CredentialCache/) [![NuGet Downloads](https://img.shields.io/nuget/dt/ktsu.CredentialCache.svg)](https://www.nuget.org/packages/ktsu.CredentialCache/) [![Build Status](https://github.com/ktsu-dev/CredentialCache/workflows/build/badge.svg)](https://github.com/ktsu-dev/CredentialCache/actions) -[![GitHub Stars](https://img.shields.io/github/stars/ktsu-dev/CredentialCache?style=social)](https://github.com/ktsu-dev/CredentialCache/stargazers) -## Introduction +## Overview -CredentialCache is a .NET library that provides a secure and convenient way to store, retrieve, and manage credentials in applications. It offers a flexible caching mechanism for various credential types while handling encryption, security, and persistence automatically. +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: -## Features +| Platform | Backing store | API | +|----------|--------------|-----| +| Windows | Windows Credential Manager | `advapi32` (`CredRead`/`CredWrite`/`CredDelete`) | +| macOS | Keychain Services | `Security.framework` (`SecKeychain*`) | +| Linux | freedesktop.org Secret Service | `libsecret-1.so.0` | +| Other / opt-out | None | `InMemoryCredentialStore` | -- **Secure Storage**: Encrypts sensitive credentials using platform-specific security features -- **Memory Caching**: Efficiently caches credentials in memory for quick access -- **Multiple Credential Types**: Supports usernames/passwords, API keys, tokens, and certificates -- **Automatic Expiration**: Time-based expiration for cached credentials -- **Credential Rotation**: Support for credential rotation and refresh workflows -- **Thread Safety**: Safe for concurrent access from multiple threads -- **Extensible**: Easily extend with custom credential types and storage providers +Each persona's credential is stored as its own entry in the OS keyring — the library never writes a plaintext blob to disk. ## Installation -### Package Manager Console - -```powershell -Install-Package ktsu.CredentialCache -``` - -### .NET CLI - ```bash dotnet add package ktsu.CredentialCache ``` -### Package Reference - -```xml - -``` - -## Usage Examples - -### Basic Example +## Quick start ```csharp using ktsu.CredentialCache; +using ktsu.CredentialCache.Storage; -// Create a credential cache -var cache = new CredentialCache(); - -// Store a credential -var credential = new UsernamePasswordCredential -{ - Username = "user@example.com", - Password = "securePassword123", - Domain = "example.com" -}; - -cache.Store("myAppLogin", credential); +// Pick the platform-native store explicitly... +ICredentialStore store = CredentialStoreFactory.CreateDefault(); +using CredentialCache cache = new(store); -// Retrieve the credential later -if (cache.TryGet("myAppLogin", out UsernamePasswordCredential retrievedCredential)) -{ - Console.WriteLine($"Retrieved username: {retrievedCredential.Username}"); - // Use the credential for authentication -} -``` +// ...or just use the singleton, which calls CreateDefault() on first access. +CredentialCache singleton = CredentialCache.Instance; -### Working with API Credentials +PersonaGUID persona = CredentialCache.CreatePersonaGUID(); -```csharp -// Store an API key -var apiCredential = new ApiKeyCredential +cache.AddOrReplace(persona, new CredentialWithUsernamePassword { - Key = "api_12345abcde", - Secret = "apisecret_xyz789", - Endpoint = "https://api.example.com" -}; - -// Store with expiration -cache.Store("apiAccess", apiCredential, TimeSpan.FromHours(1)); + Username = ktsu.Semantics.Strings.SemanticString.Create("alice"), + Password = ktsu.Semantics.Strings.SemanticString.Create("hunter2"), +}); -// Check if credential exists and is not expired -if (cache.Contains("apiAccess")) +if (cache.TryGet(persona, out Credential? stored) + && stored is CredentialWithUsernamePassword creds) { - var cred = cache.Get("apiAccess"); - // Use the API credential + Console.WriteLine($"Hello, {creds.Username}"); } -``` - -### Advanced Usage with Persistent Storage -```csharp -// Create a cache with persistent storage -var options = new CredentialCacheOptions -{ - PersistToStorage = true, - StoragePath = "credentials.dat", - EncryptionLevel = EncryptionLevel.High -}; - -var persistentCache = new CredentialCache(options); - -// Store a credential that will be saved to disk -var oauthCredential = new OAuthCredential -{ - AccessToken = "access_token_123", - RefreshToken = "refresh_token_456", - ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) -}; - -persistentCache.Store("oauth", oauthCredential); - -// Later, even after application restart: -var loadedCache = new CredentialCache(options); -if (loadedCache.TryGet("oauth", out OAuthCredential oauth)) -{ - if (oauth.IsExpired) - { - // Refresh the token - oauth = RefreshOAuthToken(oauth); - loadedCache.Store("oauth", oauth); - } - - // Use the OAuth token -} +cache.Remove(persona); ``` -## API Reference +## Credential types -### `CredentialCache` Class +- `CredentialWithNothing` — sentinel for "no credential required". +- `CredentialWithToken` — opaque bearer / API token. +- `CredentialWithUsernamePassword` — classic username + password pair. -The main class for storing and retrieving credentials. +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. -#### Properties +## Customising the backing store -| Name | Type | Description | -|------|------|-------------| -| `Count` | `int` | Number of credentials in the cache | -| `Options` | `CredentialCacheOptions` | Configuration options for this cache instance | +`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): -#### Methods - -| Name | Return Type | Description | -|------|-------------|-------------| -| `Store(string key, ICredential credential, TimeSpan? expiration = null)` | `void` | Stores a credential with optional expiration | -| `Get(string key) where T : ICredential` | `T` | Gets a credential by key (throws if not found) | -| `TryGet(string key, out T credential) where T : ICredential` | `bool` | Tries to get a credential by key | -| `Remove(string key)` | `bool` | Removes a credential from the cache | -| `Contains(string key)` | `bool` | Checks if a credential exists and is not expired | -| `Clear()` | `void` | Removes all credentials from the cache | - -### Credential Types - -| Type | Description | -|------|-------------| -| `UsernamePasswordCredential` | Standard username and password combination | -| `ApiKeyCredential` | API key and optional secret | -| `OAuthCredential` | OAuth access and refresh tokens | -| `CertificateCredential` | Certificate-based authentication | - -## Contributing +```csharp +ICredentialStore store = new MyCustomStore(); +CredentialCache.ConfigureStore(store); // must be called before first Instance access +``` -Contributions are welcome! Here's how you can help: +For unit tests, use the in-memory store and skip the singleton entirely: -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add some amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +```csharp +using CredentialCache cache = new(new InMemoryCredentialStore()); +``` -Please ensure your code follows security best practices when dealing with sensitive credential information. +## 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. +- **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). + +## API summary + +### `CredentialCache` + +| Member | Description | +|--------|-------------| +| `CredentialCache(ICredentialStore store)` | Construct an instance with an explicit store. | +| `static Instance` | Process-wide singleton (lazy, thread-safe). | +| `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. | +| `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`. | +| `TryCreate(out cred)` | Constructs a credential via a registered factory. | +| `Dispose()` | Releases in-memory state. The OS store is left untouched. | + +### `ICredentialStore` + +| Member | Description | +|--------|-------------| +| `Name` | Diagnostic identifier (`"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). | ## License -This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. +MIT — see [LICENSE.md](LICENSE.md). From ca669a05c6ab5bdef3f70f7760684d03804674b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 10:00:39 +0000 Subject: [PATCH 2/3] Force CRLF working-tree line endings for .cs files The cross-platform CI run on PR #59 fails on Linux and macOS with IDE0055 (formatting) because .editorconfig declares `end_of_line = crlf` for the whole tree, but actions/checkout@v4 leaves files at their git-normalised LF on non-Windows runners. The original CI was Windows-only, where the hosted runner's git defaults to autocrlf=true, masking the discrepancy. Mark .cs/.csproj/.props/.targets/.editorconfig as `text eol=crlf` so every checkout (including Linux and macOS) renders these files with CRLF to match the editorconfig declaration. The committed blob in git stays normalised; this only affects the working tree. https://claude.ai/code/session_017B9mN9F7C3pWGZRyoKBd6R --- .gitattributes | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitattributes b/.gitattributes index b272e2b..8abd89f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,6 +16,16 @@ # in Unix via a file share from Windows, the scripts will work. *.sh text eol=lf +# .editorconfig declares end_of_line = crlf for the entire tree. Force CRLF in +# the working copy so cross-platform CI (and Linux/macOS contributors) see the +# files exactly as editorconfig demands -- otherwise the IDE0055 formatting +# analyzer (treat-warnings-as-errors) fails the build on non-Windows runners. +*.cs text eol=crlf +*.csproj text eol=crlf +*.props text eol=crlf +*.targets text eol=crlf +*.editorconfig text eol=crlf + ############################### # Git Large File System (LFS) # ############################### From 77d73af35964763decedcf57162673700e854a9f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 10:20:56 +0000 Subject: [PATCH 3/3] Drop `--logger` from dotnet test in cross-platform workflow `global.json` selects `Microsoft.Testing.Platform` as the test runner, which does not accept the legacy VSTest `--logger "console;verbosity=normal"` form. The argument was being misinterpreted as a project path and the runner reported `Zero tests ran` with exit code 1 on every platform. Removing the unused logger flag is enough -- the platform's default console output is sufficient and all 17 tests pass locally with the same command. https://claude.ai/code/session_017B9mN9F7C3pWGZRyoKBd6R --- .github/workflows/cross-platform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index 09099d1..c8f23c5 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -53,4 +53,4 @@ jobs: run: dotnet build CredentialCache.sln --configuration Release --no-restore - name: Test - run: dotnet test CredentialCache.Test/CredentialCache.Test.csproj --configuration Release --no-build --logger "console;verbosity=normal" + run: dotnet test CredentialCache.Test/CredentialCache.Test.csproj --configuration Release --no-build