Repository navigation
release: 2.3.0 — accounts info #102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
157 changes: 157 additions & 0 deletions
157
src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreDescriber.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]; | ||
| } | ||
| } | ||
| } |
67 changes: 67 additions & 0 deletions
67
src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreInfoCommand.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.