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);
+ }
+ }
+}