diff --git a/src/Microsoft.Git.CredentialManager/CommandContext.cs b/src/Microsoft.Git.CredentialManager/CommandContext.cs index eac1d5152..d1f3df88d 100644 --- a/src/Microsoft.Git.CredentialManager/CommandContext.cs +++ b/src/Microsoft.Git.CredentialManager/CommandContext.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text; +using Microsoft.Git.CredentialManager.SecureStorage; namespace Microsoft.Git.CredentialManager { @@ -37,6 +38,11 @@ public interface ICommandContext /// IFileSystem FileSystem { get; } + /// + /// Secure credential storage. + /// + ICredentialStore CredentialStore { get; } + /// /// Access the environment variables for the current GCM process. /// @@ -110,6 +116,8 @@ public TextWriter StdError public IFileSystem FileSystem { get; } = new FileSystem(); + public ICredentialStore CredentialStore { get; } = CreateCredentialStore(); + public IReadOnlyDictionary GetEnvironmentVariables() { IDictionary variables = Environment.GetEnvironmentVariables(); @@ -140,6 +148,26 @@ public IReadOnlyDictionary GetEnvironmentVariables() } #endregion + + private static ICredentialStore CreateCredentialStore() + { + if (PlatformUtils.IsMacOS()) + { + return MacOSKeychain.Open(); + } + + if (PlatformUtils.IsWindows()) + { + return WindowsCredentialManager.Open(); + } + + if (PlatformUtils.IsLinux()) + { + throw new NotImplementedException(); + } + + throw new PlatformNotSupportedException(); + } } public static class CommandContextExtensions diff --git a/src/Microsoft.Git.CredentialManager/GitCredential.cs b/src/Microsoft.Git.CredentialManager/GitCredential.cs index 821258cad..4cf6db9bd 100644 --- a/src/Microsoft.Git.CredentialManager/GitCredential.cs +++ b/src/Microsoft.Git.CredentialManager/GitCredential.cs @@ -7,7 +7,7 @@ namespace Microsoft.Git.CredentialManager /// /// Represents a credential (username/password pair) that Git can use to authenticate to a remote repository. /// - public class GitCredential + public class GitCredential : SecureStorage.ICredential { public GitCredential(string userName, string password) { diff --git a/src/Microsoft.Git.CredentialManager/SecureStorage/Credential.cs b/src/Microsoft.Git.CredentialManager/SecureStorage/Credential.cs new file mode 100644 index 000000000..11def9b18 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/SecureStorage/Credential.cs @@ -0,0 +1,23 @@ +namespace Microsoft.Git.CredentialManager.SecureStorage +{ + /// + /// Represents a simple credential; user name and password pair. + /// + public interface ICredential + { + string UserName { get; } + string Password { get; } + } + + internal class Credential : ICredential + { + public Credential(string userName, string password) + { + UserName = userName; + Password = password; + } + + public string UserName { get; } + public string Password { get; } + } +} diff --git a/src/Microsoft.Git.CredentialManager/SecureStorage/ICredentialStore.cs b/src/Microsoft.Git.CredentialManager/SecureStorage/ICredentialStore.cs new file mode 100644 index 000000000..3ea71f5ed --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/SecureStorage/ICredentialStore.cs @@ -0,0 +1,29 @@ +namespace Microsoft.Git.CredentialManager.SecureStorage +{ + /// + /// Represents a secure storage location for s. + /// + public interface ICredentialStore + { + /// + /// Get credential from the store with the specified key. + /// + /// Key for credential to retrieve. + /// Stored credential or null if not found. + ICredential Get(string key); + + /// + /// Add or update credential in the store with the specified key. + /// + /// Key for credential to add/update. + /// Credential to store. + void AddOrUpdate(string key, ICredential credential); + + /// + /// Delete credential from the store with the specified key. + /// + /// Key of credential to delete. + /// True if the credential was deleted, false otherwise. + bool Remove(string key); + } +} diff --git a/src/Microsoft.Git.CredentialManager/SecureStorage/MacOSKeychain.cs b/src/Microsoft.Git.CredentialManager/SecureStorage/MacOSKeychain.cs new file mode 100644 index 000000000..a9c0b88b2 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/SecureStorage/MacOSKeychain.cs @@ -0,0 +1,231 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using static Microsoft.Git.CredentialManager.SecureStorage.NativeMethods.MacOS; + +namespace Microsoft.Git.CredentialManager.SecureStorage +{ + public class MacOSKeychain : ICredentialStore + { + #region Constructors + + /// + /// Open the default keychain (current user's login keychain). + /// + /// Default keychain. + public static MacOSKeychain Open() + { + return new MacOSKeychain(); + } + + private MacOSKeychain() + { + PlatformUtils.EnsureMacOS(); + } + + #endregion + + #region ICredentialStore + + public ICredential Get(string key) + { + IntPtr passwordData = IntPtr.Zero; + IntPtr itemRef = IntPtr.Zero; + + try + { + // Find the item (itemRef) and password (passwordData) in the keychain + int findResult = SecKeychainFindGenericPassword( + IntPtr.Zero, (uint) key.Length, key, 0, null, + out uint passwordLength, out passwordData, out itemRef); + + switch (findResult) + { + case OK: + // Get and decode the user name from the 'account name' attribute + byte[] userNameBytes = GetAccountNameAttributeData(itemRef); + string userName = Encoding.UTF8.GetString(userNameBytes); + + // Decode the password from the raw data + byte[] passwordBytes = NativeMethods.ToByteArray(passwordData, passwordLength); + string password = Encoding.UTF8.GetString(passwordBytes); + + return new Credential(userName, password); + + case ErrorSecItemNotFound: + return null; + + default: + ThrowIfError(findResult); + return null; + } + } + finally + { + if (passwordData != IntPtr.Zero) + { + SecKeychainItemFreeContent(IntPtr.Zero, passwordData); + } + + if (itemRef != IntPtr.Zero) + { + CFRelease(itemRef); + } + } + } + + public void AddOrUpdate(string key, ICredential credential) + { + byte[] passwordBytes = Encoding.UTF8.GetBytes(credential.Password); + + IntPtr passwordData = IntPtr.Zero; + IntPtr itemRef = IntPtr.Zero; + + try + { + // Check if an entry already exists in the keychain + int findResult = SecKeychainFindGenericPassword( + IntPtr.Zero, (uint) key.Length, key, (uint) credential.UserName.Length, credential.UserName, + out uint _, out passwordData, out itemRef); + + switch (findResult) + { + // Create new entry + case OK: + ThrowIfError( + SecKeychainItemModifyAttributesAndData(itemRef, IntPtr.Zero, (uint) passwordBytes.Length, passwordBytes), + "Could not update existing item" + ); + break; + + // Update existing entry + case ErrorSecItemNotFound: + ThrowIfError( + SecKeychainAddGenericPassword(IntPtr.Zero, (uint) key.Length, key, (uint) credential.UserName.Length, + credential.UserName, (uint) passwordBytes.Length, passwordBytes, out itemRef), + "Could not create new item" + ); + break; + + default: + ThrowIfError(findResult); + break; + } + } + finally + { + if (passwordData != IntPtr.Zero) + { + SecKeychainItemFreeContent(IntPtr.Zero, passwordData); + } + + if (itemRef != IntPtr.Zero) + { + CFRelease(itemRef); + } + } + } + + public bool Remove(string key) + { + IntPtr passwordData = IntPtr.Zero; + IntPtr itemRef = IntPtr.Zero; + + try + { + int findResult = SecKeychainFindGenericPassword( + IntPtr.Zero, (uint) key.Length, key, 0, null, + out _, out passwordData, out itemRef); + + switch (findResult) + { + case OK: + ThrowIfError( + SecKeychainItemDelete(itemRef) + ); + return true; + + case ErrorSecItemNotFound: + return false; + + default: + ThrowIfError(findResult); + return false; + } + } + finally + { + if (passwordData != IntPtr.Zero) + { + SecKeychainItemFreeContent(IntPtr.Zero, passwordData); + } + + if (itemRef != IntPtr.Zero) + { + CFRelease(itemRef); + } + } + } + + #endregion + + #region Private Methods + + private static byte[] GetAccountNameAttributeData(IntPtr itemRef) + { + IntPtr tagArrayPtr = IntPtr.Zero; + IntPtr formatArrayPtr = IntPtr.Zero; + IntPtr attrListPtr = IntPtr.Zero; // SecKeychainAttributeList + + try + { + // Extract the user name by querying for the item's 'account' attribute + tagArrayPtr = Marshal.AllocHGlobal(sizeof(SecKeychainAttrType)); + Marshal.WriteInt32(tagArrayPtr, (int) SecKeychainAttrType.AccountItem); + + formatArrayPtr = Marshal.AllocHGlobal(sizeof(CssmDbAttributeFormat)); + Marshal.WriteInt32(formatArrayPtr, (int) CssmDbAttributeFormat.String); + + var attributeInfo = new SecKeychainAttributeInfo + { + Count = 1, + Tag = tagArrayPtr, + Format = formatArrayPtr, + }; + + ThrowIfError( + SecKeychainItemCopyAttributesAndData( + itemRef, ref attributeInfo, + IntPtr.Zero, out attrListPtr, out _, IntPtr.Zero) + ); + + SecKeychainAttributeList attrList = Marshal.PtrToStructure(attrListPtr); + Debug.Assert(attrList.Count == 1, "Only expecting a list structure containing one attribute to be returned"); + + SecKeychainAttribute attribute = Marshal.PtrToStructure(attrList.Attributes); + + return NativeMethods.ToByteArray(attribute.Data, attribute.Length); + } + finally + { + if (tagArrayPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(tagArrayPtr); + } + + if (formatArrayPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(formatArrayPtr); + } + + if (attrListPtr != IntPtr.Zero) + { + SecKeychainItemFreeAttributesAndData(attrListPtr, IntPtr.Zero); + } + } + } + + #endregion + } +} diff --git a/src/Microsoft.Git.CredentialManager/SecureStorage/NativeMethods.Mac.cs b/src/Microsoft.Git.CredentialManager/SecureStorage/NativeMethods.Mac.cs new file mode 100644 index 000000000..878298288 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/SecureStorage/NativeMethods.Mac.cs @@ -0,0 +1,151 @@ +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.Git.CredentialManager.SecureStorage +{ + internal static partial class NativeMethods + { + // https://developer.apple.com/documentation/security/keychain_services/keychain_items + public static class MacOS + { + private const string CoreFoundationFrameworkLib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + private const string SecurityFrameworkLib = "/System/Library/Frameworks/Security.framework/Security"; + + // https://developer.apple.com/documentation/security/1542001-security_framework_result_codes + public const int OK = 0; + public const int ErrorSecNoSuchKeychain = -25294; + public const int ErrorSecInvalidKeychain = -25295; + public const int ErrorSecAuthFailed = -25293; + public const int ErrorSecDuplicateItem = -25299; + public const int ErrorSecItemNotFound = -25300; + public const int ErrorSecInteractionNotAllowed = -25308; + public const int ErrorSecInteractionRequired = -25315; + public const int ErrorSecNoSuchAttr = -25303; + + public static void ThrowIfError(int error, string defaultErrorMessage = "Unknown error.") + { + switch (error) + { + case OK: + return; + case ErrorSecNoSuchKeychain: + throw new InvalidOperationException($"The keychain does not exist. ({ErrorSecNoSuchKeychain})"); + case ErrorSecInvalidKeychain: + throw new InvalidOperationException($"The keychain is not valid. ({ErrorSecInvalidKeychain})"); + case ErrorSecAuthFailed: + throw new InvalidOperationException($"Authorization/Authentication failed. ({ErrorSecAuthFailed})"); + case ErrorSecDuplicateItem: + throw new InvalidOperationException($"The item already exists. ({ErrorSecDuplicateItem})"); + case ErrorSecItemNotFound: + throw new InvalidOperationException($"The item cannot be found. ({ErrorSecItemNotFound})"); + case ErrorSecInteractionNotAllowed: + throw new InvalidOperationException($"Interaction with the Security Server is not allowed. ({ErrorSecInteractionNotAllowed})"); + case ErrorSecInteractionRequired: + throw new InvalidOperationException($"User interaction is required. ({ErrorSecInteractionRequired})"); + case ErrorSecNoSuchAttr: + throw new InvalidOperationException($"The attribute does not exist. ({ErrorSecNoSuchAttr})"); + default: + throw new Exception($"{defaultErrorMessage} ({error})"); + } + } + + [StructLayout(LayoutKind.Sequential)] + public struct SecKeychainAttributeInfo + { + public uint Count; + public IntPtr Tag; // uint* (SecKeychainAttrType*) + public IntPtr Format; // uint* (CssmDbAttributeFormat*) + } + + [StructLayout(LayoutKind.Sequential)] + public struct SecKeychainAttributeList + { + public uint Count; + public IntPtr Attributes; // SecKeychainAttribute* + } + + [StructLayout(LayoutKind.Sequential)] + public struct SecKeychainAttribute + { + public SecKeychainAttrType Tag; + public uint Length; + public IntPtr Data; + } + + public enum CssmDbAttributeFormat : uint + { + String = 0, + SInt32 = 1, + UInt32 = 2, + BigNum = 3, + Real = 4, + TimeDate = 5, + Blob = 6, + MultiUInt32 = 7, + Complex = 8 + }; + + public enum SecKeychainAttrType : uint + { + // https://developer.apple.com/documentation/security/secitemattr/accountitemattr + AccountItem = 1633903476, + } + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern void CFRelease(IntPtr cf); + + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecKeychainAddGenericPassword( + IntPtr keychain, + uint serviceNameLength, + string serviceName, + uint accountNameLength, + string accountName, + uint passwordLength, + byte[] passwordData, + out IntPtr itemRef); + + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecKeychainFindGenericPassword( + IntPtr keychainOrArray, + uint serviceNameLength, + string serviceName, + uint accountNameLength, + string accountName, + out uint passwordLength, + out IntPtr passwordData, + out IntPtr itemRef); + + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecKeychainItemCopyAttributesAndData( + IntPtr itemRef, + ref SecKeychainAttributeInfo info, + IntPtr itemClass, // SecItemClass* + out IntPtr attrList, // SecKeychainAttributeList* + out uint dataLength, + IntPtr data); + + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecKeychainItemModifyAttributesAndData( + IntPtr itemRef, + IntPtr attrList, // SecKeychainAttributeList* + uint length, + byte[] data); + + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecKeychainItemDelete( + IntPtr itemRef); + + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecKeychainItemFreeContent( + IntPtr attrList, // SecKeychainAttributeList* + IntPtr data); + + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecKeychainItemFreeAttributesAndData( + IntPtr attrList, // SecKeychainAttributeList* + IntPtr data); + + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/SecureStorage/NativeMethods.Windows.cs b/src/Microsoft.Git.CredentialManager/SecureStorage/NativeMethods.Windows.cs new file mode 100644 index 000000000..7a2c1156e --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/SecureStorage/NativeMethods.Windows.cs @@ -0,0 +1,101 @@ +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using FILETIME = System.Runtime.InteropServices.ComTypes.FILETIME; + +namespace Microsoft.Git.CredentialManager.SecureStorage +{ + internal static partial class NativeMethods + { + // https://docs.microsoft.com/en-us/windows/desktop/api/wincred/ + public static class Windows + { + private const string Advapi32 = "advapi32.dll"; + + // https://docs.microsoft.com/en-gb/windows/desktop/Debug/system-error-codes + public const int OK = 0; + public const int ERROR_NO_SUCH_LOGON_SESSION = 0x520; + public const int ERROR_NOT_FOUND = 0x490; + public const int ERROR_BAD_USERNAME = 0x89A; + public const int ERROR_INVALID_FLAGS = 0x3EC; + public const int ERROR_INVALID_PARAMETER = 0x57; + + public static int GetLastError(bool success) + { + if (success) + { + return OK; + } + + return Marshal.GetLastWin32Error(); + } + + public static void ThrowIfError(int error, string defaultErrorMessage = null) + { + switch (error) + { + case OK: + return; + default: + // The Win32Exception constructor will automatically get the human-readable + // message for the error code. + throw new Win32Exception(error, defaultErrorMessage); + } + } + + public enum CredentialType + { + Generic = 1, + DomainPassword = 2, + DomainCertificate = 3, + DomainVisiblePassword = 4, + } + + public enum CredentialPersist + { + Session = 1, + LocalMachine = 2, + Enterprise = 3, + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct Win32Credential + { + public int Flags; + public CredentialType Type; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [MarshalAs(UnmanagedType.LPWStr)] public string Comment; + public FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public CredentialPersist Persist; + public int AttributeCount; + public IntPtr CredAttribute; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + [MarshalAs(UnmanagedType.LPWStr)] public string UserName; + } + + [DllImport(Advapi32, EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredRead( + string target, + CredentialType type, + int reserved, + out IntPtr credential); + + [DllImport(Advapi32, EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredWrite( + ref Win32Credential credential, + int flags); + + [DllImport(Advapi32, EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredDelete( + string target, + CredentialType type, + int flags); + + [DllImport(Advapi32, EntryPoint = "CredFree", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern void CredFree( + IntPtr credential); + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/SecureStorage/NativeMethods.cs b/src/Microsoft.Git.CredentialManager/SecureStorage/NativeMethods.cs new file mode 100644 index 000000000..902a25966 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/SecureStorage/NativeMethods.cs @@ -0,0 +1,15 @@ +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.Git.CredentialManager.SecureStorage +{ + internal static partial class NativeMethods + { + public static byte[] ToByteArray(IntPtr ptr, long count) + { + var destination = new byte[count]; + Marshal.Copy(ptr, destination, 0, destination.Length); + return destination; + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/SecureStorage/WindowsCredentialManager.cs b/src/Microsoft.Git.CredentialManager/SecureStorage/WindowsCredentialManager.cs new file mode 100644 index 000000000..17d4d179b --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/SecureStorage/WindowsCredentialManager.cs @@ -0,0 +1,125 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; +using static Microsoft.Git.CredentialManager.SecureStorage.NativeMethods.Windows; + +namespace Microsoft.Git.CredentialManager.SecureStorage +{ + public class WindowsCredentialManager : ICredentialStore + { + #region Constructors + + /// + /// Open the Windows Credential Manager vault for the current user. + /// + /// Current user's Credential Manager vault. + public static WindowsCredentialManager Open() + { + return new WindowsCredentialManager(); + } + + private WindowsCredentialManager() + { + PlatformUtils.EnsureWindows(); + } + + #endregion + + #region ICredentialStore + + public ICredential Get(string key) + { + IntPtr credPtr = IntPtr.Zero; + + try + { + int result = GetLastError( + CredRead(key, CredentialType.Generic, 0, out credPtr) + ); + + switch (result) + { + case OK: + Win32Credential credential = Marshal.PtrToStructure(credPtr); + + var userName = credential.UserName; + + byte[] passwordBytes = NativeMethods.ToByteArray(credential.CredentialBlob, credential.CredentialBlobSize); + var password = Encoding.Unicode.GetString(passwordBytes); + + return new Credential(userName, password); + + case ERROR_NOT_FOUND: + return null; + + default: + ThrowIfError(result, "Failed to read item from store."); + return null; + } + } + finally + { + if (credPtr != IntPtr.Zero) + { + CredFree(credPtr); + } + } + } + + public void AddOrUpdate(string key, ICredential credential) + { + byte[] passwordBytes = Encoding.Unicode.GetBytes(credential.Password); + + var w32Credential = new Win32Credential + { + Type = CredentialType.Generic, + TargetName = key, + CredentialBlob = Marshal.AllocHGlobal(passwordBytes.Length), + CredentialBlobSize = passwordBytes.Length, + Persist = CredentialPersist.LocalMachine, + AttributeCount = 0, + UserName = credential.UserName, + }; + + try + { + Marshal.Copy(passwordBytes, 0, w32Credential.CredentialBlob, passwordBytes.Length); + + int result = GetLastError( + CredWrite(ref w32Credential, 0) + ); + + ThrowIfError(result, "Failed to write item to store."); + } + finally + { + if (w32Credential.CredentialBlob != IntPtr.Zero) + { + Marshal.FreeHGlobal(w32Credential.CredentialBlob); + } + } + } + + public bool Remove(string key) + { + int result = GetLastError( + CredDelete(key, CredentialType.Generic, 0) + ); + + switch (result) + { + case OK: + return true; + + case ERROR_NOT_FOUND: + return false; + + default: + ThrowIfError(result); + return false; + } + } + + #endregion + } +} diff --git a/tests/Microsoft.Git.CredentialManager.Tests/SecureStorage/MacOSKeychainTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/SecureStorage/MacOSKeychainTests.cs new file mode 100644 index 000000000..134208456 --- /dev/null +++ b/tests/Microsoft.Git.CredentialManager.Tests/SecureStorage/MacOSKeychainTests.cs @@ -0,0 +1,63 @@ +using System; +using Xunit; +using Microsoft.Git.CredentialManager.SecureStorage; + +namespace Microsoft.Git.CredentialManager.Tests.SecureStorage +{ + public class MacOSKeychainTests + { + [PlatformFact(Platform.MacOS)] + public void MacOSKeychain_ReadWriteDelete() + { + MacOSKeychain keychain = MacOSKeychain.Open(); + + // Create a key that is guarenteed to be unique + string key = $"secretkey-{Guid.NewGuid():N}"; + const string userName = "john.doe"; + const string password = "letmein123"; + var credential = new GitCredential(userName, password); + + try + { + // Write + keychain.AddOrUpdate(key, credential); + + // Read + ICredential outCredential = keychain.Get(key); + + Assert.NotNull(outCredential); + Assert.Equal(credential.UserName, outCredential.UserName); + Assert.Equal(credential.Password, outCredential.Password); + } + finally + { + // Ensure we clean up after ourselves even in case of 'get' failures + keychain.Remove(key); + } + } + + [PlatformFact(Platform.MacOS)] + public void MacOSKeychain_Get_KeyNotFound_ReturnsNull() + { + MacOSKeychain keychain = MacOSKeychain.Open(); + + // Unique key; guaranteed not to exist! + string key = Guid.NewGuid().ToString("N"); + + ICredential credential = keychain.Get(key); + Assert.Null(credential); + } + + [PlatformFact(Platform.MacOS)] + public void MacOSKeychain_Remove_KeyNotFound_ReturnsFalse() + { + MacOSKeychain keychain = MacOSKeychain.Open(); + + // Unique key; guaranteed not to exist! + string key = Guid.NewGuid().ToString("N"); + + bool result = keychain.Remove(key); + Assert.False(result); + } + } +} diff --git a/tests/Microsoft.Git.CredentialManager.Tests/SecureStorage/WindowsCredentialManagerTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/SecureStorage/WindowsCredentialManagerTests.cs new file mode 100644 index 000000000..10bc704ad --- /dev/null +++ b/tests/Microsoft.Git.CredentialManager.Tests/SecureStorage/WindowsCredentialManagerTests.cs @@ -0,0 +1,63 @@ +using System; +using Xunit; +using Microsoft.Git.CredentialManager.SecureStorage; + +namespace Microsoft.Git.CredentialManager.Tests.SecureStorage +{ + public class WindowsCredentialManagerTests + { + [PlatformFact(Platform.Windows)] + public void WindowsCredentialManager_ReadWriteDelete() + { + WindowsCredentialManager credManager = WindowsCredentialManager.Open(); + + // Create a key that is guarenteed to be unique + string key = $"secretkey-{Guid.NewGuid():N}"; + const string userName = "john.doe"; + const string password = "letmein123"; + var credential = new GitCredential(userName, password); + + try + { + // Write + credManager.AddOrUpdate(key, credential); + + // Read + ICredential outCredential = credManager.Get(key); + + Assert.NotNull(outCredential); + Assert.Equal(credential.UserName, outCredential.UserName); + Assert.Equal(credential.Password, outCredential.Password); + } + finally + { + // Ensure we clean up after ourselves even in case of 'get' failures + credManager.Remove(key); + } + } + + [PlatformFact(Platform.Windows)] + public void WindowsCredentialManager_Get_KeyNotFound_ReturnsNull() + { + WindowsCredentialManager credManager = WindowsCredentialManager.Open(); + + // Unique key; guaranteed not to exist! + string key = Guid.NewGuid().ToString("N"); + + ICredential credential = credManager.Get(key); + Assert.Null(credential); + } + + [PlatformFact(Platform.Windows)] + public void WindowsCredentialManager_Remove_KeyNotFound_ReturnsFalse() + { + WindowsCredentialManager credManager = WindowsCredentialManager.Open(); + + // Unique key; guaranteed not to exist! + string key = Guid.NewGuid().ToString("N"); + + bool result = credManager.Remove(key); + Assert.False(result); + } + } +}