diff --git a/src/ProjectTemplates/test/Helpers/AspNetProcess.cs b/src/ProjectTemplates/test/Helpers/AspNetProcess.cs index 71afbd842ac0..9c79788c522c 100644 --- a/src/ProjectTemplates/test/Helpers/AspNetProcess.cs +++ b/src/ProjectTemplates/test/Helpers/AspNetProcess.cs @@ -89,8 +89,8 @@ public AspNetProcess( internal void EnsureDevelopmentCertificates() { var now = DateTimeOffset.Now; - var manager = new CertificateManager(); - var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), "CN=localhost"); + var manager = CertificateManager.Instance; + var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1)); manager.ExportCertificate(certificate, path: _certificatePath, includePrivateKey: true, _certificatePassword); } diff --git a/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs b/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs index 8705273af80c..1fedf58ae5cd 100644 --- a/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs +++ b/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs @@ -34,6 +34,18 @@ internal static class LoggerExtensions new EventId(3, "FailedToLoadDevelopmentCertificate"), "Failed to load the development https certificate at '{certificatePath}'."); + private static readonly Action _badDeveloperCertificateState = + LoggerMessage.Define( + LogLevel.Error, + new EventId(4, "BadDeveloperCertificateState"), + CoreStrings.BadDeveloperCertificateState); + + private static readonly Action _developerCertificateFirstRun = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(5, "DeveloperCertificateFirstRun"), + "{Message}"); + public static void LocatedDevelopmentCertificate(this ILogger logger, X509Certificate2 certificate) => _locatedDevelopmentCertificate(logger, certificate.Subject, certificate.Thumbprint, null); public static void UnableToLocateDevelopmentCertificate(this ILogger logger) => _unableToLocateDevelopmentCertificate(logger, null); @@ -41,5 +53,9 @@ internal static class LoggerExtensions public static void FailedToLocateDevelopmentCertificateFile(this ILogger logger, string certificatePath) => _failedToLocateDevelopmentCertificateFile(logger, certificatePath, null); public static void FailedToLoadDevelopmentCertificate(this ILogger logger, string certificatePath) => _failedToLoadDevelopmentCertificate(logger, certificatePath, null); + + public static void BadDeveloperCertificateState(this ILogger logger) => _badDeveloperCertificateState(logger, null); + + public static void DeveloperCertificateFirstRun(this ILogger logger, string message) => _developerCertificateFirstRun(logger, message, null); } } diff --git a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs index c849b84caf1c..5cfa30375105 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -162,11 +162,29 @@ private void EnsureDefaultCert() var logger = ApplicationServices.GetRequiredService>(); try { - DefaultCertificate = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true) + DefaultCertificate = CertificateManager.Instance.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true) .FirstOrDefault(); if (DefaultCertificate != null) { + var status = CertificateManager.Instance.CheckCertificateState(DefaultCertificate, interactive: false); + if (!status.Result) + { + // Display a warning indicating to the user that a prompt might appear and provide instructions on what to do in that + // case. The underlying implementation of this check is specific to Mac OS and is handled within CheckCertificateState. + // Kestrel must NEVER cause a UI prompt on a production system. We only attempt this here because Mac OS is not supported + // in production. + logger.DeveloperCertificateFirstRun(status.Message); + + // Now that we've displayed a warning in the logs so that the user gets a notification that a prompt might appear, try + // and access the certificate key, which might trigger a prompt. + status = CertificateManager.Instance.CheckCertificateState(DefaultCertificate, interactive: true); + if (!status.Result) + { + logger.BadDeveloperCertificateState(); + } + } + logger.LocatedDevelopmentCertificate(DefaultCertificate); } else diff --git a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs index a568f7a02c5b..388d45abf53f 100644 --- a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs +++ b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs @@ -220,16 +220,7 @@ public async Task OnConnectionAsync(ConnectionContext context) } catch (AuthenticationException ex) { - if (_serverCertificate == null || - !CertificateManager.IsHttpsDevelopmentCertificate(_serverCertificate) || - CertificateManager.CheckDeveloperCertificateKey(_serverCertificate)) - { - _logger.LogDebug(1, ex, CoreStrings.AuthenticationFailed); - } - else - { - _logger.LogError(3, ex, CoreStrings.BadDeveloperCertificateState); - } + _logger.LogDebug(1, ex, CoreStrings.AuthenticationFailed); await sslStream.DisposeAsync(); return; diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs index 163d6a632f10..17b5cae1e16a 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs @@ -385,35 +385,6 @@ await Assert.ThrowsAnyAsync(() => Assert.Equal(LogLevel.Debug, loggerProvider.FilterLogger.LastLogLevel); } - [Fact] - public async Task DevCertWithInvalidPrivateKeyProducesCustomWarning() - { - var loggerProvider = new HandshakeErrorLoggerProvider(); - LoggerFactory.AddProvider(loggerProvider); - - await using (var server = new TestServer(context => Task.CompletedTask, - new TestServiceContext(LoggerFactory), - listenOptions => - { - listenOptions.UseHttps(TestResources.GetTestCertificate("aspnetdevcert.pfx", "testPassword")); - })) - { - using (var connection = server.CreateConnection()) - using (var sslStream = new SslStream(connection.Stream, true, (sender, certificate, chain, errors) => true)) - { - // SslProtocols.Tls is TLS 1.0 which isn't supported by Kestrel by default. - await Assert.ThrowsAnyAsync(() => - sslStream.AuthenticateAsClientAsync("127.0.0.1", clientCertificates: null, - enabledSslProtocols: SslProtocols.Tls, - checkCertificateRevocation: false)); - } - } - - await loggerProvider.FilterLogger.LogTcs.Task.DefaultTimeout(); - Assert.Equal(3, loggerProvider.FilterLogger.LastEventId); - Assert.Equal(LogLevel.Error, loggerProvider.FilterLogger.LastLogLevel); - } - [Fact] public async Task OnAuthenticate_SeesOtherSettings() { diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index 01962e83d4bf..5fc4cd6b57a4 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -3,22 +3,20 @@ using System; using System.Collections.Generic; -using System.Diagnostics; +using System.Diagnostics.Tracing; using System.IO; using System.Linq; using System.Runtime.InteropServices; -using System.Runtime.InteropServices.ComTypes; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; -using System.Text.RegularExpressions; namespace Microsoft.AspNetCore.Certificates.Generation { - internal class CertificateManager + internal abstract class CertificateManager { - public const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; - public const string AspNetHttpsOidFriendlyName = "ASP.NET Core HTTPS development certificate"; + internal const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; + internal const string AspNetHttpsOidFriendlyName = "ASP.NET Core HTTPS development certificate"; private const string ServerAuthenticationEnhancedKeyUsageOid = "1.3.6.1.5.5.7.3.1"; private const string ServerAuthenticationEnhancedKeyUsageOidFriendlyName = "Server Authentication"; @@ -28,99 +26,94 @@ internal class CertificateManager public const int RSAMinimumKeySizeInBits = 2048; - private static readonly TimeSpan MaxRegexTimeout = TimeSpan.FromMinutes(1); - private const string CertificateSubjectRegex = "CN=(.*[^,]+).*"; - private const string MacOSSystemKeyChain = "/Library/Keychains/System.keychain"; - private static readonly string MacOSUserKeyChain = Environment.GetEnvironmentVariable("HOME") + "/Library/Keychains/login.keychain-db"; - private const string MacOSFindCertificateCommandLine = "security"; - private static readonly string MacOSFindCertificateCommandLineArgumentsFormat = "find-certificate -c {0} -a -Z -p " + MacOSSystemKeyChain; - private const string MacOSFindCertificateOutputRegex = "SHA-1 hash: ([0-9A-Z]+)"; - private const string MacOSRemoveCertificateTrustCommandLine = "sudo"; - private const string MacOSRemoveCertificateTrustCommandLineArgumentsFormat = "security remove-trusted-cert -d {0}"; - private const string MacOSDeleteCertificateCommandLine = "sudo"; - private const string MacOSDeleteCertificateCommandLineArgumentsFormat = "security delete-certificate -Z {0} {1}"; - private const string MacOSTrustCertificateCommandLine = "sudo"; - private static readonly string MacOSTrustCertificateCommandLineArguments = "security add-trusted-cert -d -r trustRoot -k " + MacOSSystemKeyChain + " "; - private const int UserCancelledErrorCode = 1223; - private const string MacOSSetPartitionKeyPermissionsCommandLine = "sudo"; - private static readonly string MacOSSetPartitionKeyPermissionsCommandLineArguments = "security set-key-partition-list -D localhost -S unsigned:,teamid:UBF8T346G9 " + MacOSUserKeyChain; + public static CertificateManager Instance { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new WindowsCertificateManager() : + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? + new MacOSCertificateManager() as CertificateManager : + new UnixCertificateManager(); + + public static CertificateManagerEventSource Log { get; set; } = new CertificateManagerEventSource(); // Setting to 0 means we don't append the version byte, // which is what all machines currently have. - public static int AspNetHttpsCertificateVersion { get; set; } = 1; + public int AspNetHttpsCertificateVersion + { + get; + // For testing purposes only + internal set; + } + + public string Subject { get; } + + public CertificateManager() : this(LocalhostHttpsDistinguishedName, 1) + { + } + + // For testing purposes only + internal CertificateManager(string subject, int version) + { + Subject = subject; + AspNetHttpsCertificateVersion = version; + } - public static bool IsHttpsDevelopmentCertificate(X509Certificate2 certificate) => + public bool IsHttpsDevelopmentCertificate(X509Certificate2 certificate) => certificate.Extensions.OfType() .Any(e => string.Equals(AspNetHttpsOid, e.Oid.Value, StringComparison.Ordinal)); - public static IList ListCertificates( - CertificatePurpose purpose, + public IList ListCertificates( StoreName storeName, StoreLocation location, bool isValid, - bool requireExportable = true, - DiagnosticInformation diagnostics = null) + bool requireExportable = true) { - diagnostics?.Debug($"Listing '{purpose.ToString()}' certificates on '{location}\\{storeName}'."); + Log.ListCertificatesStart(location, storeName); var certificates = new List(); try { - using (var store = new X509Store(storeName, location)) - { - store.Open(OpenFlags.ReadOnly); - certificates.AddRange(store.Certificates.OfType()); - IEnumerable matchingCertificates = certificates; - switch (purpose) - { - case CertificatePurpose.All: - matchingCertificates = matchingCertificates - .Where(c => HasOid(c, AspNetHttpsOid)); - break; - case CertificatePurpose.HTTPS: - matchingCertificates = matchingCertificates - .Where(c => HasOid(c, AspNetHttpsOid)); - break; - default: - break; - } + using var store = new X509Store(storeName, location); + store.Open(OpenFlags.ReadOnly); + certificates.AddRange(store.Certificates.OfType()); + IEnumerable matchingCertificates = certificates; + matchingCertificates = matchingCertificates + .Where(c => HasOid(c, AspNetHttpsOid)); - diagnostics?.Debug(diagnostics.DescribeCertificates(matchingCertificates)); - if (isValid) - { - // Ensure the certificate hasn't expired, has a private key and its exportable - // (for container/unix scenarios). - diagnostics?.Debug("Checking certificates for validity."); - var now = DateTimeOffset.Now; - var validCertificates = matchingCertificates - .Where(c => c.NotBefore <= now && - now <= c.NotAfter && - (!requireExportable || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || IsExportable(c)) - && MatchesVersion(c)) - .ToArray(); - - var invalidCertificates = matchingCertificates.Except(validCertificates); - - diagnostics?.Debug("Listing valid certificates"); - diagnostics?.Debug(diagnostics.DescribeCertificates(validCertificates)); - diagnostics?.Debug("Listing invalid certificates"); - diagnostics?.Debug(diagnostics.DescribeCertificates(invalidCertificates)); - - matchingCertificates = validCertificates; - } + Log.DescribeFoundCertificates(ToCertificateDescription(matchingCertificates)); - // We need to enumerate the certificates early to prevent dispoisng issues. - matchingCertificates = matchingCertificates.ToList(); + if (isValid) + { + // Ensure the certificate hasn't expired, has a private key and its exportable + // (for container/unix scenarios). + Log.CheckCertificatesValidity(); + var now = DateTimeOffset.Now; + var validCertificates = matchingCertificates + .Where(c => c.NotBefore <= now && + now <= c.NotAfter && + (!requireExportable || IsExportable(c)) + && MatchesVersion(c)) + .ToArray(); - var certificatesToDispose = certificates.Except(matchingCertificates); - DisposeCertificates(certificatesToDispose); + var invalidCertificates = matchingCertificates.Except(validCertificates); - store.Close(); + Log.DescribeValidCertificates(ToCertificateDescription(validCertificates)); + Log.DescribeInvalidValidCertificates(ToCertificateDescription(invalidCertificates)); - return (IList)matchingCertificates; + matchingCertificates = validCertificates; } + + // We need to enumerate the certificates early to prevent disposing issues. + matchingCertificates = matchingCertificates.ToList(); + + var certificatesToDispose = certificates.Except(matchingCertificates); + DisposeCertificates(certificatesToDispose); + + store.Close(); + + Log.ListCertificatesEnd(); + return (IList)matchingCertificates; } - catch + catch (Exception e) { + Log.ListCertificatesError(e.ToString()); DisposeCertificates(certificates); certificates.Clear(); return certificates; @@ -148,62 +141,238 @@ bool MatchesVersion(X509Certificate2 c) return byteArray[0] >= AspNetHttpsCertificateVersion; } } -#if !XPLAT - bool IsExportable(X509Certificate2 c) => - ((c.GetRSAPrivateKey() is RSACryptoServiceProvider rsaPrivateKey && - rsaPrivateKey.CspKeyContainerInfo.Exportable) || - (c.GetRSAPrivateKey() is RSACng cngPrivateKey && - cngPrivateKey.Key.ExportPolicy == CngExportPolicies.AllowExport)); -#else - // Only check for RSA CryptoServiceProvider and do not fail in XPlat tooling as - // System.Security.Cryptography.Cng is not part of the shared framework and we don't - // want to bring the dependency in on CLI scenarios. This functionality will be used - // on CLI scenarios as part of the first run experience, so checking the exportability - // of the certificate is not important. - bool IsExportable(X509Certificate2 c) => - ((c.GetRSAPrivateKey() is RSACryptoServiceProvider rsaPrivateKey && - rsaPrivateKey.CspKeyContainerInfo.Exportable) || !(c.GetRSAPrivateKey() is RSACryptoServiceProvider)); -#endif } - private static void DisposeCertificates(IEnumerable disposables) + public IList GetHttpsCertificates() => + ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true); + + public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( + DateTimeOffset notBefore, + DateTimeOffset notAfter, + string path = null, + bool trust = false, + bool includePrivateKey = false, + string password = null, + bool isInteractive = true) { - foreach (var disposable in disposables) + var result = EnsureCertificateResult.Succeeded; + + var certificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true).Concat( + ListCertificates(StoreName.My, StoreLocation.LocalMachine, isValid: true, requireExportable: true)); + + var filteredCertificates = certificates.Where(c => c.Subject == Subject); + var excludedCertificates = certificates.Except(filteredCertificates); + + Log.FilteredCertificates(ToCertificateDescription(filteredCertificates)); + Log.ExcludedCertificates(ToCertificateDescription(excludedCertificates)); + + certificates = filteredCertificates; + + X509Certificate2 certificate = null; + if (certificates.Any()) + { + certificate = certificates.First(); + var failedToFixCertificateState = false; + if (isInteractive) + { + // Skip this step if the command is not interactive, + // as we don't want to prompt on first run experience. + foreach (var candidate in certificates) + { + var status = CheckCertificateState(candidate, true); + if (!status.Result) + { + try + { + Log.CorrectCertificateStateStart(GetDescription(candidate)); + CorrectCertificateState(candidate); + Log.CorrectCertificateStateEnd(); + } + catch (Exception e) + { + Log.CorrectCertificateStateError(e.ToString()); + result = EnsureCertificateResult.FailedToMakeKeyAccessible; + // We don't return early on this type of failure to allow for tooling to + // export or trust the certificate even in this situation, as that enables + // exporting the certificate to perform any necessary fix with native tooling. + failedToFixCertificateState = true; + } + } + } + } + + if (!failedToFixCertificateState) + { + Log.ValidCertificatesFound(ToCertificateDescription(certificates)); + certificate = certificates.First(); + Log.SelectedCertificate(GetDescription(certificate)); + result = EnsureCertificateResult.ValidCertificatePresent; + } + } + else { + Log.NoValidCertificatesFound(); try { - disposable.Dispose(); + Log.CreateDevelopmentCertificateStart(); + certificate = CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter); + } + catch (Exception e) + { + Log.CreateDevelopmentCertificateError(e.ToString()); + result = EnsureCertificateResult.ErrorCreatingTheCertificate; + return result; + } + Log.CreateDevelopmentCertificateEnd(); + + try + { + certificate = SaveCertificate(certificate); + } + catch (Exception e) + { + Log.SaveCertificateInStoreError(e.ToString()); + result = EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore; + return result; + } + + if (isInteractive) + { + try + { + Log.CorrectCertificateStateStart(GetDescription(certificate)); + CorrectCertificateState(certificate); + Log.CorrectCertificateStateEnd(); + } + catch (Exception e) + { + Log.CorrectCertificateStateError(e.ToString()); + // We don't return early on this type of failure to allow for tooling to + // export or trust the certificate even in this situation, as that enables + // exporting the certificate to perform any necessary fix with native tooling. + result = EnsureCertificateResult.FailedToMakeKeyAccessible; + } + } + } + + if (path != null) + { + try + { + ExportCertificate(certificate, path, includePrivateKey, password); + } + catch (Exception e) + { + Log.ExportCertificateError(e.ToString()); + // We don't want to mask the original source of the error here. + result = result != EnsureCertificateResult.Succeeded || result != EnsureCertificateResult.ValidCertificatePresent ? + result : + EnsureCertificateResult.ErrorExportingTheCertificate; + + return result; + } + } + + if (trust) + { + try + { + TrustCertificate(certificate); + } + catch (UserCancelledTrustException) + { + result = EnsureCertificateResult.UserCancelledTrustStep; + return result; } catch { + result = EnsureCertificateResult.FailedToTrustTheCertificate; + return result; } } + + return result; } - internal bool HasValidCertificateWithInnaccessibleKeyAcrossPartitions() + public void CleanupHttpsCertificates() { - var certificates = GetHttpsCertificates(); - if (certificates.Count == 0) + // On OS X we don't have a good way to manage trusted certificates in the system keychain + // so we do everything by invoking the native toolchain. + // This has some limitations, like for example not being able to identify our custom OID extension. For that + // matter, when we are cleaning up certificates on the machine, we start by removing the trusted certificates. + // To do this, we list the certificates that we can identify on the current user personal store and we invoke + // the native toolchain to remove them from the sytem keychain. Once we have removed the trusted certificates, + // we remove the certificates from the local user store to finish up the cleanup. + var certificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false); + var filteredCertificates = certificates.Where(c => c.Subject == Subject); + var excludedCertificates = certificates.Except(filteredCertificates); + + Log.FilteredCertificates(ToCertificateDescription(filteredCertificates)); + Log.ExcludedCertificates(ToCertificateDescription(excludedCertificates)); + + foreach (var certificate in filteredCertificates) { - return false; + RemoveCertificate(certificate, RemoveLocations.All); } + } - // We need to check all certificates as a new one might be created that hasn't been correctly setup. - var result = false; - foreach (var certificate in certificates) + public abstract bool IsTrusted(X509Certificate2 certificate); + + protected abstract X509Certificate2 SaveCertificateCore(X509Certificate2 certificate); + + protected abstract void TrustCertificateCore(X509Certificate2 certificate); + + protected abstract bool IsExportable(X509Certificate2 c); + + protected abstract void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate); + + protected abstract IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation); + + internal void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string password) + { + Log.ExportCertificateStart(GetDescription(certificate), path, includePrivateKey); + if (includePrivateKey && password == null) { - result = result || !CanAccessCertificateKeyAcrossPartitions(certificate); + Log.NoPasswordForCertificate(); } - return result; - } + var targetDirectoryPath = Path.GetDirectoryName(path); + if (targetDirectoryPath != "") + { + Log.CreateExportCertificateDirectory(targetDirectoryPath); + Directory.CreateDirectory(targetDirectoryPath); + } - public IList GetHttpsCertificates() => - ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true); + byte[] bytes; + try + { + bytes = includePrivateKey ? certificate.Export(X509ContentType.Pkcs12, password) : certificate.Export(X509ContentType.Cert); + } + catch (Exception e) + { + Log.ExportCertificateError(e.ToString()); + throw; + } + + try + { + Log.WriteCertificateToDisk(path); + File.WriteAllBytes(path, bytes); + } + catch (Exception ex) + { + Log.WriteCertificateToDiskError(ex.ToString()); + throw; + } + finally + { + Array.Clear(bytes, 0, bytes.Length); + } + } - public X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter, string subjectOverride, DiagnosticInformation diagnostics = null) + internal X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter) { - var subject = new X500DistinguishedName(subjectOverride ?? LocalhostHttpsDistinguishedName); + var subject = new X500DistinguishedName(Subject); var extensions = new List(); var sanBuilder = new SubjectAlternativeNameBuilder(); sanBuilder.AddDnsName(LocalhostHttpsDnsName); @@ -248,42 +417,79 @@ public X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffs extensions.Add(aspNetHttpsExtension); var certificate = CreateSelfSignedCertificate(subject, extensions, notBefore, notAfter); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - certificate.FriendlyName = AspNetHttpsOidFriendlyName; - } + return certificate; + } + + internal X509Certificate2 SaveCertificate(X509Certificate2 certificate) + { + var name = StoreName.My; + var location = StoreLocation.CurrentUser; + + Log.SaveCertificateInStoreStart(GetDescription(certificate), name, location); + certificate = SaveCertificateCore(certificate); + + Log.SaveCertificateInStoreEnd(); return certificate; } - internal static bool CheckDeveloperCertificateKey(X509Certificate2 candidate) + internal void TrustCertificate(X509Certificate2 certificate) { - // Tries to use the certificate key to validate it can't access it try { - var rsa = candidate.GetRSAPrivateKey(); - if (rsa == null) - { - return false; - } + Log.TrustCertificateStart(GetDescription(certificate)); + TrustCertificateCore(certificate); + Log.TrustCertificateEnd(); + } + catch (Exception ex) + { + Log.TrustCertificateError(ex.ToString()); + throw; + } + } + + // Internal, for testing purposes only. + internal void RemoveAllCertificates(StoreName storeName, StoreLocation storeLocation) + { + var certificates = GetCertificatesToRemove(storeName, storeLocation); + var certificatesWithName = certificates.Where(c => c.Subject == Subject); - // Encrypting a random value is the ultimate test for a key validity. - // Windows and Mac OS both return HasPrivateKey = true if there is (or there has been) a private key associated - // with the certificate at some point. - var value = new byte[32]; - RandomNumberGenerator.Fill(value); - rsa.Decrypt(rsa.Encrypt(value, RSAEncryptionPadding.Pkcs1), RSAEncryptionPadding.Pkcs1); + var removeLocation = storeName == StoreName.My ? RemoveLocations.Local : RemoveLocations.Trusted; - // Being able to encrypt and decrypt a payload is the strongest guarantee that the key is valid. - return true; + foreach (var certificate in certificates) + { + RemoveCertificate(certificate, removeLocation); } - catch (Exception) + + DisposeCertificates(certificates); + } + + internal void RemoveCertificate(X509Certificate2 certificate, RemoveLocations locations) + { + switch (locations) { - return false; + case RemoveLocations.Undefined: + throw new InvalidOperationException($"'{nameof(RemoveLocations.Undefined)}' is not a valid location."); + case RemoveLocations.Local: + RemoveCertificateFromUserStore(certificate); + break; + case RemoveLocations.Trusted: + RemoveCertificateFromTrustedRoots(certificate); + break; + case RemoveLocations.All: + RemoveCertificateFromTrustedRoots(certificate); + RemoveCertificateFromUserStore(certificate); + break; + default: + throw new InvalidOperationException("Invalid location."); } } - public X509Certificate2 CreateSelfSignedCertificate( + internal abstract CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive); + + internal abstract void CorrectCertificateState(X509Certificate2 candidate); + + internal X509Certificate2 CreateSelfSignedCertificate( X500DistinguishedName subject, IEnumerable extensions, DateTimeOffset notBefore, @@ -297,7 +503,8 @@ public X509Certificate2 CreateSelfSignedCertificate( request.CertificateExtensions.Add(extension); } - return request.CreateSelfSigned(notBefore, notAfter); + var result = request.CreateSelfSigned(notBefore, notAfter); + return result; RSA CreateKeyMaterial(int minimumKeySize) { @@ -311,732 +518,256 @@ RSA CreateKeyMaterial(int minimumKeySize) } } - public X509Certificate2 SaveCertificateInStore(X509Certificate2 certificate, StoreName name, StoreLocation location, DiagnosticInformation diagnostics = null) - { - diagnostics?.Debug("Saving the certificate into the certificate store."); - var imported = certificate; - if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - // On non OSX systems we need to export the certificate and import it so that the transient - // key that we generated gets persisted. - var export = certificate.Export(X509ContentType.Pkcs12, ""); - imported = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); - Array.Clear(export, 0, export.Length); - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - imported.FriendlyName = certificate.FriendlyName; - } - - using (var store = new X509Store(name, location)) - { - store.Open(OpenFlags.ReadWrite); - store.Add(imported); - store.Close(); - }; - - return imported; - } - - public void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string password, DiagnosticInformation diagnostics = null) + internal static void DisposeCertificates(IEnumerable disposables) { - diagnostics?.Debug( - $"Exporting certificate to '{path}'", - includePrivateKey ? "The certificate will contain the private key" : "The certificate will not contain the private key"); - if (includePrivateKey && password == null) - { - diagnostics?.Debug("No password was provided for the certificate."); - } - - var targetDirectoryPath = Path.GetDirectoryName(path); - if (targetDirectoryPath != "") - { - diagnostics?.Debug($"Ensuring that the directory for the target exported certificate path exists '{targetDirectoryPath}'"); - Directory.CreateDirectory(targetDirectoryPath); - } - - byte[] bytes; - if (includePrivateKey) - { - try - { - diagnostics?.Debug($"Exporting the certificate including the private key."); - bytes = certificate.Export(X509ContentType.Pkcs12, password); - } - catch (Exception e) - { - diagnostics?.Error($"Failed to export the certificate with the private key", e); - throw; - } - } - else + foreach (var disposable in disposables) { try { - diagnostics?.Debug($"Exporting the certificate without the private key."); - bytes = certificate.Export(X509ContentType.Cert); + disposable.Dispose(); } - catch (Exception ex) + catch { - diagnostics?.Error($"Failed to export the certificate without the private key", ex); - throw; } } + } + + private static void RemoveCertificateFromUserStore(X509Certificate2 certificate) + { try { - diagnostics?.Debug($"Writing exported certificate to path '{path}'."); - File.WriteAllBytes(path, bytes); + Log.RemoveCertificateFromUserStoreStart(GetDescription(certificate)); + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + var matching = store.Certificates + .OfType() + .Single(c => c.SerialNumber == certificate.SerialNumber); + + store.Remove(matching); + store.Close(); + Log.RemoveCertificateFromUserStoreEnd(); } catch (Exception ex) { - diagnostics?.Error("Failed writing the certificate to the target path", ex); + Log.RemoveCertificateFromUserStoreError(ex.ToString()); throw; } - finally - { - Array.Clear(bytes, 0, bytes.Length); - } } - public void TrustCertificate(X509Certificate2 certificate, DiagnosticInformation diagnostics = null) - { - // Strip certificate of the private key if any. - var publicCertificate = new X509Certificate2(certificate.Export(X509ContentType.Cert)); + internal static string ToCertificateDescription(IEnumerable matchingCertificates) => + string.Join(Environment.NewLine, matchingCertificates + .OrderBy(c => c.Thumbprint) + .Select(c => GetDescription(c)) + .ToArray()); - if (!IsTrusted(publicCertificate)) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - diagnostics?.Debug("Trusting the certificate on Windows."); - TrustCertificateOnWindows(certificate, publicCertificate, diagnostics); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - diagnostics?.Debug("Trusting the certificate on MAC."); - TrustCertificateOnMac(publicCertificate, diagnostics); - } - } - } + internal static string GetDescription(X509Certificate2 c) => + $"{c.Thumbprint[0..6]} - {c.Subject} - {c.GetEffectiveDateString()} - {c.GetExpirationDateString()} - {Instance.IsHttpsDevelopmentCertificate(c)} - {Instance.IsExportable(c)}"; - private void TrustCertificateOnMac(X509Certificate2 publicCertificate, DiagnosticInformation diagnostics) + [EventSource(Name = "Dotnet-dev-certs")] + public class CertificateManagerEventSource : EventSource { - var tmpFile = Path.GetTempFileName(); - try - { - ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null); - diagnostics?.Debug("Running the trust command on Mac OS"); - using (var process = Process.Start(MacOSTrustCertificateCommandLine, MacOSTrustCertificateCommandLineArguments + tmpFile)) - { - process.WaitForExit(); - if (process.ExitCode != 0) - { - throw new InvalidOperationException("There was an error trusting the certificate."); - } - } - } - finally - { - try - { - if (File.Exists(tmpFile)) - { - File.Delete(tmpFile); - } - } - catch - { - // We don't care if we can't delete the temp file. - } - } - } + [Event(1, Level = EventLevel.Verbose)] + public void ListCertificatesStart(StoreLocation location, StoreName storeName) => WriteEvent(1, $"Listing certificates from {location}\\{storeName}"); - private static void TrustCertificateOnWindows(X509Certificate2 certificate, X509Certificate2 publicCertificate, DiagnosticInformation diagnostics = null) - { - publicCertificate.FriendlyName = certificate.FriendlyName; + [Event(2, Level = EventLevel.Verbose)] + public void DescribeFoundCertificates(string matchingCertificates) => WriteEvent(2, matchingCertificates); - using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) - { - store.Open(OpenFlags.ReadWrite); - var existing = store.Certificates.Find(X509FindType.FindByThumbprint, publicCertificate.Thumbprint, validOnly: false); - if (existing.Count > 0) - { - diagnostics?.Debug("Certificate already trusted. Skipping trust step."); - DisposeCertificates(existing.OfType()); - return; - } + [Event(3, Level = EventLevel.Verbose)] + public void CheckCertificatesValidity() => WriteEvent(3, "Checking certificates validity"); - try - { - diagnostics?.Debug("Adding certificate to the store."); - store.Add(publicCertificate); - } - catch (CryptographicException exception) when (exception.HResult == UserCancelledErrorCode) - { - diagnostics?.Debug("User cancelled the trust prompt."); - throw new UserCancelledTrustException(); - } - store.Close(); - }; - } + [Event(4, Level = EventLevel.Verbose)] + public void DescribeValidCertificates(string validCertificates) => WriteEvent(4, validCertificates); - public bool IsTrusted(X509Certificate2 certificate) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return ListCertificates(CertificatePurpose.HTTPS, StoreName.Root, StoreLocation.CurrentUser, isValid: true, requireExportable: false) - .Any(c => c.Thumbprint == certificate.Thumbprint); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - var subjectMatch = Regex.Match(certificate.Subject, CertificateSubjectRegex, RegexOptions.Singleline, MaxRegexTimeout); - if (!subjectMatch.Success) - { - throw new InvalidOperationException($"Can't determine the subject for the certificate with subject '{certificate.Subject}'."); - } - var subject = subjectMatch.Groups[1].Value; - using (var checkTrustProcess = Process.Start(new ProcessStartInfo( - MacOSFindCertificateCommandLine, - string.Format(MacOSFindCertificateCommandLineArgumentsFormat, subject)) - { - RedirectStandardOutput = true - })) - { - var output = checkTrustProcess.StandardOutput.ReadToEnd(); - checkTrustProcess.WaitForExit(); - var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, MaxRegexTimeout); - var hashes = matches.OfType().Select(m => m.Groups[1].Value).ToList(); - return hashes.Any(h => string.Equals(h, certificate.Thumbprint, StringComparison.Ordinal)); - } - } - else - { - return false; - } - } + [Event(5, Level = EventLevel.Verbose)] + public void DescribeInvalidValidCertificates(string invalidCertificates) => WriteEvent(5, invalidCertificates); - public void CleanupHttpsCertificates(string subject = LocalhostHttpsDistinguishedName) - { - CleanupCertificates(CertificatePurpose.HTTPS, subject); - } + [Event(6, Level = EventLevel.Verbose)] + public void ListCertificatesEnd() => WriteEvent(6, "Finished listing certificates."); - public void CleanupCertificates(CertificatePurpose purpose, string subject) - { - // On OS X we don't have a good way to manage trusted certificates in the system keychain - // so we do everything by invoking the native toolchain. - // This has some limitations, like for example not being able to identify our custom OID extension. For that - // matter, when we are cleaning up certificates on the machine, we start by removing the trusted certificates. - // To do this, we list the certificates that we can identify on the current user personal store and we invoke - // the native toolchain to remove them from the sytem keychain. Once we have removed the trusted certificates, - // we remove the certificates from the local user store to finish up the cleanup. - var certificates = ListCertificates(purpose, StoreName.My, StoreLocation.CurrentUser, isValid: false); - foreach (var certificate in certificates) - { - RemoveCertificate(certificate, RemoveLocations.All); - } - } + [Event(7, Level = EventLevel.Error)] + public void ListCertificatesError(string e) => WriteEvent(7, $"An error ocurred while listing the certificates: {e}"); - public DiagnosticInformation CleanupHttpsCertificates2(string subject = LocalhostHttpsDistinguishedName) - { - return CleanupCertificates2(CertificatePurpose.HTTPS, subject); - } + [Event(8, Level = EventLevel.Verbose)] + public void FilteredCertificates(string filteredCertificates) => WriteEvent(8, filteredCertificates); - public DiagnosticInformation CleanupCertificates2(CertificatePurpose purpose, string subject) - { - var diagnostics = new DiagnosticInformation(); - // On OS X we don't have a good way to manage trusted certificates in the system keychain - // so we do everything by invoking the native toolchain. - // This has some limitations, like for example not being able to identify our custom OID extension. For that - // matter, when we are cleaning up certificates on the machine, we start by removing the trusted certificates. - // To do this, we list the certificates that we can identify on the current user personal store and we invoke - // the native toolchain to remove them from the sytem keychain. Once we have removed the trusted certificates, - // we remove the certificates from the local user store to finish up the cleanup. - var certificates = ListCertificates(purpose, StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: true, diagnostics); - foreach (var certificate in certificates) - { - RemoveCertificate(certificate, RemoveLocations.All, diagnostics); - } + [Event(9, Level = EventLevel.Verbose)] + public void ExcludedCertificates(string excludedCertificates) => WriteEvent(9, excludedCertificates); - return diagnostics; - } + [Event(11, Level = EventLevel.Verbose)] + public void MacOSMakeCertificateAccessibleAcrossPartitionsStart(string cert) => WriteEvent(11, $"Trying to make certificate accessible across partitions: {cert}"); - public void RemoveAllCertificates(CertificatePurpose purpose, StoreName storeName, StoreLocation storeLocation, string subject = null) - { - var certificates = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? - ListCertificates(purpose, StoreName.My, StoreLocation.CurrentUser, isValid: false) : - ListCertificates(purpose, storeName, storeLocation, isValid: false); - var certificatesWithName = subject == null ? certificates : certificates.Where(c => c.Subject == subject); + [Event(12, Level = EventLevel.Verbose)] + public void MacOSMakeCertificateAccessibleAcrossPartitionsEnd() => WriteEvent(12, "Finished making the certificate accessible across partitions."); - var removeLocation = storeName == StoreName.My ? RemoveLocations.Local : RemoveLocations.Trusted; + [Event(13, Level = EventLevel.Error)] + public void MacOSMakeCertificateAccessibleAcrossPartitionsError(string ex) => WriteEvent(13, $"An error ocurred while making the certificate accessible across partitions : {ex}"); - foreach (var certificate in certificates) - { - RemoveCertificate(certificate, removeLocation); - } - DisposeCertificates(certificates); - } + [Event(14, Level = EventLevel.Verbose)] + public void ValidCertificatesFound(string certificates) => WriteEvent(14, certificates); - private void RemoveCertificate(X509Certificate2 certificate, RemoveLocations locations, DiagnosticInformation diagnostics = null) - { - switch (locations) - { - case RemoveLocations.Undefined: - throw new InvalidOperationException($"'{nameof(RemoveLocations.Undefined)}' is not a valid location."); - case RemoveLocations.Local: - RemoveCertificateFromUserStore(certificate, diagnostics); - break; - case RemoveLocations.Trusted when !RuntimeInformation.IsOSPlatform(OSPlatform.Linux): - RemoveCertificateFromTrustedRoots(certificate, diagnostics); - break; - case RemoveLocations.All: - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - RemoveCertificateFromTrustedRoots(certificate, diagnostics); - } - RemoveCertificateFromUserStore(certificate, diagnostics); - break; - default: - throw new InvalidOperationException("Invalid location."); - } - } + [Event(15, Level = EventLevel.Verbose)] + public void SelectedCertificate(string certificate) => WriteEvent(15, $"Selected certificate: {certificate}"); - private static void RemoveCertificateFromUserStore(X509Certificate2 certificate, DiagnosticInformation diagnostics) - { - diagnostics?.Debug($"Trying to remove certificate with thumbprint '{certificate.Thumbprint}' from certificate store '{StoreLocation.CurrentUser}\\{StoreName.My}'."); - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) - { - store.Open(OpenFlags.ReadWrite); - var matching = store.Certificates - .OfType() - .Single(c => c.SerialNumber == certificate.SerialNumber); - store.Remove(matching); - store.Close(); - } - } + [Event(16, Level = EventLevel.Verbose)] + public void NoValidCertificatesFound() => WriteEvent(16, "No valid certificates found."); - private void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate, DiagnosticInformation diagnostics) - { - diagnostics?.Debug($"Trying to remove certificate with thumbprint '{certificate.Thumbprint}' from certificate store '{StoreLocation.CurrentUser}\\{StoreName.Root}'."); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) - { - store.Open(OpenFlags.ReadWrite); - var matching = store.Certificates - .OfType() - .SingleOrDefault(c => c.SerialNumber == certificate.SerialNumber); - if (matching != null) - { - store.Remove(matching); - } + [Event(17, Level = EventLevel.Verbose)] + public void CreateDevelopmentCertificateStart() => WriteEvent(17, "Generating HTTPS development certificate."); - store.Close(); - } - } - else - { - if (IsTrusted(certificate)) // On OSX this check just ensures its on the system keychain - { - try - { - diagnostics?.Debug("Trying to remove the certificate trust rule."); - RemoveCertificateTrustRule(certificate); - } - catch - { - diagnostics?.Debug("Failed to remove the certificate trust rule."); - // We don't care if we fail to remove the trust rule if - // for some reason the certificate became untrusted. - // The delete command will fail if the certificate is - // trusted. - } - RemoveCertificateFromKeyChain(MacOSSystemKeyChain, certificate); - } - else - { - diagnostics?.Debug("The certificate was not trusted."); - } - } - } + [Event(18, Level = EventLevel.Verbose)] + public void CreateDevelopmentCertificateEnd() => WriteEvent(18, "Finished generating HTTPS development certificate."); - private static void RemoveCertificateTrustRule(X509Certificate2 certificate) - { - var certificatePath = Path.GetTempFileName(); - try - { - var certBytes = certificate.Export(X509ContentType.Cert); - File.WriteAllBytes(certificatePath, certBytes); - var processInfo = new ProcessStartInfo( - MacOSRemoveCertificateTrustCommandLine, - string.Format( - MacOSRemoveCertificateTrustCommandLineArgumentsFormat, - certificatePath - )); - using (var process = Process.Start(processInfo)) - { - process.WaitForExit(); - } - } - finally - { - try - { - if (File.Exists(certificatePath)) - { - File.Delete(certificatePath); - } - } - catch - { - // We don't care about failing to do clean-up on a temp file. - } - } - } + [Event(19, Level = EventLevel.Error)] + public void CreateDevelopmentCertificateError(string e) => WriteEvent(19, $"An error has occurred generating the certificate: {e}."); - private static void RemoveCertificateFromKeyChain(string keyChain, X509Certificate2 certificate) - { - var processInfo = new ProcessStartInfo( - MacOSDeleteCertificateCommandLine, - string.Format( - MacOSDeleteCertificateCommandLineArgumentsFormat, - certificate.Thumbprint.ToUpperInvariant(), - keyChain - )) - { - RedirectStandardOutput = true, - RedirectStandardError = true - }; - using (var process = Process.Start(processInfo)) - { - var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); - process.WaitForExit(); + [Event(20, Level = EventLevel.Verbose)] + public void SaveCertificateInStoreStart(string certificate, StoreName name, StoreLocation location) => WriteEvent(20, $"Saving certificate '{certificate}' to store {location}\\{name}."); - if (process.ExitCode != 0) - { - throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'. + [Event(21, Level = EventLevel.Verbose)] + public void SaveCertificateInStoreEnd() => WriteEvent(21, "Finished saving certificate to the store."); -{output}"); - } - } - } + [Event(22, Level = EventLevel.Error)] + public void SaveCertificateInStoreError(string e) => WriteEvent(22, $"An error has occurred saving the certificate: {e}."); - public DetailedEnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( - DateTimeOffset notBefore, - DateTimeOffset notAfter, - string path = null, - bool trust = false, - bool includePrivateKey = false, - string password = null, - string subject = LocalhostHttpsDistinguishedName, - bool isInteractive = true) - { - return EnsureValidCertificateExists(notBefore, notAfter, CertificatePurpose.HTTPS, path, trust, includePrivateKey, password, subject, isInteractive); - } - public DetailedEnsureCertificateResult EnsureValidCertificateExists( - DateTimeOffset notBefore, - DateTimeOffset notAfter, - CertificatePurpose purpose, - string path = null, - bool trust = false, - bool includePrivateKey = false, - string password = null, - string subjectOverride = null, - bool isInteractive = true) - { - if (purpose == CertificatePurpose.All) - { - throw new ArgumentException("The certificate must have a specific purpose."); - } + [Event(23, Level = EventLevel.Verbose)] + public void ExportCertificateStart(string certificate, string path, bool includePrivateKey) => + WriteEvent(23, $"Saving certificate '{certificate}' to {path} {(includePrivateKey ? "with" : "without")} private key."); - var result = new DetailedEnsureCertificateResult(); + [Event(24, Level = EventLevel.Verbose)] + public void NoPasswordForCertificate() => WriteEvent(24, "Exporting certificate with private key but no password"); - var certificates = ListCertificates(purpose, StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true, result.Diagnostics).Concat( - ListCertificates(purpose, StoreName.My, StoreLocation.LocalMachine, isValid: true, requireExportable: true, result.Diagnostics)); + [Event(25, Level = EventLevel.Verbose)] + public void CreateExportCertificateDirectory(string path) => WriteEvent(25, $"Creating directory {path}."); - var filteredCertificates = subjectOverride == null ? certificates : certificates.Where(c => c.Subject == subjectOverride); - if (subjectOverride != null) - { - var excludedCertificates = certificates.Except(filteredCertificates); - result.Diagnostics.Debug($"Filtering found certificates to those with a subject equal to '{subjectOverride}'"); - result.Diagnostics.Debug(result.Diagnostics.DescribeCertificates(filteredCertificates)); - result.Diagnostics.Debug($"Listing certificates excluded from consideration."); - result.Diagnostics.Debug(result.Diagnostics.DescribeCertificates(excludedCertificates)); - } - else - { - result.Diagnostics.Debug("Skipped filtering certificates by subject."); - } + [Event(26, Level = EventLevel.Error)] + public void ExportCertificateError(string ex) => WriteEvent(26, $"An error has ocurred while exporting the certificate: {ex}."); - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - foreach (var cert in filteredCertificates) - { - if (!CanAccessCertificateKeyAcrossPartitions(cert)) - { - if (!isInteractive) - { - // If the process is not interactive (first run experience) bail out. We will simply create a certificate - // in case there is none or report success during the first run experience. - break; - } - try - { - // The command we run handles making keys for all localhost certificates accessible across partitions. If it can not run the - // command safely (because there are other localhost certificates that were not created by asp.net core, it will throw. - MakeCertificateKeyAccessibleAcrossPartitions(cert); - break; - } - catch (Exception ex) - { - result.Diagnostics.Error("Failed to make certificate key accessible", ex); - result.ResultCode = EnsureCertificateResult.FailedToMakeKeyAccessible; - return result; - } - } - } - } - certificates = filteredCertificates; + [Event(27, Level = EventLevel.Verbose)] + public void WriteCertificateToDisk(string path) => WriteEvent(27, $"Writing the certificate to: {path}."); - result.ResultCode = EnsureCertificateResult.Succeeded; + [Event(28, Level = EventLevel.Error)] + public void WriteCertificateToDiskError(string ex) => WriteEvent(28, $"An error has ocurred while writing the certificate to disk: {ex}."); - X509Certificate2 certificate = null; - if (certificates.Any()) - { - result.Diagnostics.Debug("Found valid certificates present on the machine."); - result.Diagnostics.Debug(result.Diagnostics.DescribeCertificates(certificates)); - certificate = certificates.First(); - result.Diagnostics.Debug("Selected certificate"); - result.Diagnostics.Debug(result.Diagnostics.DescribeCertificates(certificate)); - result.ResultCode = EnsureCertificateResult.ValidCertificatePresent; - } - else - { - result.Diagnostics.Debug("No valid certificates present on this machine. Trying to create one."); - try - { - switch (purpose) - { - case CertificatePurpose.All: - throw new InvalidOperationException("The certificate must have a specific purpose."); - case CertificatePurpose.HTTPS: - certificate = CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter, subjectOverride, result.Diagnostics); - break; - default: - throw new InvalidOperationException("The certificate must have a purpose."); - } - } - catch (Exception e) - { - result.Diagnostics.Error("Error creating the certificate.", e); - result.ResultCode = EnsureCertificateResult.ErrorCreatingTheCertificate; - return result; - } - try - { - certificate = SaveCertificateInStore(certificate, StoreName.My, StoreLocation.CurrentUser, result.Diagnostics); - } - catch (Exception e) - { - result.Diagnostics.Error($"Error saving the certificate in the certificate store '{StoreLocation.CurrentUser}\\{StoreName.My}'.", e); - result.ResultCode = EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore; - return result; - } + [Event(29, Level = EventLevel.Verbose)] + public void TrustCertificateStart(string certificate) => WriteEvent(29, $"Trusting the certificate to: {certificate}."); - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && isInteractive) - { - MakeCertificateKeyAccessibleAcrossPartitions(certificate); - } + [Event(30, Level = EventLevel.Verbose)] + public void TrustCertificateEnd() => WriteEvent(30, $"Finished trusting the certificate."); - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && isInteractive) - { - MakeCertificateKeyAccessibleAcrossPartitions(certificate); - } - } - if (path != null) - { - result.Diagnostics.Debug("Trying to export the certificate."); - result.Diagnostics.Debug(result.Diagnostics.DescribeCertificates(certificate)); - try - { - ExportCertificate(certificate, path, includePrivateKey, password, result.Diagnostics); - } - catch (Exception e) - { - result.Diagnostics.Error("An error ocurred exporting the certificate.", e); - result.ResultCode = EnsureCertificateResult.ErrorExportingTheCertificate; - return result; - } - } + [Event(31, Level = EventLevel.Error)] + public void TrustCertificateError(string ex) => WriteEvent(31, $"An error has ocurred while trusting the certificate: {ex}."); - if ((RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) && trust) - { - try - { - result.Diagnostics.Debug("Trying to export the certificate."); - TrustCertificate(certificate, result.Diagnostics); - } - catch (UserCancelledTrustException) - { - result.Diagnostics.Error("The user cancelled trusting the certificate.", null); - result.ResultCode = EnsureCertificateResult.UserCancelledTrustStep; - return result; - } - catch (Exception e) - { - result.Diagnostics.Error("There was an error trusting the certificate.", e); - result.ResultCode = EnsureCertificateResult.FailedToTrustTheCertificate; - return result; - } - } - return result; - } + [Event(32, Level = EventLevel.Verbose)] + public void MacOSTrustCommandStart(string command) => WriteEvent(32, $"Running the trust command {command}."); - private void MakeCertificateKeyAccessibleAcrossPartitions(X509Certificate2 certificate) - { - if (OtherNonAspNetCoreHttpsCertificatesPresent()) - { - throw new InvalidOperationException("Unable to make HTTPS certificate key trusted across security partitions."); - } - using (var process = Process.Start(MacOSSetPartitionKeyPermissionsCommandLine, MacOSSetPartitionKeyPermissionsCommandLineArguments)) - { - process.WaitForExit(); - if (process.ExitCode != 0) - { - throw new InvalidOperationException("Error making the key accessible across partitions."); - } - } + [Event(33, Level = EventLevel.Verbose)] + public void MacOSTrustCommandEnd() => WriteEvent(33, $"Finished running the trust command."); - var certificateSentinelPath = GetCertificateSentinelPath(certificate); - File.WriteAllText(certificateSentinelPath, "true"); - } + [Event(34, Level = EventLevel.Verbose)] + public void MacOSTrustCommandError(int exitCode) => WriteEvent(34, $"An error has ocurred while running the trust command: {exitCode}."); - private static string GetCertificateSentinelPath(X509Certificate2 certificate) => - Path.Combine(Environment.GetEnvironmentVariable("HOME"), ".dotnet", $"certificate.{certificate.GetCertHashString(HashAlgorithmName.SHA256)}.sentinel"); - private bool OtherNonAspNetCoreHttpsCertificatesPresent() - { - var certificates = new List(); - try - { - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) - { - store.Open(OpenFlags.ReadOnly); - certificates.AddRange(store.Certificates.OfType()); - IEnumerable matchingCertificates = certificates; - // Ensure the certificate hasn't expired, has a private key and its exportable - // (for container/unix scenarios). - var now = DateTimeOffset.Now; - matchingCertificates = matchingCertificates - .Where(c => c.NotBefore <= now && - now <= c.NotAfter && c.Subject == LocalhostHttpsDistinguishedName); + [Event(35, Level = EventLevel.Verbose)] + public void MacOSRemoveCertificateTrustRuleStart(string certificate) => WriteEvent(35, $"Running the remove trust command for {certificate}."); - // We need to enumerate the certificates early to prevent dispoisng issues. - matchingCertificates = matchingCertificates.ToList(); + [Event(36, Level = EventLevel.Verbose)] + public void MacOSRemoveCertificateTrustRuleEnd() => WriteEvent(36, $"Finished running the remove trust command."); - var certificatesToDispose = certificates.Except(matchingCertificates); - DisposeCertificates(certificatesToDispose); + [Event(37, Level = EventLevel.Verbose)] + public void MacOSRemoveCertificateTrustRuleError(int exitCode) => WriteEvent(37, $"An error has ocurred while running the remove trust command: {exitCode}."); - store.Close(); + [Event(38, Level = EventLevel.Verbose)] + public void MacOSCertificateUntrusted(string certificate) => WriteEvent(38, $"The certificate is not trusted: {certificate}."); - return matchingCertificates.All(c => !HasOid(c, AspNetHttpsOid)); - } - } - catch - { - DisposeCertificates(certificates); - certificates.Clear(); - return true; - } - bool HasOid(X509Certificate2 certificate, string oid) => - certificate.Extensions.OfType() - .Any(e => string.Equals(oid, e.Oid.Value, StringComparison.Ordinal)); - } + [Event(39, Level = EventLevel.Verbose)] + public void MacOSRemoveCertificateFromKeyChainStart(string keyChain, string certificate) => WriteEvent(39, $"Removing the certificate from the keychain {keyChain} {certificate}."); - private bool CanAccessCertificateKeyAcrossPartitions(X509Certificate2 certificate) - { - var certificateSentinelPath = GetCertificateSentinelPath(certificate); - return File.Exists(certificateSentinelPath); - } + [Event(40, Level = EventLevel.Verbose)] + public void MacOSRemoveCertificateFromKeyChainEnd() => WriteEvent(40, $"Finished removing the certificate from the keychain."); - private class UserCancelledTrustException : Exception - { - } + [Event(41, Level = EventLevel.Verbose)] + public void MacOSRemoveCertificateFromKeyChainError(int exitCode) => WriteEvent(41, $"An error has ocurred while running the remove trust command: {exitCode}."); - private enum RemoveLocations - { - Undefined, - Local, - Trusted, - All - } - internal class DetailedEnsureCertificateResult - { - public EnsureCertificateResult ResultCode { get; set; } - public DiagnosticInformation Diagnostics { get; set; } = new DiagnosticInformation(); - } + [Event(42, Level = EventLevel.Verbose)] + public void RemoveCertificateFromUserStoreStart(string certificate) => WriteEvent(42, $"Removing the certificate from the user store {certificate}."); - internal class DiagnosticInformation - { - public IList Messages { get; } = new List(); + [Event(43, Level = EventLevel.Verbose)] + public void RemoveCertificateFromUserStoreEnd() => WriteEvent(43, $"Finished removing the certificate from the user store."); - public IList Exceptions { get; } = new List(); + [Event(44, Level = EventLevel.Error)] + public void RemoveCertificateFromUserStoreError(string ex) => WriteEvent(44, $"An error has ocurred while removing the certificate from the user store: {ex}."); - internal void Debug(params string[] messages) - { - foreach (var message in messages) - { - Messages.Add(message); - } - } - internal string[] DescribeCertificates(params X509Certificate2[] certificates) - { - return DescribeCertificates(certificates.AsEnumerable()); - } + [Event(45, Level = EventLevel.Verbose)] + public void WindowsAddCertificateToRootStore() => WriteEvent(45, $"Adding certificate to the trusted root certification authority store."); - internal string[] DescribeCertificates(IEnumerable certificates) - { - var result = new List(); - result.Add($"'{certificates.Count()}' found matching the criteria."); - result.Add($"SUBJECT - THUMBPRINT - NOT BEFORE - EXPIRES - HAS PRIVATE KEY"); - foreach (var certificate in certificates) - { - result.Add(DescribeCertificate(certificate)); - } + [Event(46, Level = EventLevel.Verbose)] + public void WindowsCertificateAlreadyTrusted() => WriteEvent(46, $"The certificate is already trusted"); - return result.ToArray(); - } + [Event(47, Level = EventLevel.Verbose)] + public void WindowsCertificateTrustCanceled() => WriteEvent(47, $"Trusting the certificate was cancelled by the user."); - private static string DescribeCertificate(X509Certificate2 certificate) => - $"{certificate.Subject} - {certificate.Thumbprint} - {certificate.NotBefore} - {certificate.NotAfter} - {certificate.HasPrivateKey}"; + [Event(48, Level = EventLevel.Verbose)] + public void WindowsRemoveCertificateFromRootStoreStart() => WriteEvent(48, $"Removing the certificate from the trusted root certification authority store."); - internal void Error(string preamble, Exception e) - { - Messages.Add(preamble); - if (Exceptions.Count > 0 && Exceptions[Exceptions.Count - 1] == e) - { - return; - } + [Event(49, Level = EventLevel.Verbose)] + public void WindowsRemoveCertificateFromRootStoreEnd() => WriteEvent(49, $"Finished removing the certificate from the trusted root certification authority store."); - var ex = e; - while (ex != null) - { - Messages.Add("Exception message: " + ex.Message); - ex = ex.InnerException; - } + [Event(50, Level = EventLevel.Verbose)] + public void WindowsRemoveCertificateFromRootStoreNotFound() => WriteEvent(50, "The certificate was not trusted."); + + [Event(50, Level = EventLevel.Verbose)] + public void CorrectCertificateStateStart(string certificate) => WriteEvent(51, $"Correcting the the certificate state for '{certificate}'"); + [Event(51, Level = EventLevel.Verbose)] + public void CorrectCertificateStateEnd() => WriteEvent(52, "Finished correcting the certificate state"); + + [Event(52, Level = EventLevel.Error)] + public void CorrectCertificateStateError(string error) => WriteEvent(53, $"An error has ocurred while correcting the certificate state: {error}."); + + [Event(54, Level = EventLevel.Verbose)] + internal void MacOSAddCertificateToKeyChainStart(string keychain, string certificate) => WriteEvent(54, $"Importing the certificate {certificate} to the keychain '{keychain}'"); + + [Event(55, Level = EventLevel.Verbose)] + internal void MacOSAddCertificateToKeyChainEnd() => WriteEvent(55, "Finished importing the certificate to the key chain."); + + [Event(56, Level = EventLevel.Error)] + internal void MacOSAddCertificateToKeyChainError(int exitCode) => WriteEvent(56, $"An error has ocurred while importing the certificate to the keychain: {exitCode}."); + } + + internal class UserCancelledTrustException : Exception + { + } + + internal struct CheckCertificateStateResult + { + public bool Result { get; } + public string Message { get; } + + public CheckCertificateStateResult(bool result, string message) + { + Result = result; + Message = message; } } + + internal enum RemoveLocations + { + Undefined, + Local, + Trusted, + All + } } } diff --git a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs new file mode 100644 index 000000000000..b11c9ac9149a --- /dev/null +++ b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs @@ -0,0 +1,306 @@ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; + +namespace Microsoft.AspNetCore.Certificates.Generation +{ + internal class MacOSCertificateManager : CertificateManager + { + private const string CertificateSubjectRegex = "CN=(.*[^,]+).*"; + private static readonly string MacOSUserKeyChain = Environment.GetEnvironmentVariable("HOME") + "/Library/Keychains/login.keychain-db"; + private const string MacOSSystemKeyChain = "/Library/Keychains/System.keychain"; + private const string MacOSFindCertificateCommandLine = "security"; + private static readonly string MacOSFindCertificateCommandLineArgumentsFormat = "find-certificate -c {0} -a -Z -p " + MacOSSystemKeyChain; + private const string MacOSFindCertificateOutputRegex = "SHA-1 hash: ([0-9A-Z]+)"; + private const string MacOSRemoveCertificateTrustCommandLine = "sudo"; + private const string MacOSRemoveCertificateTrustCommandLineArgumentsFormat = "security remove-trusted-cert -d {0}"; + private const string MacOSDeleteCertificateCommandLine = "sudo"; + private const string MacOSDeleteCertificateCommandLineArgumentsFormat = "security delete-certificate -Z {0} {1}"; + private const string MacOSTrustCertificateCommandLine = "sudo"; + private static readonly string MacOSTrustCertificateCommandLineArguments = "security add-trusted-cert -d -r trustRoot -k " + MacOSSystemKeyChain + " "; + + private const string MacOSAddCertificateToKeyChainCommandLine = "security"; + private static readonly string MacOSAddCertificateToKeyChainCommandLineArgumentsFormat = "import {0} -k " + MacOSUserKeyChain + " -t cert -f pkcs12 -P {1} -A"; + + public const string InvalidCertificateState = "The ASP.NET Core developer certificate is in an invalid state. " + + "To fix this issue, run the following commands 'dotnet dev-certs https --clean' and 'dotnet dev-certs https' to remove all existing ASP.NET Core development certificates " + + "and create a new untrusted developer certificate. " + + "On macOS or Windows, use 'dotnet dev-certs https --trust' to trust the new certificate."; + + public const string KeyNotAccessibleWithoutUserInteraction = + "The application is trying to access the ASP.NET Core developer certificate key. " + + "A prompt might appear to ask for permission to access the key. " + + "When that happens, select 'Always Allow' to grant 'dotnet' access to the certificate key in the future."; + + private static readonly TimeSpan MaxRegexTimeout = TimeSpan.FromMinutes(1); + + public MacOSCertificateManager() + { + } + + internal MacOSCertificateManager(string subject, int version) + : base(subject, version) + { + } + + protected override void TrustCertificateCore(X509Certificate2 publicCertificate) + { + var tmpFile = Path.GetTempFileName(); + try + { + ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null); + Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {MacOSTrustCertificateCommandLineArguments}{tmpFile}"); + using (var process = Process.Start(MacOSTrustCertificateCommandLine, MacOSTrustCertificateCommandLineArguments + tmpFile)) + { + process.WaitForExit(); + if (process.ExitCode != 0) + { + Log.MacOSTrustCommandError(process.ExitCode); + throw new InvalidOperationException("There was an error trusting the certificate."); + } + } + Log.MacOSTrustCommandEnd(); + } + finally + { + try + { + if (File.Exists(tmpFile)) + { + File.Delete(tmpFile); + } + } + catch + { + // We don't care if we can't delete the temp file. + } + } + } + + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive) + { + var sentinelPath = Path.Combine(Environment.GetEnvironmentVariable("HOME"), ".dotnet", $"certificates.{candidate.GetCertHashString(HashAlgorithmName.SHA256)}.sentinel"); + if (!interactive && !File.Exists(sentinelPath)) + { + return new CheckCertificateStateResult(false, KeyNotAccessibleWithoutUserInteraction); + } + + // Tries to use the certificate key to validate it can't access it + try + { + var rsa = candidate.GetRSAPrivateKey(); + if (rsa == null) + { + return new CheckCertificateStateResult(false, InvalidCertificateState); + } + + // Encrypting a random value is the ultimate test for a key validity. + // Windows and Mac OS both return HasPrivateKey = true if there is (or there has been) a private key associated + // with the certificate at some point. + var value = new byte[32]; + RandomNumberGenerator.Fill(value); + rsa.Decrypt(rsa.Encrypt(value, RSAEncryptionPadding.Pkcs1), RSAEncryptionPadding.Pkcs1); + + // If we were able to access the key, create a sentinel so that we don't have to show a prompt + // on every kestrel run. + if (Directory.Exists(Path.GetDirectoryName(sentinelPath)) && !File.Exists(sentinelPath)) + { + File.WriteAllText(sentinelPath, "true"); + } + + // Being able to encrypt and decrypt a payload is the strongest guarantee that the key is valid. + return new CheckCertificateStateResult(true, null); + } + catch (Exception) + { + return new CheckCertificateStateResult(false, InvalidCertificateState); + } + } + + + internal override void CorrectCertificateState(X509Certificate2 candidate) + { + var status = CheckCertificateState(candidate, true); + if (!status.Result) + { + throw new InvalidOperationException(InvalidCertificateState); + } + } + + + public override bool IsTrusted(X509Certificate2 certificate) + { + var subjectMatch = Regex.Match(certificate.Subject, CertificateSubjectRegex, RegexOptions.Singleline, MaxRegexTimeout); + if (!subjectMatch.Success) + { + throw new InvalidOperationException($"Can't determine the subject for the certificate with subject '{certificate.Subject}'."); + } + var subject = subjectMatch.Groups[1].Value; + using var checkTrustProcess = Process.Start(new ProcessStartInfo( + MacOSFindCertificateCommandLine, + string.Format(MacOSFindCertificateCommandLineArgumentsFormat, subject)) + { + RedirectStandardOutput = true + }); + var output = checkTrustProcess.StandardOutput.ReadToEnd(); + checkTrustProcess.WaitForExit(); + var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, MaxRegexTimeout); + var hashes = matches.OfType().Select(m => m.Groups[1].Value).ToList(); + return hashes.Any(h => string.Equals(h, certificate.Thumbprint, StringComparison.Ordinal)); + } + + protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) + { + if (IsTrusted(certificate)) // On OSX this check just ensures its on the system keychain + { + // A trusted certificate in OSX is installed into the system keychain and + // as a "trust rule" applied to it. + // To remove the certificate we first need to remove the "trust rule" and then + // remove the certificate from the keychain. + // We don't care if we fail to remove the trust rule if + // for some reason the certificate became untrusted. + // Trying to remove the certificate from the keychain will fail if the certificate is + // trusted. + try + { + RemoveCertificateTrustRule(certificate); + } + catch + { + } + + RemoveCertificateFromKeyChain(MacOSSystemKeyChain, certificate); + } + else + { + Log.MacOSCertificateUntrusted(GetDescription(certificate)); + } + } + + private static void RemoveCertificateTrustRule(X509Certificate2 certificate) + { + Log.MacOSRemoveCertificateTrustRuleStart(GetDescription(certificate)); + var certificatePath = Path.GetTempFileName(); + try + { + var certBytes = certificate.Export(X509ContentType.Cert); + File.WriteAllBytes(certificatePath, certBytes); + var processInfo = new ProcessStartInfo( + MacOSRemoveCertificateTrustCommandLine, + string.Format( + MacOSRemoveCertificateTrustCommandLineArgumentsFormat, + certificatePath + )); + using var process = Process.Start(processInfo); + process.WaitForExit(); + if (process.ExitCode != 0) + { + Log.MacOSRemoveCertificateTrustRuleError(process.ExitCode); + } + Log.MacOSRemoveCertificateTrustRuleEnd(); + } + finally + { + try + { + if (File.Exists(certificatePath)) + { + File.Delete(certificatePath); + } + } + catch + { + // We don't care about failing to do clean-up on a temp file. + } + } + } + + private static void RemoveCertificateFromKeyChain(string keyChain, X509Certificate2 certificate) + { + var processInfo = new ProcessStartInfo( + MacOSDeleteCertificateCommandLine, + string.Format( + MacOSDeleteCertificateCommandLineArgumentsFormat, + certificate.Thumbprint.ToUpperInvariant(), + keyChain + )) + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + Log.MacOSRemoveCertificateFromKeyChainStart(keyChain, GetDescription(certificate)); + using (var process = Process.Start(processInfo)) + { + var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + Log.MacOSRemoveCertificateFromKeyChainError(process.ExitCode); + throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'. + +{output}"); + } + } + + Log.MacOSRemoveCertificateFromKeyChainEnd(); + } + + // We don't have a good way of checking on the underlying implementation if ti is exportable, so just return true. + protected override bool IsExportable(X509Certificate2 c) => true; + + protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate) + { + // security import https.pfx -k $loginKeyChain -t cert -f pkcs12 -P password -A; + var passwordBytes = new byte[48]; + RandomNumberGenerator.Fill(passwordBytes.AsSpan()[0..35]); + var password = Convert.ToBase64String(passwordBytes, 0, 36); + var certBytes = certificate.Export(X509ContentType.Pfx, password); + var certificatePath = Path.GetTempFileName(); + File.WriteAllBytes(certificatePath, certBytes); + + var processInfo = new ProcessStartInfo( + MacOSAddCertificateToKeyChainCommandLine, + string.Format( + MacOSAddCertificateToKeyChainCommandLineArgumentsFormat, + certificatePath, + password + )) + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + Log.MacOSAddCertificateToKeyChainStart(MacOSUserKeyChain, GetDescription(certificate)); + using (var process = Process.Start(processInfo)) + { + var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + Log.MacOSAddCertificateToKeyChainError(process.ExitCode); + throw new InvalidOperationException($@"There was an error importing the certificate into the user key chain '{certificate.Thumbprint}'. + +{output}"); + } + } + + Log.MacOSAddCertificateToKeyChainEnd(); + + return certificate; + } + + protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) + { + return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false); + } + } +} diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs new file mode 100644 index 000000000000..a66cebc22841 --- /dev/null +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.AspNetCore.Certificates.Generation +{ + internal class UnixCertificateManager : CertificateManager + { + public UnixCertificateManager() + { + } + + internal UnixCertificateManager(string subject, int version) + : base(subject, version) + { + } + + public override bool IsTrusted(X509Certificate2 certificate) => false; + + protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate) + { + var export = certificate.Export(X509ContentType.Pkcs12, ""); + certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + Array.Clear(export, 0, export.Length); + + using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(certificate); + store.Close(); + }; + + return certificate; + } + + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive) + { + // Return true as we don't perform any check. + return new CheckCertificateStateResult(true, null); + } + + internal override void CorrectCertificateState(X509Certificate2 candidate) + { + // Do nothing since we don't have anything to check here. + } + + protected override bool IsExportable(X509Certificate2 c) => true; + + protected override void TrustCertificateCore(X509Certificate2 certificate) => + throw new InvalidOperationException("Trusting the certificate is not supported on linux"); + + protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) + { + // No-op here as is benign + } + + protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) + { + return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false); + } + } +} diff --git a/src/Shared/CertificateGeneration/WindowsCertificateManager.cs b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs new file mode 100644 index 000000000000..82fc96177827 --- /dev/null +++ b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.AspNetCore.Certificates.Generation +{ + internal class WindowsCertificateManager : CertificateManager + { + private const int UserCancelledErrorCode = 1223; + + public WindowsCertificateManager() + { + } + + // For testing purposes only + internal WindowsCertificateManager(string subject, int version) + : base(subject, version) + { + } + + protected override bool IsExportable(X509Certificate2 c) + { +#if XPLAT + // For the first run experience we don't need to know if the certificate can be exported. + return true; +#else + return (c.GetRSAPrivateKey() is RSACryptoServiceProvider rsaPrivateKey && + rsaPrivateKey.CspKeyContainerInfo.Exportable) || + (c.GetRSAPrivateKey() is RSACng cngPrivateKey && + cngPrivateKey.Key.ExportPolicy == CngExportPolicies.AllowExport); +#endif + } + + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive) + { + // Return true as we don't perform any check. + return new CheckCertificateStateResult(true, null); + } + + internal override void CorrectCertificateState(X509Certificate2 candidate) + { + // Do nothing since we don't have anything to check here. + } + + protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate) + { + // On non OSX systems we need to export the certificate and import it so that the transient + // key that we generated gets persisted. + var export = certificate.Export(X509ContentType.Pkcs12, ""); + certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + Array.Clear(export, 0, export.Length); + certificate.FriendlyName = AspNetHttpsOidFriendlyName; + + using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(certificate); + store.Close(); + }; + + return certificate; + } + + protected override void TrustCertificateCore(X509Certificate2 certificate) + { + var publicCertificate = new X509Certificate2(certificate.Export(X509ContentType.Cert)); + + publicCertificate.FriendlyName = certificate.FriendlyName; + + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + + store.Open(OpenFlags.ReadWrite); + var existing = store.Certificates.Find(X509FindType.FindByThumbprint, publicCertificate.Thumbprint, validOnly: false); + if (existing.Count > 0) + { + Log.WindowsCertificateAlreadyTrusted(); + DisposeCertificates(existing.OfType()); + return; + } + + try + { + Log.WindowsAddCertificateToRootStore(); + store.Add(publicCertificate); + store.Close(); + } + catch (CryptographicException exception) when (exception.HResult == UserCancelledErrorCode) + { + Log.WindowsCertificateTrustCanceled(); + throw new UserCancelledTrustException(); + } + } + + protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) + { + Log.WindowsRemoveCertificateFromRootStoreStart(); + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + + store.Open(OpenFlags.ReadWrite); + var matching = store.Certificates + .OfType() + .SingleOrDefault(c => c.SerialNumber == certificate.SerialNumber); + + if (matching != null) + { + store.Remove(matching); + } + else + { + Log.WindowsRemoveCertificateFromRootStoreNotFound(); + } + + store.Close(); + Log.WindowsRemoveCertificateFromRootStoreEnd(); + } + + public override bool IsTrusted(X509Certificate2 certificate) + { + return ListCertificates(StoreName.Root, StoreLocation.CurrentUser, isValid: true, requireExportable: false) + .Any(c => c.Thumbprint == certificate.Thumbprint); + } + + protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) + { + return ListCertificates(storeName, storeLocation, isValid: false); + } + } +} diff --git a/src/Tools/FirstRunCertGenerator/src/CertificateGenerator.cs b/src/Tools/FirstRunCertGenerator/src/CertificateGenerator.cs index d3a94baf2ec6..a8290cfdbcfd 100644 --- a/src/Tools/FirstRunCertGenerator/src/CertificateGenerator.cs +++ b/src/Tools/FirstRunCertGenerator/src/CertificateGenerator.cs @@ -7,7 +7,7 @@ public static class CertificateGenerator { public static void GenerateAspNetHttpsCertificate() { - var manager = new CertificateManager(); + var manager = CertificateManager.Instance; var now = DateTimeOffset.Now; manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), isInteractive: false); } diff --git a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs index 6114e56a44e2..f0c53bfb5c0f 100644 --- a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs +++ b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs @@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests { public class CertificateManagerTests : IClassFixture { - private CertFixture _fixture; + private readonly CertFixture _fixture; private CertificateManager _manager => _fixture.Manager; public CertificateManagerTests(ITestOutputHelper output, CertFixture fixture) @@ -39,20 +39,20 @@ public void EnsureCreateHttpsCertificate_CreatesACertificate_WhenThereAreNoHttps const string CertificateName = nameof(EnsureCreateHttpsCertificate_CreatesACertificate_WhenThereAreNoHttpsCertificates) + ".cer"; - // Act - DateTimeOffset now = DateTimeOffset.UtcNow; + // Act + var now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, subject: TestCertificateSubject, isInteractive: false); + var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, isInteractive: false); // Assert - Assert.Equal(EnsureCertificateResult.Succeeded, result.ResultCode); + Assert.Equal(EnsureCertificateResult.Succeeded, result); Assert.True(File.Exists(CertificateName)); var exportedCertificate = new X509Certificate2(File.ReadAllBytes(CertificateName)); Assert.NotNull(exportedCertificate); Assert.False(exportedCertificate.HasPrivateKey); - var httpsCertificates = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false); + var httpsCertificates = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false); var httpsCertificate = Assert.Single(httpsCertificates, c => c.Subject == TestCertificateSubject); Assert.True(httpsCertificate.HasPrivateKey); Assert.Equal(TestCertificateSubject, httpsCertificate.Subject); @@ -94,7 +94,7 @@ public void EnsureCreateHttpsCertificate_CreatesACertificate_WhenThereAreNoHttps httpsCertificate.Extensions.OfType(), e => e.Critical == false && e.Oid.Value == "1.3.6.1.4.1.311.84.1.1" && - e.RawData[0] == CertificateManager.AspNetHttpsCertificateVersion); + e.RawData[0] == _manager.AspNetHttpsCertificateVersion); Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString()); @@ -102,12 +102,12 @@ public void EnsureCreateHttpsCertificate_CreatesACertificate_WhenThereAreNoHttps catch (Exception e) { Output.WriteLine(e.Message); - ListCertificates(Output); + ListCertificates(); throw; } } - private void ListCertificates(ITestOutputHelper output) + private void ListCertificates() { using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) { @@ -133,17 +133,18 @@ public void EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAn _fixture.CleanupCertificates(); - DateTimeOffset now = DateTimeOffset.UtcNow; + var now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false); - - var httpsCertificate = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject); + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); + var httpsCertificate = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject); // Act - var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, subject: TestCertificateSubject, isInteractive: false); + var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, isInteractive: false); // Assert - Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result.ResultCode); + Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result); Assert.True(File.Exists(CertificateName)); var exportedCertificate = new X509Certificate2(File.ReadAllBytes(CertificateName), certificatePassword); @@ -159,13 +160,15 @@ public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateIfVersionIsInc { _fixture.CleanupCertificates(); - DateTimeOffset now = DateTimeOffset.UtcNow; + var now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false); + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); - CertificateManager.AspNetHttpsCertificateVersion = 2; + _manager.AspNetHttpsCertificateVersion = 2; - var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.Empty(httpsCertificateList); } @@ -174,14 +177,16 @@ public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateForEmptyVersio { _fixture.CleanupCertificates(); - DateTimeOffset now = DateTimeOffset.UtcNow; + var now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - CertificateManager.AspNetHttpsCertificateVersion = 0; - _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false); + _manager.AspNetHttpsCertificateVersion = 0; + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); - CertificateManager.AspNetHttpsCertificateVersion = 1; + _manager.AspNetHttpsCertificateVersion = 1; - var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.Empty(httpsCertificateList); } @@ -191,12 +196,14 @@ public void EnsureCreateHttpsCertificate_ReturnsValidIfVersionIsZero() { _fixture.CleanupCertificates(); - DateTimeOffset now = DateTimeOffset.UtcNow; + var now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - CertificateManager.AspNetHttpsCertificateVersion = 0; - _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false); + _manager.AspNetHttpsCertificateVersion = 0; + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); - var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.NotEmpty(httpsCertificateList); } @@ -206,45 +213,17 @@ public void EnsureCreateHttpsCertificate_ReturnValidIfCertIsNewer() { _fixture.CleanupCertificates(); - DateTimeOffset now = DateTimeOffset.UtcNow; + var now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - CertificateManager.AspNetHttpsCertificateVersion = 2; - _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject, isInteractive: false); + _manager.AspNetHttpsCertificateVersion = 2; + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); - CertificateManager.AspNetHttpsCertificateVersion = 1; - var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + _manager.AspNetHttpsCertificateVersion = 1; + var httpsCertificateList = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.NotEmpty(httpsCertificateList); } - - [Fact(Skip = "Requires user interaction")] - public void EnsureAspNetCoreHttpsDevelopmentCertificate_ReturnsCorrectResult_WhenUserCancelsTrustStepOnWindows() - { - _fixture.CleanupCertificates(); - - DateTimeOffset now = DateTimeOffset.UtcNow; - now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - var trustFailed = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject, isInteractive: false); - - Assert.Equal(EnsureCertificateResult.UserCancelledTrustStep, trustFailed.ResultCode); - } - - [Fact(Skip = "Requires user interaction")] - public void EnsureAspNetCoreHttpsDevelopmentCertificate_CanRemoveCertificates() - { - _fixture.CleanupCertificates(); - - DateTimeOffset now = DateTimeOffset.UtcNow; - now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: true, subject: TestCertificateSubject, isInteractive: false); - - _manager.CleanupHttpsCertificates(TestCertificateSubject); - - Assert.Empty(CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false).Where(c => c.Subject == TestCertificateSubject)); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Empty(CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.Root, StoreLocation.CurrentUser, isValid: false).Where(c => c.Subject == TestCertificateSubject)); - } - } } public class CertFixture : IDisposable @@ -253,24 +232,25 @@ public class CertFixture : IDisposable public CertFixture() { - Manager = new CertificateManager(); + Manager = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new WindowsCertificateManager(TestCertificateSubject, 1) : + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? + new MacOSCertificateManager(TestCertificateSubject, 1) as CertificateManager : + new UnixCertificateManager(TestCertificateSubject, 1); CleanupCertificates(); } internal CertificateManager Manager { get; set; } - public void Dispose() - { - CleanupCertificates(); - } + public void Dispose() => CleanupCertificates(); internal void CleanupCertificates() { - Manager.RemoveAllCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, TestCertificateSubject); + Manager.RemoveAllCertificates(StoreName.My, StoreLocation.CurrentUser); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Manager.RemoveAllCertificates(CertificatePurpose.HTTPS, StoreName.Root, StoreLocation.CurrentUser, TestCertificateSubject); + Manager.RemoveAllCertificates(StoreName.Root, StoreLocation.CurrentUser); } } } diff --git a/src/Tools/Tools.sln b/src/Tools/Tools.sln index f25c3fc98212..a058251673b0 100644 --- a/src/Tools/Tools.sln +++ b/src/Tools/Tools.sln @@ -35,6 +35,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GetDocumentInsider", "GetDo EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.ApiDescription.Client.Tests", "Extensions.ApiDescription.Client\test\Microsoft.Extensions.ApiDescription.Client.Tests.csproj", "{2C62584B-EC31-40C8-819B-E46334645AE5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.DeveloperCertificates.XPlat.Tests", "FirstRunCertGenerator\test\Microsoft.AspNetCore.DeveloperCertificates.XPlat.Tests.csproj", "{88712247-88C1-442B-874D-69D4B302EEBF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.DeveloperCertificates.XPlat", "FirstRunCertGenerator\src\Microsoft.AspNetCore.DeveloperCertificates.XPlat.csproj", "{28E3181D-FAAA-483C-A924-3AF8D3F274A3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -93,6 +97,14 @@ Global {2C62584B-EC31-40C8-819B-E46334645AE5}.Debug|Any CPU.Build.0 = Debug|Any CPU {2C62584B-EC31-40C8-819B-E46334645AE5}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C62584B-EC31-40C8-819B-E46334645AE5}.Release|Any CPU.Build.0 = Release|Any CPU + {88712247-88C1-442B-874D-69D4B302EEBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {88712247-88C1-442B-874D-69D4B302EEBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88712247-88C1-442B-874D-69D4B302EEBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {88712247-88C1-442B-874D-69D4B302EEBF}.Release|Any CPU.Build.0 = Release|Any CPU + {28E3181D-FAAA-483C-A924-3AF8D3F274A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28E3181D-FAAA-483C-A924-3AF8D3F274A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28E3181D-FAAA-483C-A924-3AF8D3F274A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28E3181D-FAAA-483C-A924-3AF8D3F274A3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -112,6 +124,8 @@ Global {233119FC-E4C1-421C-89AE-1A445C5A947F} = {003EA860-5DFC-40AE-87C0-9D21BB2C68D7} {EB63AECB-B898-475D-90F7-FE61F9C1CCC6} = {003EA860-5DFC-40AE-87C0-9D21BB2C68D7} {2C62584B-EC31-40C8-819B-E46334645AE5} = {2C485EAF-E4DE-4D14-8AE1-330641E17D44} + {88712247-88C1-442B-874D-69D4B302EEBF} = {2C485EAF-E4DE-4D14-8AE1-330641E17D44} + {28E3181D-FAAA-483C-A924-3AF8D3F274A3} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EC668D8E-97B9-4758-9E5C-2E5DD6B9137B} diff --git a/src/Tools/dotnet-dev-certs/src/Program.cs b/src/Tools/dotnet-dev-certs/src/Program.cs index 0afc7057357c..c43a438a8bb4 100644 --- a/src/Tools/dotnet-dev-certs/src/Program.cs +++ b/src/Tools/dotnet-dev-certs/src/Program.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -24,12 +25,22 @@ internal class Program private const int ErrorNoValidCertificateFound = 6; private const int ErrorCertificateNotTrusted = 7; private const int ErrorCleaningUpCertificates = 8; - private const int ErrorMacOsCertificateKeyCouldNotBeAccessible = 9; + private const int InvalidCertificateState = 9; public static readonly TimeSpan HttpsCertificateValidity = TimeSpan.FromDays(365); public static int Main(string[] args) { + if (args.Contains("--debug")) + { + // This is so that we can attach `dotnet trace` for debug purposes. + Console.WriteLine("Press any key to continue..."); + _ = Console.ReadKey(); + var newArgs = new List(args); + newArgs.Remove("--debug"); + args = newArgs.ToArray(); + } + try { var app = new CommandLineApplication @@ -120,7 +131,7 @@ public static int Main(string[] args) private static int CleanHttpsCertificates(IReporter reporter) { - var manager = new CertificateManager(); + var manager = CertificateManager.Instance; try { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -138,7 +149,7 @@ private static int CleanHttpsCertificates(IReporter reporter) reporter.Output("HTTPS development certificates successfully removed from the machine."); return Success; } - catch(Exception e) + catch (Exception e) { reporter.Error("There was an error trying to clean HTTPS development certificates on this machine."); reporter.Error(e.Message); @@ -150,8 +161,8 @@ private static int CleanHttpsCertificates(IReporter reporter) private static int CheckHttpsCertificate(CommandOption trust, IReporter reporter) { var now = DateTimeOffset.Now; - var certificateManager = new CertificateManager(); - var certificates = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + var certificateManager = CertificateManager.Instance; + var certificates = certificateManager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); if (certificates.Count == 0) { reporter.Output("No valid certificate found."); @@ -159,21 +170,25 @@ private static int CheckHttpsCertificate(CommandOption trust, IReporter reporter } else { - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && certificateManager.HasValidCertificateWithInnaccessibleKeyAcrossPartitions()) - { - reporter.Warn($"A valid HTTPS certificate was found but it may not be accessible across security partitions. Run dotnet dev-certs https to ensure it will be accessible during development."); - return ErrorMacOsCertificateKeyCouldNotBeAccessible; - } - else + foreach (var certificate in certificates) { - reporter.Verbose("A valid certificate was found."); + // We never want check to require interaction. + // When IDEs run dotnet dev-certs https after calling --check, we will try to access the key and + // that will trigger a prompt if necessary. + var status = certificateManager.CheckCertificateState(certificate, interactive: false); + if (!status.Result) + { + reporter.Warn(status.Message); + return InvalidCertificateState; + } } + reporter.Verbose("A valid certificate was found."); } if (trust != null && trust.HasValue()) { var store = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? StoreName.My : StoreName.Root; - var trustedCertificates = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, store, StoreLocation.CurrentUser, isValid: true); + var trustedCertificates = certificateManager.ListCertificates(store, StoreLocation.CurrentUser, isValid: true); if (!certificates.Any(c => certificateManager.IsTrusted(c))) { reporter.Output($@"The following certificates were found, but none of them is trusted: @@ -192,20 +207,24 @@ private static int CheckHttpsCertificate(CommandOption trust, IReporter reporter private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOption password, CommandOption trust, IReporter reporter) { var now = DateTimeOffset.Now; - var manager = new CertificateManager(); + var manager = CertificateManager.Instance; - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && manager.HasValidCertificateWithInnaccessibleKeyAcrossPartitions() || manager.GetHttpsCertificates().Count == 0) + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - reporter.Warn($"A valid HTTPS certificate with a key accessible across security partitions was not found. The following command will run to fix it:" + Environment.NewLine + - "'sudo security set-key-partition-list -D localhost -S unsigned:,teamid:UBF8T346G9'" + Environment.NewLine + - "This command will make the certificate key accessible across security partitions and might prompt you for your password. For more information see: https://aka.ms/aspnetcore/2.1/troubleshootcertissues"); - } + var certificates = manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, exportPath.HasValue()); + foreach (var certificate in certificates) + { + var status = manager.CheckCertificateState(certificate, interactive: true); + if (!status.Result) + { + reporter.Warn("One or more certificates might be in an invalid state. We will try to access the certificate key " + + "for each certificate and as a result you might be prompted one or more times to enter " + + "your password to access the user keychain. " + + "When that happens, select 'Always Allow' to grant 'dotnet' access to the certificate key in the future."); + } - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && manager.HasValidCertificateWithInnaccessibleKeyAcrossPartitions() || manager.GetHttpsCertificates().Count == 0) - { - reporter.Warn($"A valid HTTPS certificate with a key accessible across security partitions was not found. The following command will run to fix it:" + Environment.NewLine + - "'sudo security set-key-partition-list -D localhost -S unsigned:,teamid:UBF8T346G9'" + Environment.NewLine + - "This command will make the certificate key accessible across security partitions and might prompt you for your password. For more information see: https://aka.ms/aspnetcore/3.1/troubleshootcertissues"); + break; + } } if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && trust?.HasValue() == true) @@ -231,9 +250,7 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio password.HasValue(), password.Value()); - reporter.Verbose(string.Join(Environment.NewLine, result.Diagnostics.Messages)); - - switch (result.ResultCode) + switch (result) { case EnsureCertificateResult.Succeeded: reporter.Output("The HTTPS developer certificate was generated successfully.");