Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> [--force]` | Write every credential to a single passphrase-encrypted archive. |
| `accounts import <file> [--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.

Expand Down Expand Up @@ -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<ICredentialEncryption>().GetType()` if in doubt.
`provider.GetRequiredService<ICredentialEncryption>().GetType()` if in doubt — or run
`accounts info`, which names the encryption type the store is actually using.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public static class CommandConfiguratorExtensions
{
/// <summary>
/// Registers the <c>accounts</c> branch of credential-management commands
/// (<c>add</c>, <c>list</c>, <c>select</c>, <c>delete</c>).
/// (<c>add</c>, <c>list</c>, <c>select</c>, <c>delete</c>, <c>export</c>,
/// <c>import</c>, <c>info</c>).
/// </summary>
public static IConfigurator AddAccountsBranch(this IConfigurator configurator)
{
Expand Down Expand Up @@ -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<CredentialStoreInfoCommand>("info")
.WithDescription("Show where and how credentials are stored")
.WithExample("accounts", "info");
});

return configurator;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// One labelled line of <c>accounts info</c> output. <see cref="Warning"/>, when set,
/// explains why the value weakens the store (e.g. a credentials directory readable by
/// other users).
/// </summary>
internal sealed record CredentialStoreFact(string Label, string Value, string? Warning = null);

/// <summary>
/// Builds the <c>accounts info</c> description from the <see cref="ICredentialManager"/>
/// that DI actually resolved, rather than from <see cref="CredentialStoreOptions"/>.
/// </summary>
/// <remarks>
/// Describing the resolved instance is deliberate. The DPAPI setup in the README registers
/// <see cref="FileCredentialManager"/> by hand without <c>AddCredentialStore</c>, so there
/// are no options to read; and a custom <see cref="ICredentialEncryption"/> 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.
/// </remarks>
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<CredentialStoreFact> Describe(ICredentialManager credentialManager)
{
ArgumentNullException.ThrowIfNull(credentialManager);

var facts = new List<CredentialStoreFact>();

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<CredentialStoreFact> 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<CredentialStoreFact> facts)
{
facts.Add(new("Backend", "macOS Keychain (experimental)"));
facts.Add(new("App identifier", keychain.AppIdentifier));
facts.Add(new("Items", $"Generic passwords, service '{keychain.AppIdentifier}.<provider>'"));
facts.Add(new("Encryption", "Managed by macOS Keychain"));
}

[SupportedOSPlatform("linux")]
private static void DescribeLibsecretBackend(LibsecretCredentialManager libsecret, List<CredentialStoreFact> 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<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString()
?? "unknown";

// Drop the SourceLink "+<commit sha>" suffix; the version is what users quote.
var plus = version.IndexOf('+', StringComparison.Ordinal);
return plus < 0 ? version : version[..plus];
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using NextIteration.SpectreConsole.Auth.Persistence;

using Spectre.Console;
using Spectre.Console.Cli;

namespace NextIteration.SpectreConsole.Auth.Commands
{
/// <summary>
/// Spectre.Console command for <c>accounts info</c>. 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.
/// </summary>
/// <remarks>
/// <para>
/// The output describes the <see cref="ICredentialManager"/> that DI actually resolved,
/// so it also answers "is my custom <c>ICredentialEncryption</c> really in use?".
/// </para>
/// <para>
/// 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
/// <see cref="CredentialStoreOptions.AdditionalEntropy"/>, are reported only as present
/// or absent.
/// </para>
/// <para>DI constructor.</para>
/// </remarks>
public sealed class CredentialStoreInfoCommand(ICredentialManager credentialManager) : Command<AccountsCommandSettings>
{
private readonly ICredentialManager _credentialManager = credentialManager;

/// <inheritdoc />
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;
}
Comment thread
StuartMeeks marked this conversation as resolved.
Dismissed
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ public LocalFileCredentialEncryption(string credentialsDirectory, byte[]? additi
_dataKey = new Lazy<Task<byte[]>>(LoadOrCreateDataKeyAsync, LazyThreadSafetyMode.ExecutionAndPublication);
}

/// <summary>Path of the <c>.keystore</c> file. Read by <c>accounts info</c>.</summary>
internal string KeystorePath => _keyFile;

/// <summary>
/// Whether caller-supplied entropy is mixed into the KEK (hardened mode). Reports
/// only its presence — the value itself is never exposed.
/// </summary>
internal bool HasAdditionalEntropy => _callerEntropy is not null;

/// <inheritdoc />
public async Task<string> EncryptAsync(string plainText)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<PropertyGroup>
<PackageId>NextIteration.SpectreConsole.Auth</PackageId>
<Version>2.2.0</Version>
<Version>2.3.0</Version>
<Description>Credential storage, encryption, and Spectre.Console CLI commands for managing provider credentials in CLI tools.</Description>
<GeneratePackageOnBuild Condition="'$(Configuration)' == 'Release'">true</GeneratePackageOnBuild>
<PackageOutputPath>$(MSBuildThisFileDirectory)..\..\artifacts\packages</PackageOutputPath>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ public FileCredentialManager(
EnsureDirectoryExists();
}

/// <summary>The directory this manager stores credentials in. Read by <c>accounts info</c>.</summary>
internal string CredentialsDirectoryPath => _credentialsDirectory;

/// <summary>The selections file inside <see cref="CredentialsDirectoryPath"/>.</summary>
internal string SelectionFilePath => _selectionFile;

/// <summary>The encryption backend actually in use, which may be a custom one.</summary>
internal ICredentialEncryption Encryption => _encryption;

/// <inheritdoc />
public async Task<IEnumerable<CredentialSummary>> ListCredentialsAsync(string providerName, CancellationToken cancellationToken = default)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ public KeychainCredentialManager(
.ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase);
}

/// <summary>The identifier scoping this CLI's Keychain items. Read by <c>accounts info</c>.</summary>
internal string AppIdentifier => _appIdentifier;

/// <inheritdoc />
public Task<string> AddCredentialAsync(string providerName, string accountName, string environment, string credentialData, CancellationToken cancellationToken = default)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ public LibsecretCredentialManager(
.ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase);
}

/// <summary>The identifier scoping this CLI's keyring items. Read by <c>accounts info</c>.</summary>
internal string AppIdentifier => _appIdentifier;

/// <summary>The Secret Service collection new items are written to.</summary>
internal string Collection => _collection;

/// <inheritdoc />
public Task<string> AddCredentialAsync(string providerName, string accountName, string environment, string credentialData, CancellationToken cancellationToken = default)
{
Expand Down
Loading
Loading