diff --git a/.github/ISSUE_TEMPLATE/auth-problem.md b/.github/ISSUE_TEMPLATE/auth-problem.md index 8973dc52b..316cd8a65 100644 --- a/.github/ISSUE_TEMPLATE/auth-problem.md +++ b/.github/ISSUE_TEMPLATE/auth-problem.md @@ -8,7 +8,7 @@ assignees: '' **Which version of GCM Core are you using?** -From a terminal, run `git-credential-manager-core version` and paste the output. +From a terminal, run `git-credential-manager-core --version` and paste the output. + +### Linux + +#### Debian package (.deb) + +`apt-get` support is available for Ubuntu Bionic Beaver (18.04) and Hirsute +Hippo (21.04). Take the following steps to set up and install based on the +version you are running: + +#### Ubuntu 18.04 (Bionic) + +```shell +curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - +sudo apt-add-repository https://packages.microsoft.com/ubuntu/18.04/prod +sudo apt-get update +sudo apt-get install gcmcore +``` + +#### Ubuntu 21.04 (Hirsute) + +```shell +curl -sSL https://packages.microsoft.com/config/ubuntu/21.04/prod.list | sudo tee /etc/apt/sources.list.d/microsoft-prod.list +curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc +sudo apt-get update +sudo apt-get install gcmcore +``` + +#### Other Ubuntu/Debian distributions Download the latest [.deb package](https://github.com/microsoft/Git-Credential-Manager-Core/releases/latest), and run the following: @@ -97,9 +125,7 @@ git-credential-manager-core configure Note that Linux distributions [require additional configuration](https://aka.ms/gcmcore-linuxcredstores) to use GCM Core. ---- - -### Linux tarball (.tar.gz) +#### Other distributions Download the latest [tarball](https://github.com/microsoft/Git-Credential-Manager-Core/releases/latest), and run the following: @@ -144,8 +170,11 @@ To uninstall, open Control Panel and navigate to the Programs and Features scree ## How to use -Git Credential Manager Core is called implicitly by Git, when so configured. It is not intended to be called directly by the user. -For example, when pushing (`git push`) to [Azure DevOps](https://dev.azure.com), a window is automatically opened and an OAuth2 flow is started to get your personal access token. +Once it's installed and configured, Git Credential Manager Core is called implicitly by Git. +You don't have to do anything special, and GCM Core isn't intended to be called directly by the user. +For example, when pushing (`git push`) to [Azure DevOps](https://dev.azure.com), [Bitbucket](https://bitbucket.org), or [GitHub](https://github.com), a window will automatically open and walk you through the sign-in process. +(This process will look slightly different for each Git host, and even in some cases, whether you've connected to an on-premises or cloud-hosted Git host.) +Later Git commands in the same repository will re-use existing credentials or tokens that GCM Core has stored for as long as they're valid. Read full command line usage [here](docs/usage.md). @@ -160,6 +189,7 @@ See detailed information [here](https://aka.ms/gcmcore-httpproxy). - [Command-line usage](docs/usage.md) - [Configuration options](docs/configuration.md) - [Environment variables](docs/environment.md) +- [Enterprise configuration](docs/enterprise-config.md) - [Network and HTTP configuration](docs/netconfig.md) - [Architectural overview](docs/architecture.md) - [Host provider specification](docs/hostprovider.md) diff --git a/docs/bitbucket-development.md b/docs/bitbucket-development.md new file mode 100644 index 000000000..e20384260 --- /dev/null +++ b/docs/bitbucket-development.md @@ -0,0 +1,80 @@ +# Bitbucket Authentication, 2FA and OAuth + +By default for authenticating against private Git repositories Bitbucket supports SSH and username/password Basic Auth over HTTPS. +Username/password Basic Auth over HTTPS is also available for REST API access. +Additionally Bitbucket supports App-specific passwords which can be used via Basic Auth as username/app-specific-password. + +To enhance security Bitbucket offers optional Two-Factor Authentication (2FA). When 2FA is enabled username/password Basic Auth access to the REST APIs and to Git repositories is suspended. +At that point users are left with the choice of username/apps-specific-password Basic Auth for REST APIs and Git interactions, OAuth for REST APIs and Git/Hg interactions or SSH for Git/HG interactions and one of the previous choices for REST APIs. +SSH and REST API access are beyond the scope of this document. +Read about [Bitbucket's 2FA implementation](https://confluence.atlassian.com/bitbucket/two-step-verification-777023203.html). + +App-specific passwords are not particularly user friendly as once created Bitbucket hides their value, even from the owner. +They are intended for use within application that talk to Bitbucket where application can remember and use the app-specific-password. +[Additional information](https://confluence.atlassian.com/display/BITBUCKET/App+passwords). + +OAuth is the intended authentication method for user interactions with HTTPS remote URL for Git repositories when 2FA is active. +Essentially once a client application has an OAuth access token it can be used in place of a user's password. +Read more about information [Bitbucket's OAuth implementation](https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html). + +Bitbucket's OAuth implementation follows the standard specifications for OAuth 2.0, which is out of scope for this document. +However it implements a comparatively rare part of OAuth 2.0 Refresh Tokens. +Bitbucket's Access Token's expire after 1 hour if not revoked, as opposed to GitHub's that expire after 1 year. +When GitHub's Access Tokens expire the user must anticipate in the standard OAuth authentication flow to get a new Access Token. Since this occurs, in theory, once per year this is not too onerous. +Since Bitbucket's Access Tokens expire every hour it is too much to expect a user to go through the OAuth authentication flow every hour. +Bitbucket implements refresh Tokens. +Refresh Tokens are issued to the client application at the same time as Access Tokens. +They can only be used to request a new Access Token, and then only if they have not been revoked. +As such the support for Bitbucket and the use of its OAuth in the Git Credentials Manager differs significantly from how VSTS and GitHub are implemented. +This is explained in more detail below. + +## Multiple User Accounts + +Unlike the GitHub implementation within the Git Credential Manager, the Bitbucket implementation stores 'secrets', passwords, app-specific passwords, or OAuth tokens, with usernames in the [Windows Credential Manager](https://msdn.microsoft.com/en-us/library/windows/desktop/aa374792(v=vs.85).aspx) vault. + +Depending on the circumstances this means either saving an explicit username in to the Windows Credential Manager/Vault or including the username in the URL used as the identifying key of entries in the Windows Credential Manager vault, i.e. using a key such as `git:https://mminns@bitbucket.org/` rather than `git:https://bitbucket.org`. +This means that the Bitbucket implementation in the GCM can support multiple accounts, and usernames, for a single user against Bitbucket, e.g. a personal account and a work account. + +## Authentication User Experience + +When the GCM is triggered by Git, the GCM will check the `host` parameter passed to it. +If it contains `bitbucket.org` it will trigger the Bitbucket related processes. + +### Basic Authentication + +If the GCM needs to prompt the user for credentials they will always be shown an initial dialog where they can enter a username and password. If the `username` parameter was passed into the GCM it is used to pre-populate the username field, although it can be overridden. +When username and password credentials are submitted the GCM will use them to attempt to retrieve a token, for Basic Authentication this token is in effect the password the user just entered. +The GCM retrieves this `token` by checking the password can be used to successfully retrieve the User profile via the Bitbucket REST API. + +If the username and password credentials sent as Basic Authentication credentials works, then the password is identified as the token. The credentials, the username and the password/token, are then stored and the values returned to Git. + +If the request for the User profile via the REST API fails with a 401 return code it indicates the username/password combination is invalid, nothing is stored and nothing is returned to Git. + +However if the request fails with a 403 (Forbidden) return code, this indicates that the username and password are valid but 2FA is enabled on the Bitbucket Account. +When this occurs the user it prompted to complete the OAuth authentication process. + +### OAuth + +OAuth authentication prompts the User with a new dialog where they can trigger OAuth authentication. +This involves opening a browser request to `_https://bitbucket.org/site/oauth2/authorize?response_type=code&client_id={consumerkey}&state=authenticated&scope={scopes}&redirect_uri=http://localhost:34106/_`. +This will trigger a flow on Bitbucket where the user must login, potentially including a 2FA prompt, and authorize the GCM to access Bitbucket with the specified scopes. +The GCM will spawn a temporary, local webserver, listening on port 34106, to handle the OAuth redirect/callback. +Assuming the user successfully logins into Bitbucket and authorizes the GCM this callback will include the Access and Refresh Tokens. + +The Access and Refresh Tokens will be stored against the username and the username/Access Token credentials returned to Git. + +# On-Premise Bitbucket + +On-premise Bitbucket, more correctly known as Bitbucket Server or Bitbucket DC, has a number of differences compared to the cloud instance of Bitbucket, https://bitbucket.org. + +As far as GCMC is concerned the main difference it doesn't support OAuth so only Basic Authentication is available. + +It is possible to test with Bitbucket Server by running it locally using the following command from the Atlassian SDK: + + ❯ atlas-run-standalone --product bitbucket + +See https://developer.atlassian.com/server/framework/atlassian-sdk/atlas-run-standalone/. + +This will download and run a standalone instance of Bitbucket Server which can be accessed using the credentials `admin`/`admin` at + + https://localhost:7990/bitbucket diff --git a/docs/configuration.md b/docs/configuration.md index 48a025f40..1ea541542 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,12 +3,13 @@ [Git Credential Manager Core](usage.md) works out of the box for most users. Git Credential Manager Core (GCM Core) can be configured using Git's configuration files, and follows all of the same rules Git does when consuming the files. + Global configuration settings override system configuration settings, and local configuration settings override global settings; and because the configuration details exist within Git's configuration files you can use Git's `git config` utility to set, unset, and alter the setting values. All of GCM Core's configuration settings begin with the term `credential`. GCM Core honors several levels of settings, in addition to the standard local \> global \> system tiering Git uses. URL-specific settings or overrides can be applied to any value in the `credential` namespace with the syntax below. -Additionally, GCM Core respects several GCM-specific [environment variables](environment.md) **which take precedence over configuration options.** +Additionally, GCM Core respects several GCM-specific [environment variables](environment.md) **which take precedence over configuration options**. System administrators may also configure [default values](enterprise-config.md) for many settings used by GCM Core. GCM Core will only be used by Git if it is installed and configured. Use `git config --global credential.helper manager-core` to assign GCM Core as your credential helper. Use `git config credential.helper` to see the current configuration. diff --git a/docs/enterprise-config.md b/docs/enterprise-config.md new file mode 100644 index 000000000..d6827d1d4 --- /dev/null +++ b/docs/enterprise-config.md @@ -0,0 +1,61 @@ +# Enterprise configuration defaults + +Git Credential Manager Core (GCM Core) can be configured using multiple +different mechanisms. In order of preference, those mechanisms are: + +1. [Environment variables](environment.md) +2. [Standard Git configuration files](configuration.md) + 1. Repository/local configuration (`.git/config`) + 2. User/global configuration (`$HOME/.gitconfig` or `%HOME%\.gitconfig`) + 3. Installation/system configuration (`etc/gitconfig`) +3. Enterprise system administrator defaults +4. Compiled default values + +This model largely matches what Git itself supports, namely environment +variables that take precedence over Git configuration files. + +The addition of the enterprise system administrator defaults enables those +administrators to configure many GCM settings using familiar MDM tooling, rather +than having to modify the Git installation configuration files. + +### User Freedom + +We believe the user should _always_ be at liberty to configure +Git and GCM exactly as they wish. By prefering environment variables and Git +configuration files over system admin values, these only act as _default values_ +that can always be overriden by the user in the usual ways. + +## Windows + +Default setting values come from the Windows Registry, specifically the +following keys: + +**32-bit Windows** + +```text +HKEY_LOCAL_MACHINE\SOFTWARE\GitCredentialManager\Configuration +``` + +**64-bit Windows** + +```text +HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GitCredentialManager\Configuration +``` + +> GCM Core is a 32-bit executable on Windows. When running on a 64-bit +installation of Windows registry access is transparently redirected to the +`WOW6432Node` node. + +By using the Windows Registry, system administrators can use Group Policy to +easily set defaults for GCM Core's settings. + +The names and possible values of all settings under this key are the same as +those of the [Git configuration](configuration.md) settings. + +The type of each registry key can be either `REG_SZ` (string) or `REG_DWORD` +(integer). + + +## macOS/Linux + +Default configuration setting stores has not been implemented. diff --git a/docs/environment.md b/docs/environment.md index 906390df5..f33176627 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -2,7 +2,7 @@ [Git Credential Manager Core](usage.md) works out of the box for most users. Configuration options are available to customize or tweak behavior. -Git Credential Manager Core (GCM Core) can be configured using environment variables. **Environment variables take precedence over [configuration](configuration.md) options.** +Git Credential Manager Core (GCM Core) can be configured using environment variables. **Environment variables take precedence over [configuration](configuration.md) options and enterprise system administrator [default values](enterprise-config.md)**. For the complete list of environment variables GCM Core understands, see the list below. @@ -398,6 +398,22 @@ export GCM_PLAINTEXT_STORE_PATH=/mnt/external-drive/credentials --- +### GCM_GPG_PATH + +Specify the path (_including_ the executable name) to the version of `gpg` used by `pass` (`gpg2` if present, otherwise `gpg`). This is primarily meant to allow manual resolution of the conflict that occurs on legacy Linux systems with parallel installs of `gpg` and `gpg2`. + +If not specified, GCM Core defaults to using the version of `gpg2` on the `$PATH`, falling back on `gpg` if `gpg2` is not found. + +##### Linux + +```bash +export GCM_GPG_PATH="/usr/local/bin/gpg2" +``` + +_No configuration equivalent._ + +--- + ### GCM_MSAUTH_FLOW Specify which authentication flow should be used when performing Microsoft authentication and an interactive flow is required. diff --git a/docs/linuxcredstores.md b/docs/linuxcredstores.md index 45a42894d..a959fe287 100644 --- a/docs/linuxcredstores.md +++ b/docs/linuxcredstores.md @@ -8,7 +8,7 @@ Manager Core (GCM Core) manages on Linux platforms: 3. Git's built-in [credential cache](https://git-scm.com/docs/git-credential-cache) 4. Plaintext files -By default, GCM Core comes unconfigured. You can select which credential store +By default, GCM Core comes unconfigured. After running `git-credential-manager-core configure`, you can select which credential store to use by setting the [`GCM_CREDENTIAL_STORE`](environment.md#GCM_CREDENTIAL_STORE) environment variable, or the [`credential.credentialStore`](configuration.md#credentialcredentialstore) Git configuration setting. @@ -134,6 +134,10 @@ the environment variable `GCM_PLAINTEXT_STORE_PATH` environment variable. If the directory does not exist is will be created. +On POSIX platforms the newly created store directory will have permissions set +such that only the owner can `r`ead/`w`rite/e`x`ecute (`700` or `drwx---`). +Permissions on existing directories will not be modified. + ---

diff --git a/docs/windows-broker.md b/docs/windows-broker.md index e5adf0c7b..a4a1a9ed6 100644 --- a/docs/windows-broker.md +++ b/docs/windows-broker.md @@ -8,6 +8,10 @@ Integration with the WAM broker comes with several additional benefits, but it a Note that this only affects [Azure DevOps](https://dev.azure.com). It doesn't impact authentication with GitHub, Bitbucket, or any other Git host. +## How to enable + +You can opt-in to WAM support by setting the environment variable [`GCM_MSAUTH_USEBROKER`](https://github.com/microsoft/Git-Credential-Manager-Core/blob/main/docs/environment.md#gcm_msauth_usebroker-experimental) or setting the Git configuration value [`credential.msauthUseBroker`](https://github.com/microsoft/Git-Credential-Manager-Core/blob/main/docs/configuration.md#credentialmsauthusebroker-experimental). + ## Features When you turn on WAM support, GCM Core can cooperate with Windows and with other WAM-enabled software on your machine. diff --git a/src/osx/Installer.Mac/layout.sh b/src/osx/Installer.Mac/layout.sh index 9d4493e66..898450963 100755 --- a/src/osx/Installer.Mac/layout.sh +++ b/src/osx/Installer.Mac/layout.sh @@ -74,6 +74,8 @@ cp "$INSTALLER_SRC/uninstall.sh" "$PAYLOAD" || exit 1 # Publish core application executables echo "Publishing core application..." dotnet publish "$GCM_SRC" \ + --no-restore \ + -m:1 \ --configuration="$CONFIGURATION" \ --framework="$FRAMEWORK" \ --runtime="$RUNTIME" \ @@ -81,6 +83,8 @@ dotnet publish "$GCM_SRC" \ echo "Publishing Bitbucket UI helper..." dotnet publish "$BITBUCKET_UI_SRC" \ + --no-restore \ + -m:1 \ --configuration="$CONFIGURATION" \ --framework="$FRAMEWORK" \ --runtime="$RUNTIME" \ @@ -88,6 +92,8 @@ dotnet publish "$BITBUCKET_UI_SRC" \ echo "Publishing GitHub UI helper..." dotnet publish "$GITHUB_UI_SRC" \ + --no-restore \ + -m:1 \ --configuration="$CONFIGURATION" \ --framework="$FRAMEWORK" \ --runtime="$RUNTIME" \ diff --git a/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs b/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs new file mode 100644 index 000000000..57537e35d --- /dev/null +++ b/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs @@ -0,0 +1,60 @@ +using Microsoft.Git.CredentialManager; +using Microsoft.Git.CredentialManager.Tests.Objects; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Atlassian.Bitbucket.Tests +{ + public class BitbucketHostProviderTest + { + [Theory] + // We report that we support unencrypted HTTP here so that we can fail and + // show a helpful error message in the call to `GenerateCredentialAsync` instead. + [InlineData("http", "bitbucket.org", true)] + [InlineData("ssh", "bitbucket.org", false)] + [InlineData("https", "bitbucket.org", true)] + [InlineData("https", "api.bitbucket.org", true)] // Currently does support sub domains. + + [InlineData("https", "bitbucket.ogg", false)] // No support of phony similar tld. + [InlineData("https", "bitbucket.com", false)] // No support of wrong tld. + [InlineData("https", "example.com", false)] // No support of non bitbucket domains. + + [InlineData("http", "bitbucket.my-company-server.com", false)] // Currently no support for named on-premise instances + [InlineData("https", "my-company-server.com", false)] + [InlineData("https", "bitbucket.my.company.server.com", false)] + [InlineData("https", "api.bitbucket.my-company-server.com", false)] + [InlineData("https", "BITBUCKET.My-Company-Server.Com", false)] + public void BitbucketHostProvider_IsSupported(string protocol, string host, bool expected) + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = protocol, + ["host"] = host, + }); + + var provider = new BitbucketHostProvider(new TestCommandContext()); + Assert.Equal(expected, provider.IsSupported(input)); + } + + [Theory] + [InlineData("X-AREQUESTID", "123456789", true)] // only the specific header is acceptable + [InlineData("X-REQUESTID", "123456789", false)] + [InlineData(null, null, false)] + public void BitbucketHostProvider_IsSupported_HttpResponseMessage(string header, string value, bool expected) + { + var input = new HttpResponseMessage(); + if (header != null) + { + input.Headers.Add(header, value); + } + + var provider = new BitbucketHostProvider(new TestCommandContext()); + Assert.Equal(expected, provider.IsSupported(input)); + } + } +} diff --git a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs index c6da9002d..8e0b1f102 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs @@ -66,8 +66,12 @@ public bool IsSupported(HttpResponseMessage response) return false; } - // TODO: identify Bitbucket on-prem instances from the HTTP response - return false; + // Identify Bitbucket on-prem instances from the HTTP response using the Atlassian specific header X-AREQUESTID + var supported = response.Headers.Contains("X-AREQUESTID"); + + _context.Trace.WriteLine($"Host is{(supported ? null : "n't")} supported as Bitbucket"); + + return supported; } public async Task GetCredentialAsync(InputArguments input) diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/MacOS/MacOSKeychainTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/MacOS/MacOSKeychainTests.cs index f65060913..ab5940849 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/MacOS/MacOSKeychainTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/MacOS/MacOSKeychainTests.cs @@ -2,7 +2,9 @@ // Licensed under the MIT license. using System; using Xunit; +using Microsoft.Git.CredentialManager.Interop; using Microsoft.Git.CredentialManager.Interop.MacOS; +using Microsoft.Git.CredentialManager.Interop.MacOS.Native; namespace Microsoft.Git.CredentialManager.Tests.Interop.MacOS { @@ -10,7 +12,7 @@ public class MacOSKeychainTests { private const string TestNamespace = "git-test"; - [PlatformFact(Platforms.MacOS)] + [SkippablePlatformFact(Platforms.MacOS)] public void MacOSKeychain_ReadWriteDelete() { var keychain = new MacOSKeychain(TestNamespace); @@ -32,6 +34,19 @@ public void MacOSKeychain_ReadWriteDelete() Assert.Equal(account, outCredential.Account); Assert.Equal(password, outCredential.Password); } + // There is an unknown issue that the keychain can sometimes get itself in where all API calls + // result in an errSecAuthFailed error. The only solution seems to be a machine restart, which + // isn't really possible in CI! + // The problem has plagued others who are calling the same Keychain APIs from C# such as the + // MSAL.NET team - they don't know either. It might have something to do with the code signing + // signature of the binary (our collective best theory). + // It's probably only diagnosable at this point by Apple, but we don't have a reliable way to + // reproduce the problem. + // For now we will just mark the test as "skipped" when we hit this problem. + catch (InteropException iex) when (iex.ErrorCode == SecurityFramework.ErrorSecAuthFailed) + { + AssertEx.Skip("macOS Keychain is in an invalid state (errSecAuthFailed)"); + } finally { // Ensure we clean up after ourselves even in case of 'get' failures diff --git a/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs b/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs index 7ec5900f6..e5f1a8003 100644 --- a/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs +++ b/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs @@ -95,13 +95,13 @@ public CommandContext(string appPath) SystemPrompts = new WindowsSystemPrompts(); Environment = new WindowsEnvironment(FileSystem); Terminal = new WindowsTerminal(Trace); - string gitPath = GetGitPath(Environment, FileSystem); + string gitPath = GetGitPath(Environment, FileSystem, Trace); Git = new GitProcess( Trace, gitPath, FileSystem.GetCurrentDirectory() ); - Settings = new Settings(Environment, Git); + Settings = new WindowsSettings(Environment, Git, Trace); CredentialStore = new WindowsCredentialManager(Settings.CredentialNamespace); } else if (PlatformUtils.IsMacOS()) @@ -111,7 +111,7 @@ public CommandContext(string appPath) SystemPrompts = new MacOSSystemPrompts(); Environment = new PosixEnvironment(FileSystem); Terminal = new PosixTerminal(Trace); - string gitPath = GetGitPath(Environment, FileSystem); + string gitPath = GetGitPath(Environment, FileSystem, Trace); Git = new GitProcess( Trace, gitPath, @@ -119,6 +119,7 @@ public CommandContext(string appPath) ); Settings = new Settings(Environment, Git); CredentialStore = new MacOSKeychain(Settings.CredentialNamespace); + } else if (PlatformUtils.IsLinux()) { @@ -128,17 +129,16 @@ public CommandContext(string appPath) SystemPrompts = new LinuxSystemPrompts(); Environment = new PosixEnvironment(FileSystem); Terminal = new PosixTerminal(Trace); - string gitPath = GetGitPath(Environment, FileSystem); + string gitPath = GetGitPath(Environment, FileSystem, Trace); Git = new GitProcess( Trace, gitPath, FileSystem.GetCurrentDirectory() ); Settings = new Settings(Environment, Git); - IGpg gpg = new Gpg( - Environment.LocateExecutable("gpg"), - SessionManager - ); + + string gpgPath = GetGpgPath(Environment, FileSystem, Trace); + IGpg gpg = new Gpg(gpgPath, SessionManager); CredentialStore = new LinuxCredentialStore(FileSystem, Settings, SessionManager, gpg, Environment, Git); } else @@ -152,23 +152,62 @@ public CommandContext(string appPath) SystemPrompts.ParentWindowId = Settings.ParentWindowId; } - private static string GetGitPath(IEnvironment environment, IFileSystem fileSystem) + private static string GetGitPath(IEnvironment environment, IFileSystem fileSystem, ITrace trace) { + string gitExecPath; string programName = PlatformUtils.IsWindows() ? "git.exe" : "git"; // Use the GIT_EXEC_PATH environment variable if set if (environment.Variables.TryGetValue(Constants.EnvironmentVariables.GitExecutablePath, - out string gitExecPath)) + out gitExecPath)) { string candidatePath = Path.Combine(gitExecPath, programName); if (fileSystem.FileExists(candidatePath)) { + trace.WriteLine($"Using Git executable from GIT_EXEC_PATH: {candidatePath}"); return candidatePath; } } // Otherwise try to locate the git(.exe) on the current PATH - return environment.LocateExecutable(programName); + gitExecPath = environment.LocateExecutable(programName); + trace.WriteLine($"Using PATH-located Git executable: {gitExecPath}"); + return gitExecPath; + } + + private static string GetGpgPath(IEnvironment environment, IFileSystem fileSystem, ITrace trace) + { + string gpgPath; + + // Use the GCM_GPG_PATH environment variable if set + if (environment.Variables.TryGetValue(Constants.EnvironmentVariables.GpgExecutablePath, + out gpgPath)) + { + if (fileSystem.FileExists(gpgPath)) + { + trace.WriteLine($"Using Git executable from GCM_GPG_PATH: {gpgPath}"); + return gpgPath; + } + else + { + throw new Exception($"GPG executable does not exist with path '{gpgPath}'"); + } + + } + + // If no explicit GPG path is specified, mimic the way `pass` + // determines GPG dependency (use gpg2 if available, otherwise gpg) + if (environment.TryLocateExecutable("gpg2", out string gpg2Path)) + { + trace.WriteLine($"Using PATH-located GPG (gpg2) executable: {gpg2Path}"); + return gpg2Path; + } + else + { + gpgPath = environment.LocateExecutable("gpg"); + trace.WriteLine($"Using PATH-located GPG (gpg) executable: {gpgPath}"); + return gpgPath; + } } #region ICommandContext diff --git a/src/shared/Microsoft.Git.CredentialManager/Constants.cs b/src/shared/Microsoft.Git.CredentialManager/Constants.cs index 5751f5472..38b61994f 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Constants.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Constants.cs @@ -58,6 +58,7 @@ public static class EnvironmentVariables public const string GcmCredCacheOptions = "GCM_CREDENTIAL_CACHE_OPTIONS"; public const string GcmPlaintextStorePath = "GCM_PLAINTEXT_STORE_PATH"; public const string GitExecutablePath = "GIT_EXEC_PATH"; + public const string GpgExecutablePath = "GCM_GPG_PATH"; } public static class Http @@ -107,6 +108,12 @@ public static class Remote } } + public static class WindowsRegistry + { + public const string HKAppBasePath = @"SOFTWARE\GitCredentialManager"; + public const string HKConfigurationPath = HKAppBasePath + @"\Configuration"; + } + public static class HelpUrls { public const string GcmProjectUrl = "https://aka.ms/gcmcore"; diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/Native/Stat.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/Native/Stat.cs new file mode 100644 index 000000000..595b115a7 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/Native/Stat.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.Git.CredentialManager.Interop.Posix.Native +{ + public static class Stat + { + [DllImport("libc", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern int chmod(string path, NativeFileMode mode); + } + [Flags] + public enum NativeFileMode + { + NONE = 0, + + // Default permissions (RW for owner, RW for group, RW for other) + DEFAULT = S_IWOTH | S_IROTH | S_IWGRP | S_IRGRP | S_IWUSR | S_IRUSR, + + // All file access permissions (RWX for owner, group, and other) + ACCESSPERMS = S_IRWXO | S_IRWXU | S_IRWXG, + + // Read for owner (0000400) + S_IRUSR = 0x100, + // Write for owner (0000200) + S_IWUSR = 0x080, + // Execute for owner (0000100) + S_IXUSR = 0x040, + // Access permissions for owner + S_IRWXU = S_IRUSR | S_IWUSR | S_IXUSR, + + // Read for group (0000040) + S_IRGRP = 0x020, + // Write for group (0000020) + S_IWGRP = 0x010, + // Execute for group (0000010) + S_IXGRP = 0x008, + // Access permissions for group + S_IRWXG = S_IRGRP | S_IWGRP | S_IXGRP, + + // Read for other (0000004) + S_IROTH = 0x004, + // Write for other (0000002) + S_IWOTH = 0x002, + // Execute for other (0000001) + S_IXOTH = 0x001, + // Access permissions for other + S_IRWXO = S_IROTH | S_IWOTH | S_IXOTH, + + // Set user ID on execution (0004000) + S_ISUID = 0x800, + // Set group ID on execution (0002000) + S_ISGID = 0x400, + // Sticky bit (0001000) + S_ISVTX = 0x200, + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsSettings.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsSettings.cs new file mode 100644 index 000000000..663f09307 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsSettings.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.Git.CredentialManager.Interop.Windows +{ + ///

+ /// Reads settings from Git configuration, environment variables, and defaults from the Windows Registry. + /// + public class WindowsSettings : Settings + { + private readonly ITrace _trace; + + public WindowsSettings(IEnvironment environment, IGit git, ITrace trace) + : base(environment, git) + { + EnsureArgument.NotNull(trace, nameof(trace)); + _trace = trace; + + PlatformUtils.EnsureWindows(); + } + + protected override bool TryGetExternalDefault(string section, string property, out string value) + { + value = null; + +#if NETFRAMEWORK + // Check for machine (HKLM) registry keys that match the Git configuration name. + // These can be set by system administrators via Group Policy, so make useful defaults. + using (Win32.RegistryKey configKey = Win32.Registry.LocalMachine.OpenSubKey(Constants.WindowsRegistry.HKConfigurationPath)) + { + if (configKey is null) + { + // No configuration key exists + return false; + } + + string name = $"{section}.{property}"; + object registryValue = configKey.GetValue(name); + if (registryValue is null) + { + // No property exists + return false; + } + + value = registryValue.ToString(); + _trace.WriteLine($"Default setting found in registry: {name}={value}"); + + return true; + } +#else + return base.TryGetExternalDefault(section, property, out value); +#endif + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/PlaintextCredentialStore.cs b/src/shared/Microsoft.Git.CredentialManager/PlaintextCredentialStore.cs index 954f2df7e..cc280d52c 100644 --- a/src/shared/Microsoft.Git.CredentialManager/PlaintextCredentialStore.cs +++ b/src/shared/Microsoft.Git.CredentialManager/PlaintextCredentialStore.cs @@ -59,6 +59,9 @@ public ICredential Get(string service, string account) public void AddOrUpdate(string service, string account, string secret) { + // Ensure the store root exists and permissions are set + EnsureStoreRoot(); + string serviceSlug = CreateServiceSlug(service); string servicePath = Path.Combine(StoreRoot, serviceSlug); @@ -161,6 +164,36 @@ protected virtual void SerializeCredential(FileCredential credential) } } + /// + /// Ensure the store root directory exists. If it does not, create a new directory with + /// permissions that only permit the owner to read/write/execute. Permissions on an existing + /// directory are not modified. + /// + private void EnsureStoreRoot() + { + if (FileSystem.DirectoryExists(StoreRoot)) + { + // Don't touch the permissions on the existing directory + return; + } + + FileSystem.CreateDirectory(StoreRoot); + + // We only set file system permissions on POSIX platforms + if (!PlatformUtils.IsPosix()) + { + return; + } + + // Set store root permissions such that only the owner can read/write/execute + var mode = Interop.Posix.Native.NativeFileMode.S_IRUSR | + Interop.Posix.Native.NativeFileMode.S_IWUSR | + Interop.Posix.Native.NativeFileMode.S_IXUSR; + + // Ignore the return code.. this is a best effort only + Interop.Posix.Native.Stat.chmod(StoreRoot, mode); + } + private string CreateServiceSlug(string service) { var sb = new StringBuilder(); diff --git a/src/shared/Microsoft.Git.CredentialManager/Settings.cs b/src/shared/Microsoft.Git.CredentialManager/Settings.cs index d3f7d5499..7d6ba6fb4 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Settings.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Settings.cs @@ -289,9 +289,29 @@ public IEnumerable GetSettingValues(string envarName, string section, st { yield return value; } + + // Check for an externally specified default value + if (TryGetExternalDefault(section, property, out string defaultValue)) + { + yield return defaultValue; + } } } + /// + /// Try to get the default value for a configuration setting. + /// This may come from external policies or the Operating System. + /// + /// Configuration section name. + /// Configuration property name. + /// Value of the configuration setting, or null. + /// True if a default setting has been set, false otherwise. + protected virtual bool TryGetExternalDefault(string section, string property, out string value) + { + value = null; + return false; + } + public Uri RemoteUri { get; set; } public bool IsDebuggingEnabled => _environment.Variables.GetBooleanyOrDefault(KnownEnvars.GcmDebug, false); diff --git a/src/shared/TestInfrastructure/AssertEx.cs b/src/shared/TestInfrastructure/AssertEx.cs new file mode 100644 index 000000000..563484148 --- /dev/null +++ b/src/shared/TestInfrastructure/AssertEx.cs @@ -0,0 +1,17 @@ +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests +{ + public static class AssertEx + { + /// + /// Requires the fact or theory be marked with the + /// or . + /// + /// Reason the test has been skipped. + public static void Skip(string reason) + { + Xunit.Skip.If(true, reason); + } + } +} diff --git a/src/shared/TestInfrastructure/PlatformAttributes.cs b/src/shared/TestInfrastructure/PlatformAttributes.cs index b95cd0f8e..99ec3b645 100644 --- a/src/shared/TestInfrastructure/PlatformAttributes.cs +++ b/src/shared/TestInfrastructure/PlatformAttributes.cs @@ -28,6 +28,28 @@ public PlatformTheoryAttribute(Platforms platforms) } } + public class SkippablePlatformFactAttribute : SkippableFactAttribute + { + public SkippablePlatformFactAttribute(Platforms platforms) + { + Xunit.Skip.IfNot( + XunitHelpers.IsSupportedPlatform(platforms), + "Test not supported on this platform." + ); + } + } + + public class SkippablePlatformTheoryAttribute : SkippableTheoryAttribute + { + public SkippablePlatformTheoryAttribute(Platforms platforms) + { + Xunit.Skip.IfNot( + XunitHelpers.IsSupportedPlatform(platforms), + "Test not supported on this platform." + ); + } + } + internal static class XunitHelpers { public static bool IsSupportedPlatform(Platforms platforms) diff --git a/src/shared/TestInfrastructure/TestInfrastructure.csproj b/src/shared/TestInfrastructure/TestInfrastructure.csproj index d4376b8e8..fcf2af69e 100644 --- a/src/shared/TestInfrastructure/TestInfrastructure.csproj +++ b/src/shared/TestInfrastructure/TestInfrastructure.csproj @@ -11,6 +11,7 @@ +