// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace Infrastructure.Common
{
// This class manages the adding and removal of certificates.
// It also handles informing http.sys of which certificate to associate with SSL ports.
internal static class CertificateManager
{
private static object s_certificateLock = new object();
private static bool s_platformSpecificStoreLocationIsSet = false;
private static StoreLocation s_platformSpecificRootStoreLocation = StoreLocation.LocalMachine;
// The location from where to open StoreName.Root
//
// Since we don't have access to System.Runtime.InteropServices.IsOSPlatform API, we need to find a novel way to switch
// the store we use on Linux
//
// For Linux:
// On Linux, opening stores from the LocalMachine store is not supported yet, so we toggle to use CurrentUser
// Furthermore, we don't need to sudo in Linux to install a StoreName.Root : StoreLocation.CurrentUser. So this will allow
// tests requiring certs to pass in Linux.
// See dotnet/corefx#3690
//
// For Windows:
// We don't want to use CurrentUser, as writing to StoreName.Root : StoreLocation.CurrentUser will result in a
// modal dialog box that isn't dismissable if the root cert hasn't already been installed previously.
// If the cert has already been installed, writing to StoreName.Root : StoreLocation.CurrentUser results in a no-op
//
// In other words, on Windows, we can bypass the modal dialog box, but only if we install to StoreName.Root : StoreLocation.LocalMachine
// To do this though means that we must run certificate-based tests elevated
internal static StoreLocation PlatformSpecificRootStoreLocation
{
get
{
if (!s_platformSpecificStoreLocationIsSet)
{
try
{
using (var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine))
{
store.Open(OpenFlags.ReadWrite);
}
}
catch (PlatformNotSupportedException)
{
// Linux
s_platformSpecificRootStoreLocation = StoreLocation.CurrentUser;
}
catch(CryptographicException)
{
// Linux
s_platformSpecificRootStoreLocation = StoreLocation.CurrentUser;
}
s_platformSpecificStoreLocationIsSet = true;
}
return s_platformSpecificRootStoreLocation;
}
}
internal static string OSXCustomKeychainFilePath
{
get
{
return Path.Combine(Environment.CurrentDirectory, "wcfLocal.keychain");
}
}
internal static string OSXCustomKeychainPassword
{
get
{
return "WCFKeychainFilePassword";
}
}
// Adds the given certificate to the given store unless it is
// already present. Returns the certificate either already in
// the store or the one requested.
public static X509Certificate2 AddToStoreIfNeeded(StoreName storeName,
StoreLocation storeLocation,
X509Certificate2 certificate)
{
X509Certificate2 resultCert = null;
lock (s_certificateLock)
{
// Open the store as ReadOnly first, as it prevents the need for elevation if opening
// a LocalMachine store
using (X509Store store = new X509Store(storeName, storeLocation))
{
store.Open(OpenFlags.ReadOnly);
resultCert = CertificateFromThumbprint(store, certificate.Thumbprint, validOnly: false);
}
// Not already in store. We need to add it.
if (resultCert == null)
{
using (X509Store store = new X509Store(storeName, storeLocation))
{
try
{
store.Open(OpenFlags.ReadWrite);
store.Add(certificate);
resultCert = certificate;
}
catch (CryptographicException inner)
{
StringBuilder exceptionString = new StringBuilder();
exceptionString.AppendFormat("Error opening StoreName: '{0}' certificate store from StoreLocation '{1}' in ReadWrite mode ", storeName, storeLocation);
exceptionString.AppendFormat("while attempting to install cert with thumbprint '{1}'.", Environment.NewLine, certificate.Thumbprint);
exceptionString.AppendFormat("{0}This is usually due to permissions issues if writing to the LocalMachine location", Environment.NewLine);
exceptionString.AppendFormat("{0}Try running the test with elevated or superuser permissions.", Environment.NewLine);
throw new InvalidOperationException(exceptionString.ToString(), inner);
}
}
}
}
return resultCert;
}
// Adds the given certificate to the given keychain unless it is
// already present. Returns the certificate either already in
// the store or the one requested.
public static X509Certificate2 AddToOSXKeyChainIfNeeded(SafeKeychainHandle keychain,
X509Certificate2 certificate)
{
X509Certificate2 resultCert = null;
lock (s_certificateLock)
{
using (X509Store store = new X509Store(keychain.DangerousGetHandle()))
{
// No need to open X509Store as it is already opened with mode of MaxAllowed
resultCert = CertificateFromThumbprint(store, certificate.Thumbprint, validOnly: false);
// Not already in store. We need to add it.
if (resultCert == null)
{
try
{
// We don't need the private key and the X509Certificate2 instance wasn't created as exportable.
// This creates a new certificate instance with only the public key so that it can be added to keychain.
#pragma warning disable SYSLIB0057 // Type or member is obsolete
var publicOnly = new X509Certificate2(certificate.RawData);
#pragma warning restore SYSLIB0057 // Type or member is obsolete
store.Add(publicOnly);
resultCert = publicOnly;
}
catch (CryptographicException inner)
{
throw new InvalidOperationException($"Error while attempting to install cert with thumbprint '{certificate.Thumbprint}' into OSX custom keychain.", inner);
}
}
}
}
return resultCert;
}
// Returns the certificate matching the given thumbprint from the given store.
// Returns null if not found.
private static X509Certificate2 CertificateFromThumbprint(X509Store store, string thumbprint, bool validOnly)
{
X509Certificate2Collection foundCertificates = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly);
return foundCertificates.Count == 0 ? null : foundCertificates[0];
}
private static X509Certificate2 CertificateFromThumbprint(StoreName storeName,
StoreLocation storeLocation,
string thumbprint,
bool validOnly)
{
X509Certificate2 resultCert = null;
using (X509Store store = new X509Store(storeName, storeLocation))
{
store.Open(OpenFlags.ReadOnly);
resultCert = CertificateFromThumbprint(store, thumbprint, validOnly);
}
return resultCert;
}
private static X509Certificate2 KeychainCertificateFromThumbprint(string thumbprint, bool validOnly)
{
X509Certificate2 resultCert = null;
using (SafeKeychainHandle handle = SafeKeychainHandle.Open(CertificateManager.OSXCustomKeychainFilePath, CertificateManager.OSXCustomKeychainPassword))
{
using (X509Store store = new X509Store(handle.DangerousGetHandle()))
{
resultCert = CertificateFromThumbprint(store, thumbprint, validOnly);
}
}
return resultCert;
}
// Retrieves a root certificate matching the given thumbprint from the root store
public static X509Certificate2 RootCertificateFromThumprint(string thumbprint, bool validOnly)
{
return CertificateFromThumbprint(StoreName.Root, PlatformSpecificRootStoreLocation, thumbprint, validOnly);
}
// Retrieves a client certificate matching the given thumbprint from the certificate store
public static X509Certificate2 ClientCertificateFromThumprint(string thumbprint, bool validOnly)
{
return CertificateFromThumbprint(StoreName.My, StoreLocation.CurrentUser, thumbprint, validOnly);
}
// Retrieves a server certificate matching the given thumbprint from the certificate store
public static X509Certificate2 PeerCertificateFromThumprint(string thumbprint, bool validOnly)
{
return CertificateFromThumbprint(StoreName.TrustedPeople, StoreLocation.CurrentUser, thumbprint, validOnly);
}
// Retrieves a server certificate matching the given thumbprint from a OSX local Keychain store
public static X509Certificate2 OSXLocalKeychainCertificateFromThumprint(string thumbprint, bool validOnly)
{
return KeychainCertificateFromThumbprint(thumbprint, validOnly);
}
// Install the certificate into the Root store and returns its thumbprint.
// It will not install the certificate if it is already present in the store.
// It returns the thumbprint of the certificate, regardless whether it was added or found.
public static X509Certificate2 InstallCertificateToRootStore(X509Certificate2 certificate)
{
// See explanation of StoreLocation selection at PlatformSpecificRootStoreLocation
certificate = AddToStoreIfNeeded(StoreName.Root, PlatformSpecificRootStoreLocation, certificate);
// On macOS, .NET's X509Store(Root, *) does not establish OS-level trust required by SslStream/SecTrust.
// Add an admin trust setting to the System.keychain via the `security` CLI. Passwordless sudo is set up
// for the Helix work item via eng/SendToHelix.proj pre-commands.
if ((OSHelper.Current & OSID.OSX) == OSHelper.Current)
{
AddTrustedRootOnMacOS(certificate);
}
return certificate;
}
private static void AddTrustedRootOnMacOS(X509Certificate2 certificate)
{
string pemPath = Path.Combine(Path.GetTempPath(), "wcf-root-" + certificate.Thumbprint + ".pem");
try
{
byte[] der = certificate.Export(X509ContentType.Cert);
var sb = new StringBuilder();
sb.AppendLine("-----BEGIN CERTIFICATE-----");
string b64 = Convert.ToBase64String(der);
for (int i = 0; i < b64.Length; i += 64)
{
sb.AppendLine(b64.Substring(i, Math.Min(64, b64.Length - i)));
}
sb.AppendLine("-----END CERTIFICATE-----");
File.WriteAllText(pemPath, sb.ToString());
// add-trusted-cert -d writes to the admin trust domain via SecTrustSettings,
// which on headless macOS CI runners intermittently fails with
// "SecTrustSettingsSetTrustSettings: The authorization was denied since no user
// interaction was possible." The failure is transient, so retry a few times with
// a short backoff; without a trusted root every TLS test would fail with
// UntrustedRoot.
const int maxAttempts = 5;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
int rc = RunSecurity("sudo", "-n security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain \"" + pemPath + "\"");
if (rc == 0)
{
break;
}
Console.WriteLine($"[CertificateManager] add-trusted-cert attempt {attempt}/{maxAttempts} failed (exit {rc})" + (attempt < maxAttempts ? "; retrying after 2s..." : "."));
if (attempt < maxAttempts)
{
System.Threading.Thread.Sleep(2000);
}
}
}
catch (Exception ex)
{
Console.WriteLine("[CertificateManager] Failed to add macOS admin trust: " + ex);
}
finally
{
try { if (File.Exists(pemPath)) File.Delete(pemPath); } catch { }
}
}
private static int RunSecurity(string fileName, string arguments)
{
try
{
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using (var p = Process.Start(psi))
{
string stdout = p.StandardOutput.ReadToEnd();
string stderr = p.StandardError.ReadToEnd();
p.WaitForExit(60000);
Console.WriteLine($"[CertificateManager] {fileName} {arguments} exit={p.ExitCode}");
if (!string.IsNullOrEmpty(stdout)) Console.WriteLine("[CertificateManager] stdout: " + stdout);
if (!string.IsNullOrEmpty(stderr)) Console.WriteLine("[CertificateManager] stderr: " + stderr);
return p.ExitCode;
}
}
catch (Exception ex)
{
Console.WriteLine($"[CertificateManager] Failed to run '{fileName} {arguments}': {ex}");
return -1;
}
}
// Install the certificate into the My store.
// It will not install the certificate if it is already present in the store.
// It returns the thumbprint of the certificate, regardless whether it was added or found.
public static X509Certificate2 InstallCertificateToMyStore(X509Certificate2 certificate)
{
// Always install client certs to CurrentUser
// StoreLocation.CurrentUser is supported on both Linux and Windows
// Furthermore, installing this cert to this location does not require sudo or admin elevation
certificate = AddToStoreIfNeeded(StoreName.My, StoreLocation.CurrentUser, certificate);
return certificate;
}
// Install the certificate into the TrustedPeople store.
// It will not install the certificate if it is already present in the store.
// It returns the thumbprint of the certificate, regardless whether it was added or found.
public static X509Certificate2 InstallCertificateToTrustedPeopleStore(X509Certificate2 certificate)
{
// Always install certs to CurrentUser
// StoreLocation.CurrentUser is supported on both Linux and Windows
// Furthermore, installing this cert to this location does not require sudo or admin elevation
certificate = AddToStoreIfNeeded(StoreName.TrustedPeople, StoreLocation.CurrentUser, certificate);
return certificate;
}
// Install the certificate into a custom keychain on OSX. The TrustedPeople store isn't supported
// on OSX but a similar mechanism can be achieved by creating a custom keychain and using it in
// the same way as the TrustedPeople store.
// It will not install the certificate if it is already present in the store.
// It returns the thumbprint of the certificate, regardless whether it was added or found.
public static X509Certificate2 InstallCertificateToOSXKeychainStore(X509Certificate2 certificate)
{
SafeKeychainHandle keychain;
if (!File.Exists(OSXCustomKeychainFilePath))
{
keychain = SafeKeychainHandle.Create(OSXCustomKeychainFilePath, OSXCustomKeychainPassword);
}
else
{
keychain = SafeKeychainHandle.Open(OSXCustomKeychainFilePath, OSXCustomKeychainPassword);
}
certificate = AddToOSXKeyChainIfNeeded(keychain, certificate);
return certificate;
}
}
}