diff --git a/CHANGELOG.md b/CHANGELOG.md index d9503ae..77938ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.3.0] — 2026-10-13 + +Adds **`accounts info`**, which shows where and how credentials are stored. It is a new +command in the `accounts` branch and needs no code change in a consuming CLI: anything +calling `AddAccountsBranch()` gets it on upgrade. + +No provider-facing interface changed, so the provider packages need no re-cut: their +`[2.2.0, 3.0.0)` range admits 2.3.0. A range resolves to its floor, though, so a CLI that +only references the providers keeps getting 2.2.0 until it references 2.3.0 directly. + +### Added + +- **`accounts info`** — shows where and how credentials are stored, so a user can find the + credentials directory without knowing how the host CLI configured it, and a developer can + confirm what is really wired up. For the file backend it lists the credentials directory, + keystore and selections paths, the encryption (AES-256-GCM, Windows DPAPI, or the type name + of a custom `ICredentialEncryption`), whether `AdditionalEntropy` is set (presence only, + never the value), and the directory's permissions — warning when they reach beyond the + owner, since those permissions are the default mode's security boundary. For Keychain and + libsecret it lists the app identifier (and collection). It describes the resolved + `ICredentialManager` rather than `CredentialStoreOptions`, so it is accurate for a + hand-registered DPAPI store and exposes a custom encryption registered in the wrong order. + It never decrypts a credential or queries an OS secret store, so it cannot prompt. + ## [2.2.0] — 2026-10-06 Moves to **Spectre.Console.Cli 0.57.2**. Spectre.Console.Cli now ships on its own release @@ -952,7 +976,8 @@ Consumers needed a way to read a specific stored credential's secret at runtime - SourceLink, deterministic builds, embedded symbols, published symbol packages. - `TreatWarningsAsErrors=true`, `AnalysisLevel=latest` — zero-warning public API. -[Unreleased]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/compare/v2.2.0...HEAD +[Unreleased]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/compare/v2.3.0...HEAD +[2.3.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v2.3.0 [2.2.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v2.2.0 [2.1.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v2.1.0 [2.0.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v2.0.0 diff --git a/README.md b/README.md index ce60386..a1562e4 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ public sealed class SyncCommand(AdobeAuthenticationService auth) : AsyncCommand | `accounts delete [id] [--force]` | Remove a credential. Clears the selection if it pointed at the deleted entry. | | `accounts export [--force]` | Write every credential to a single passphrase-encrypted archive. | | `accounts import [--on-conflict skip\|overwrite]` | Restore credentials from an archive into this machine's store. | +| `accounts info` | Show where and how credentials are stored: the backend, the credentials directory, keystore and selections paths, the encryption in use (AES-GCM file, DPAPI, Keychain, libsecret or a custom type), whether `AdditionalEntropy` is set, and the directory's permissions. Never decrypts anything or touches an OS secret store. | Every command accepts `-v` / `--verbose` for full stack-trace output when something goes wrong. @@ -424,7 +425,8 @@ Order matters and getting it wrong fails **silently**. `AddCredentialStore` regi a custom backend registered *first* is quietly ignored and your credentials are encrypted with the default one. There is no error to tell you, which is exactly the wrong failure mode for a change made to strengthen encryption. Register after, and verify with -`provider.GetRequiredService().GetType()` if in doubt. +`provider.GetRequiredService().GetType()` if in doubt — or run +`accounts info`, which names the encryption type the store is actually using. --- diff --git a/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs b/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs index b748903..c1d6d63 100644 --- a/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs +++ b/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs @@ -12,7 +12,8 @@ public static class CommandConfiguratorExtensions { /// /// Registers the accounts branch of credential-management commands - /// (add, list, select, delete). + /// (add, list, select, delete, export, + /// import, info). /// public static IConfigurator AddAccountsBranch(this IConfigurator configurator) { @@ -47,6 +48,10 @@ public static IConfigurator AddAccountsBranch(this IConfigurator configurator) .WithDescription("Import credentials from an encrypted archive") .WithExample("accounts", "import", "credentials.bundle") .WithExample("accounts", "import", "credentials.bundle", "--on-conflict", "overwrite"); + + accounts.AddCommand("info") + .WithDescription("Show where and how credentials are stored") + .WithExample("accounts", "info"); }); return configurator; diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreDescriber.cs b/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreDescriber.cs new file mode 100644 index 0000000..3a483af --- /dev/null +++ b/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreDescriber.cs @@ -0,0 +1,157 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +using NextIteration.SpectreConsole.Auth.Encryption; +using NextIteration.SpectreConsole.Auth.Persistence; +using NextIteration.SpectreConsole.Auth.Persistence.Keychain; +using NextIteration.SpectreConsole.Auth.Persistence.Libsecret; + +namespace NextIteration.SpectreConsole.Auth.Commands +{ + /// + /// One labelled line of accounts info output. , when set, + /// explains why the value weakens the store (e.g. a credentials directory readable by + /// other users). + /// + internal sealed record CredentialStoreFact(string Label, string Value, string? Warning = null); + + /// + /// Builds the accounts info description from the + /// that DI actually resolved, rather than from . + /// + /// + /// Describing the resolved instance is deliberate. The DPAPI setup in the README registers + /// by hand without AddCredentialStore, so there + /// are no options to read; and a custom registered in + /// the wrong order is silently ignored. Inspecting what is wired up reports both truthfully. + /// Nothing here reads a secret or touches the OS secret stores, so the command cannot + /// trigger an unlock prompt. + /// + internal static class CredentialStoreDescriber + { + // Group and other permission bits. Any of them set on the credentials directory + // widens the file backend's only security boundary beyond the owning user. + private const UnixFileMode GroupOrOther = + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + internal static IReadOnlyList Describe(ICredentialManager credentialManager) + { + ArgumentNullException.ThrowIfNull(credentialManager); + + var facts = new List(); + + if (credentialManager is FileCredentialManager file) + { + DescribeFileBackend(file, facts); + } + else if (OperatingSystem.IsMacOS() && credentialManager is KeychainCredentialManager keychain) + { + DescribeKeychainBackend(keychain, facts); + } + else if (OperatingSystem.IsLinux() && credentialManager is LibsecretCredentialManager libsecret) + { + DescribeLibsecretBackend(libsecret, facts); + } + else + { + facts.Add(new("Backend", $"Custom ({credentialManager.GetType().FullName})")); + } + + facts.Add(new("Library version", LibraryVersion())); + facts.Add(new("Platform", $"{RuntimeInformation.OSDescription} ({RuntimeInformation.RuntimeIdentifier})")); + + return facts; + } + + private static void DescribeFileBackend(FileCredentialManager file, List facts) + { + var directory = file.CredentialsDirectoryPath; + + facts.Add(new("Backend", "Encrypted files")); + facts.Add(new("Credentials directory", Path.GetFullPath(directory))); + facts.Add(DescribeDirectoryPermissions(directory)); + + switch (file.Encryption) + { + case LocalFileCredentialEncryption local: + facts.Add(new("Encryption", "AES-256-GCM (data key sealed with PBKDF2-HMAC-SHA256)")); + facts.Add(new("Keystore", Path.GetFullPath(local.KeystorePath) + (File.Exists(local.KeystorePath) ? string.Empty : " (not created yet)"))); + facts.Add(local.HasAdditionalEntropy + ? new("Key binding", "AdditionalEntropy (hardened)") + : new("Key binding", "None (default; portable, protected by directory permissions)")); + break; + + case var encryption when OperatingSystem.IsWindows() && encryption is DpapiCredentialEncryption: + facts.Add(new("Encryption", "Windows DPAPI (current user scope)")); + facts.Add(new("Key binding", "Windows user profile")); + break; + + case var encryption: + facts.Add(new("Encryption", $"Custom ({encryption.GetType().FullName})")); + break; + } + + facts.Add(new("Selections file", Path.GetFullPath(file.SelectionFilePath))); + } + + [SupportedOSPlatform("macos")] + private static void DescribeKeychainBackend(KeychainCredentialManager keychain, List facts) + { + facts.Add(new("Backend", "macOS Keychain (experimental)")); + facts.Add(new("App identifier", keychain.AppIdentifier)); + facts.Add(new("Items", $"Generic passwords, service '{keychain.AppIdentifier}.'")); + facts.Add(new("Encryption", "Managed by macOS Keychain")); + } + + [SupportedOSPlatform("linux")] + private static void DescribeLibsecretBackend(LibsecretCredentialManager libsecret, List facts) + { + facts.Add(new("Backend", "Secret Service via libsecret (experimental)")); + facts.Add(new("App identifier", libsecret.AppIdentifier)); + facts.Add(new("Collection", libsecret.Collection)); + facts.Add(new("Encryption", "Managed by the Secret Service daemon")); + } + + internal static CredentialStoreFact DescribeDirectoryPermissions(string directory) + { + const string label = "Directory permissions"; + + try + { + if (OperatingSystem.IsWindows()) + { + var isProtected = new DirectoryInfo(directory).GetAccessControl().AreAccessRulesProtected; + return isProtected + ? new(label, "Explicit ACL (inheritance disabled)") + : new(label, "Inherited from parent folder", + Warning: "The credentials directory inherits its ACL, so other accounts may be able to read it."); + } + + var mode = File.GetUnixFileMode(directory); + var octal = Convert.ToString((int)mode & 0x1FF, 8).PadLeft(4, '0'); + return (mode & GroupOrOther) == 0 + ? new(label, $"{octal} (owner only)") + : new(label, octal, + Warning: "The credentials directory is accessible to group or other users. Running 'chmod 700' on it restricts it to its owner."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return new(label, "Unknown", Warning: $"Could not read the credentials directory's permissions: {ex.Message}"); + } + } + + private static string LibraryVersion() + { + var assembly = typeof(CredentialStoreDescriber).Assembly; + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + + // Drop the SourceLink "+" suffix; the version is what users quote. + var plus = version.IndexOf('+', StringComparison.Ordinal); + return plus < 0 ? version : version[..plus]; + } + } +} diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreInfoCommand.cs b/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreInfoCommand.cs new file mode 100644 index 0000000..a4e6fb1 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreInfoCommand.cs @@ -0,0 +1,67 @@ +using NextIteration.SpectreConsole.Auth.Persistence; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace NextIteration.SpectreConsole.Auth.Commands +{ + /// + /// Spectre.Console command for accounts info. Shows where and how credentials + /// are stored: the backend, the on-disk paths for the file backend, the encryption in + /// use, and whether the store is bound to a secret. + /// + /// + /// + /// The output describes the that DI actually resolved, + /// so it also answers "is my custom ICredentialEncryption really in use?". + /// + /// + /// It never decrypts a credential or queries an OS secret store, so it is safe to run on + /// a locked keychain and cannot prompt. Secret values, including + /// , are reported only as present + /// or absent. + /// + /// DI constructor. + /// + public sealed class CredentialStoreInfoCommand(ICredentialManager credentialManager) : Command + { + private readonly ICredentialManager _credentialManager = credentialManager; + + /// + public override int Execute(CommandContext context, AccountsCommandSettings settings, CancellationToken cancellationToken) + { + try + { + var facts = CredentialStoreDescriber.Describe(_credentialManager); + var labelWidth = facts.Max(f => f.Label.Length); + + foreach (var fact in facts) + { + // The label goes through Spectre for styling, but the value is written + // raw: Spectre hard-wraps anything wider than the console, splitting a + // long path with real newlines so it no longer copy-pastes. Left to the + // terminal, an over-long line soft-wraps and copies intact. + AnsiConsole.Markup($"[bold]{Markup.Escape(fact.Label.PadRight(labelWidth))}[/] "); + AnsiConsole.Profile.Out.Writer.WriteLine(fact.Value); + } + + var warnings = facts.Where(f => f.Warning is not null).ToList(); + if (warnings.Count > 0) + { + AnsiConsole.WriteLine(); + foreach (var fact in warnings) + { + AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(fact.Warning!)}[/]"); + } + } + + return 0; + } + catch (Exception ex) + { + CommandErrorReporter.Report(ex, "Error describing the credential store", settings.Verbose); + return 1; + } + } + } +} diff --git a/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs b/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs index ee77da3..85054f8 100644 --- a/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs +++ b/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs @@ -171,6 +171,15 @@ public LocalFileCredentialEncryption(string credentialsDirectory, byte[]? additi _dataKey = new Lazy>(LoadOrCreateDataKeyAsync, LazyThreadSafetyMode.ExecutionAndPublication); } + /// Path of the .keystore file. Read by accounts info. + internal string KeystorePath => _keyFile; + + /// + /// Whether caller-supplied entropy is mixed into the KEK (hardened mode). Reports + /// only its presence — the value itself is never exposed. + /// + internal bool HasAdditionalEntropy => _callerEntropy is not null; + /// public async Task EncryptAsync(string plainText) { diff --git a/src/NextIteration.SpectreConsole.Auth/NextIteration.SpectreConsole.Auth.csproj b/src/NextIteration.SpectreConsole.Auth/NextIteration.SpectreConsole.Auth.csproj index ea1d023..f6eb982 100644 --- a/src/NextIteration.SpectreConsole.Auth/NextIteration.SpectreConsole.Auth.csproj +++ b/src/NextIteration.SpectreConsole.Auth/NextIteration.SpectreConsole.Auth.csproj @@ -12,7 +12,7 @@ NextIteration.SpectreConsole.Auth - 2.2.0 + 2.3.0 Credential storage, encryption, and Spectre.Console CLI commands for managing provider credentials in CLI tools. true $(MSBuildThisFileDirectory)..\..\artifacts\packages diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs index 52e03cb..2d8a88e 100644 --- a/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs +++ b/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs @@ -49,6 +49,15 @@ public FileCredentialManager( EnsureDirectoryExists(); } + /// The directory this manager stores credentials in. Read by accounts info. + internal string CredentialsDirectoryPath => _credentialsDirectory; + + /// The selections file inside . + internal string SelectionFilePath => _selectionFile; + + /// The encryption backend actually in use, which may be a custom one. + internal ICredentialEncryption Encryption => _encryption; + /// public async Task> ListCredentialsAsync(string providerName, CancellationToken cancellationToken = default) { diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs index 17a856a..80e1abc 100644 --- a/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs +++ b/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs @@ -58,6 +58,9 @@ public KeychainCredentialManager( .ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase); } + /// The identifier scoping this CLI's Keychain items. Read by accounts info. + internal string AppIdentifier => _appIdentifier; + /// public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData, CancellationToken cancellationToken = default) { diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs index cd7d009..7c892c2 100644 --- a/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs +++ b/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs @@ -90,6 +90,12 @@ public LibsecretCredentialManager( .ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase); } + /// The identifier scoping this CLI's keyring items. Read by accounts info. + internal string AppIdentifier => _appIdentifier; + + /// The Secret Service collection new items are written to. + internal string Collection => _collection; + /// public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData, CancellationToken cancellationToken = default) { diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CredentialStoreDescriberTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CredentialStoreDescriberTests.cs new file mode 100644 index 0000000..94ed30d --- /dev/null +++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CredentialStoreDescriberTests.cs @@ -0,0 +1,149 @@ +using NextIteration.SpectreConsole.Auth.Commands; +using NextIteration.SpectreConsole.Auth.Encryption; +using NextIteration.SpectreConsole.Auth.Persistence; +using NextIteration.SpectreConsole.Auth.Tests.Infrastructure; + +using Xunit; + +namespace NextIteration.SpectreConsole.Auth.Tests.Commands +{ + public sealed class CredentialStoreDescriberTests + { + private static string ValueOf(IReadOnlyList facts, string label) => + Assert.Single(facts, f => f.Label == label).Value; + + [Fact] + public void Describe_FileBackendDefaultMode_ReportsPathsAndPortableKeyBinding() + { + using var temp = new TempDir(); + var manager = new FileCredentialManager(new LocalFileCredentialEncryption(temp.Path), temp.Path); + + var facts = CredentialStoreDescriber.Describe(manager); + + Assert.Equal("Encrypted files", ValueOf(facts, "Backend")); + Assert.Equal(Path.GetFullPath(temp.Path), ValueOf(facts, "Credentials directory")); + Assert.StartsWith("AES-256-GCM", ValueOf(facts, "Encryption"), StringComparison.Ordinal); + var keyBinding = Assert.Single(facts, f => f.Label == "Key binding"); + Assert.StartsWith("None (default; portable", keyBinding.Value, StringComparison.Ordinal); + Assert.Equal(Path.Join(Path.GetFullPath(temp.Path), "selections.json"), ValueOf(facts, "Selections file")); + } + + [Fact] + public async Task Describe_Keystore_ReportsWhetherItExistsYet() + { + using var temp = new TempDir(); + var encryption = new LocalFileCredentialEncryption(temp.Path); + var manager = new FileCredentialManager(encryption, temp.Path); + var keystore = Path.Join(Path.GetFullPath(temp.Path), ".keystore"); + + Assert.Equal(keystore + " (not created yet)", ValueOf(CredentialStoreDescriber.Describe(manager), "Keystore")); + + _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}"); + + Assert.Equal(keystore, ValueOf(CredentialStoreDescriber.Describe(manager), "Keystore")); + } + + [Fact] + public void Describe_FileBackendWithEntropy_ReportsHardenedWithoutTheValue() + { + using var temp = new TempDir(); + var entropy = "per-deployment-secret"u8.ToArray(); + var manager = new FileCredentialManager(new LocalFileCredentialEncryption(temp.Path, entropy), temp.Path); + + var facts = CredentialStoreDescriber.Describe(manager); + + var keyBinding = Assert.Single(facts, f => f.Label == "Key binding"); + Assert.Equal("AdditionalEntropy (hardened)", keyBinding.Value); + Assert.Null(keyBinding.Warning); + Assert.DoesNotContain(facts, f => $"{f.Value} {f.Warning}".Contains("per-deployment-secret", StringComparison.Ordinal)); + } + + [Fact] + public void Describe_CustomEncryption_NamesTheTypeActuallyInUse() + { + using var temp = new TempDir(); + var manager = new FileCredentialManager(new FakeEncryption(), temp.Path); + + var facts = CredentialStoreDescriber.Describe(manager); + + Assert.Equal($"Custom ({typeof(FakeEncryption).FullName})", ValueOf(facts, "Encryption")); + Assert.DoesNotContain(facts, f => f.Label is "Keystore" or "Key binding"); + } + + [Fact] + public void Describe_CustomManager_NamesTheTypeAndStillReportsVersionAndPlatform() + { + var facts = CredentialStoreDescriber.Describe(new FakeManager()); + + Assert.Equal($"Custom ({typeof(FakeManager).FullName})", ValueOf(facts, "Backend")); + Assert.DoesNotContain("+", ValueOf(facts, "Library version"), StringComparison.Ordinal); + Assert.NotEmpty(ValueOf(facts, "Platform")); + } + + [Fact] + public void DescribeDirectoryPermissions_OwnerOnlyDirectory_IsNotAWarning() + { + using var temp = new TempDir(); + var directory = Path.Join(temp.Path, "creds"); + _ = new FileCredentialManager(new FakeEncryption(), directory); // creates it hardened + + var fact = CredentialStoreDescriber.DescribeDirectoryPermissions(directory); + + Assert.Null(fact.Warning); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal("0700 (owner only)", fact.Value); + } + } + + [Fact] + public void DescribeDirectoryPermissions_GroupReadableDirectory_IsAWarning() + { + if (OperatingSystem.IsWindows()) + { + return; // Unix mode bits; the Windows ACL path is covered by the owner-only test. + } + + using var temp = new TempDir(); + File.SetUnixFileMode(temp.Path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute); + + var fact = CredentialStoreDescriber.DescribeDirectoryPermissions(temp.Path); + + Assert.Equal("0750", fact.Value); + Assert.NotNull(fact.Warning); + } + + [Fact] + public void DescribeDirectoryPermissions_MissingDirectory_ReportsUnknownWithoutThrowing() + { + using var temp = new TempDir(); + + var fact = CredentialStoreDescriber.DescribeDirectoryPermissions(Path.Join(temp.Path, "absent")); + + Assert.Equal("Unknown", fact.Value); + Assert.NotNull(fact.Warning); + } + + private sealed class FakeEncryption : ICredentialEncryption + { + public Task EncryptAsync(string plainText) => Task.FromResult(plainText); + + public Task DecryptAsync(string encryptedText) => Task.FromResult(encryptedText); + } + + private sealed class FakeManager : ICredentialManager + { + public Task> ListCredentialsAsync(string providerName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task DeleteCredentialAsync(string accountId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task SelectCredentialAsync(string accountId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetSelectedCredentialAsync(string providerName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetCredentialByIdAsync(string providerName, string accountId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetProviderNamesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> ExportCredentialsAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task RestoreCredentialAsync(CredentialExport credential, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + } +}