From 3ceca46c5bebfb39f472bb4b2998b1a3e4600e17 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 31 Oct 2019 21:50:26 +0100 Subject: [PATCH] [Platform] Provide a better error message when the developer certificate can't be used (#16659) Improves the error message Kestrel gives when the developer certificate key is not available for some reason. --- src/Servers/Kestrel/Core/src/CoreStrings.resx | 3 ++ .../Kestrel/Core/src/KestrelServerOptions.cs | 3 +- .../Middleware/HttpsConnectionMiddleware.cs | 19 +++++++++- .../InMemory.FunctionalTests/HttpsTests.cs | 31 +++++++++++++++- .../CertificateManager.cs | 35 +++++++++++++++++-- .../test/CertificateManagerTests.cs | 30 ++++++++-------- src/Tools/dotnet-dev-certs/src/Program.cs | 4 +-- 7 files changed, 102 insertions(+), 23 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/CoreStrings.resx b/src/Servers/Kestrel/Core/src/CoreStrings.resx index 0f49dedc81..cb3245b4fe 100644 --- a/src/Servers/Kestrel/Core/src/CoreStrings.resx +++ b/src/Servers/Kestrel/Core/src/CoreStrings.resx @@ -617,4 +617,7 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l A new stream was refused because this connection has too many streams that haven't finished processing. This may happen if many streams are aborted but not yet cleaned up. + + 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. + diff --git a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs index 7ac2acc27e..c383945c98 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -142,8 +142,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core var logger = ApplicationServices.GetRequiredService>(); try { - var certificateManager = new CertificateManager(); - DefaultCertificate = certificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true) + DefaultCertificate = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true) .FirstOrDefault(); if (DefaultCertificate != null) diff --git a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs index 4dce5d0348..6693b4c06b 100644 --- a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs +++ b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs @@ -11,6 +11,7 @@ using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Certificates.Generation; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Http.Features; @@ -208,12 +209,28 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https.Internal await sslStream.DisposeAsync(); return; } - catch (Exception ex) when (ex is IOException || ex is AuthenticationException) + catch (IOException ex) { _logger?.LogDebug(1, ex, CoreStrings.AuthenticationFailed); await sslStream.DisposeAsync(); return; } + catch (AuthenticationException ex) + { + if (_serverCertificate == null || + !CertificateManager.IsHttpsDevelopmentCertificate(_serverCertificate) || + CertificateManager.CheckDeveloperCertificateKey(_serverCertificate)) + { + _logger?.LogDebug(1, ex, CoreStrings.AuthenticationFailed); + } + else + { + _logger?.LogError(2, ex, CoreStrings.BadDeveloperCertificateState); + } + + await sslStream.DisposeAsync(); + return; + } } feature.ApplicationProtocol = sslStream.NegotiatedApplicationProtocol.Protocol; diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs index 453c8f5385..ab9ec94d6b 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs @@ -364,7 +364,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests new TestServiceContext(LoggerFactory), listenOptions => { - listenOptions.UseHttps(TestResources.GetTestCertificate()); + listenOptions.UseHttps(TestResources.GetTestCertificate("no_extensions.pfx")); })) { using (var connection = server.CreateConnection()) @@ -383,6 +383,35 @@ namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests 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()); + })) + { + 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.ThrowsAsync(() => + sslStream.AuthenticateAsClientAsync("127.0.0.1", clientCertificates: null, + enabledSslProtocols: SslProtocols.Tls, + checkCertificateRevocation: false)); + } + } + + await loggerProvider.FilterLogger.LogTcs.Task.DefaultTimeout(); + Assert.Equal(2, 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 14d26abbcd..09824fb127 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -44,9 +44,13 @@ namespace Microsoft.AspNetCore.Certificates.Generation // Setting to 0 means we don't append the version byte, // which is what all machines currently have. - public int AspNetHttpsCertificateVersion { get; set; } = 1; + public static int AspNetHttpsCertificateVersion { get; set; } = 1; - public IList ListCertificates( + public static bool IsHttpsDevelopmentCertificate(X509Certificate2 certificate) => + certificate.Extensions.OfType() + .Any(e => string.Equals(AspNetHttpsOid, e.Oid.Value, StringComparison.Ordinal)); + + public static IList ListCertificates( CertificatePurpose purpose, StoreName storeName, StoreLocation location, @@ -228,6 +232,33 @@ namespace Microsoft.AspNetCore.Certificates.Generation return certificate; } + internal static bool CheckDeveloperCertificateKey(X509Certificate2 candidate) + { + // Tries to use the certificate key to validate it can't access it + try + { + var rsa = candidate.GetRSAPrivateKey(); + if (rsa == null) + { + return false; + } + + // 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); + + // Being able to encrypt and decrypt a payload is the strongest guarantee that the key is valid. + return true; + } + catch (Exception) + { + return false; + } + } + public X509Certificate2 CreateSelfSignedCertificate( X500DistinguishedName subject, IEnumerable extensions, diff --git a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs index 1be6c5cfe7..a0f103342a 100644 --- a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs +++ b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs @@ -52,7 +52,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests Assert.NotNull(exportedCertificate); Assert.False(exportedCertificate.HasPrivateKey); - var httpsCertificates = _manager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false); + var httpsCertificates = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, 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 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests httpsCertificate.Extensions.OfType(), e => e.Critical == false && e.Oid.Value == "1.3.6.1.4.1.311.84.1.1" && - e.RawData[0] == _manager.AspNetHttpsCertificateVersion); + e.RawData[0] == CertificateManager.AspNetHttpsCertificateVersion); Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString()); @@ -137,7 +137,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests 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); - var httpsCertificate = _manager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject); + var httpsCertificate = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, 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); @@ -164,9 +164,9 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests 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); - _manager.AspNetHttpsCertificateVersion = 2; + CertificateManager.AspNetHttpsCertificateVersion = 2; - var httpsCertificateList = _manager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.Empty(httpsCertificateList); } @@ -178,12 +178,12 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests DateTimeOffset now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - _manager.AspNetHttpsCertificateVersion = 0; + CertificateManager.AspNetHttpsCertificateVersion = 0; _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject); - _manager.AspNetHttpsCertificateVersion = 1; + CertificateManager.AspNetHttpsCertificateVersion = 1; - var httpsCertificateList = _manager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.Empty(httpsCertificateList); } @@ -195,10 +195,10 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests DateTimeOffset now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - _manager.AspNetHttpsCertificateVersion = 0; + CertificateManager.AspNetHttpsCertificateVersion = 0; _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject); - var httpsCertificateList = _manager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.NotEmpty(httpsCertificateList); } @@ -210,11 +210,11 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests DateTimeOffset now = DateTimeOffset.UtcNow; now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - _manager.AspNetHttpsCertificateVersion = 2; + CertificateManager.AspNetHttpsCertificateVersion = 2; _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject); - _manager.AspNetHttpsCertificateVersion = 1; - var httpsCertificateList = _manager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + CertificateManager.AspNetHttpsCertificateVersion = 1; + var httpsCertificateList = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); Assert.NotEmpty(httpsCertificateList); } @@ -241,10 +241,10 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests _manager.CleanupHttpsCertificates(TestCertificateSubject); - Assert.Empty(_manager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false).Where(c => c.Subject == 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(_manager.ListCertificates(CertificatePurpose.HTTPS, StoreName.Root, StoreLocation.CurrentUser, isValid: false).Where(c => c.Subject == TestCertificateSubject)); + Assert.Empty(CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.Root, StoreLocation.CurrentUser, isValid: false).Where(c => c.Subject == TestCertificateSubject)); } } } diff --git a/src/Tools/dotnet-dev-certs/src/Program.cs b/src/Tools/dotnet-dev-certs/src/Program.cs index 6ecb1912cd..2c58ff4947 100644 --- a/src/Tools/dotnet-dev-certs/src/Program.cs +++ b/src/Tools/dotnet-dev-certs/src/Program.cs @@ -150,7 +150,7 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools { var now = DateTimeOffset.Now; var certificateManager = new CertificateManager(); - var certificates = certificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + var certificates = CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); if (certificates.Count == 0) { reporter.Output("No valid certificate found."); @@ -164,7 +164,7 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools 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(CertificatePurpose.HTTPS, 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: