From 9ea2c50068c77978f0df7b3c58368167503dfa50 Mon Sep 17 00:00:00 2001 From: "Chris Ross (ASP.NET)" Date: Fri, 23 Mar 2018 10:23:17 -0700 Subject: [PATCH] Add SNI support #2357 --- build/dependencies.props | 58 ++--- samples/SampleApp/Startup.cs | 18 +- src/Kestrel.Core/CoreStrings.resx | 2 +- .../HttpsConnectionAdapterOptions.cs | 14 +- .../Internal/HttpsConnectionAdapter.cs | 52 ++++- .../KestrelConfigurationLoader.cs | 2 +- .../ListenOptionsHttpsExtensions.cs | 4 +- .../Properties/CoreStrings.Designer.cs | 8 +- .../HttpsConnectionAdapterTests.cs | 208 +++++++++++++++++- test/shared/TestResources.cs | 5 + 10 files changed, 323 insertions(+), 48 deletions(-) diff --git a/build/dependencies.props b/build/dependencies.props index bcef3e376..c3d5165f8 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -7,39 +7,39 @@ 0.10.13 2.1.0-preview2-15749 1.10.0 - 2.1.0-a-preview2-mpr-16443 - 2.1.0-a-preview2-mpr-16443 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-a-preview2-mpr-16443 - 2.1.0-a-preview2-mpr-16443 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 - 2.1.0-preview2-30478 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 + 2.1.0-preview2-30554 2.0.0 - 2.1.0-preview3-26331-01 - 2.1.0-preview2-30478 + 2.1.0-preview2-26403-06 + 2.1.0-preview2-30554 15.6.1 4.7.49 11.0.2 - 4.5.0-preview3-26331-02 - 4.5.0-preview3-26331-02 - 4.5.0-preview3-26331-02 - 4.5.0-preview3-26331-02 - 4.5.0-preview3-26331-02 - 4.5.0-preview3-26331-02 - 4.5.0-preview3-26331-02 + 4.5.0-preview2-26403-05 + 4.5.0-preview2-26403-05 + 4.5.0-preview2-26403-05 + 4.5.0-preview2-26403-05 + 4.5.0-preview2-26403-05 + 4.5.0-preview2-26403-05 + 4.5.0-preview2-26403-05 0.8.0 2.3.1 2.4.0-beta.1.build3945 diff --git a/samples/SampleApp/Startup.cs b/samples/SampleApp/Startup.cs index 02c66a20b..3a4f29a3f 100644 --- a/samples/SampleApp/Startup.cs +++ b/samples/SampleApp/Startup.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; using Microsoft.AspNetCore.Server.Kestrel.Transport.Abstractions.Internal; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -105,10 +106,23 @@ public static Task Main(string[] args) listenOptions.UseHttps(StoreName.My, "localhost", allowInvalid: true); }); + options.ListenAnyIP(basePort + 5, listenOptions => + { + listenOptions.UseHttps(httpsOptions => + { + var localhostCert = CertificateLoader.LoadFromStoreCert("localhost", "My", StoreLocation.CurrentUser, allowInvalid: true); + httpsOptions.ServerCertificateSelector = (features, name) => + { + // Here you would check the name, select an appropriate cert, and provide a fallback or fail for null names. + return localhostCert; + }; + }); + }); + options .Configure() - .Endpoint(IPAddress.Loopback, basePort + 5) - .LocalhostEndpoint(basePort + 6) + .Endpoint(IPAddress.Loopback, basePort + 6) + .LocalhostEndpoint(basePort + 7) .Load(); options diff --git a/src/Kestrel.Core/CoreStrings.resx b/src/Kestrel.Core/CoreStrings.resx index 5eb49c132..0ba93c921 100644 --- a/src/Kestrel.Core/CoreStrings.resx +++ b/src/Kestrel.Core/CoreStrings.resx @@ -477,7 +477,7 @@ Value must be a positive TimeSpan. - + The server certificate parameter is required. diff --git a/src/Kestrel.Core/HttpsConnectionAdapterOptions.cs b/src/Kestrel.Core/HttpsConnectionAdapterOptions.cs index 6ada70170..760c29cfc 100644 --- a/src/Kestrel.Core/HttpsConnectionAdapterOptions.cs +++ b/src/Kestrel.Core/HttpsConnectionAdapterOptions.cs @@ -6,6 +6,7 @@ using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Threading; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; namespace Microsoft.AspNetCore.Server.Kestrel.Https @@ -29,7 +30,7 @@ public HttpsConnectionAdapterOptions() /// /// - /// Specifies the server certificate used to authenticate HTTPS connections. + /// Specifies the server certificate used to authenticate HTTPS connections. This is ignored if ServerCertificateSelector is set. /// /// /// If the server certificate has an Extended Key Usage extension, the usages must include Server Authentication (OID 1.3.6.1.5.5.7.3.1). @@ -37,6 +38,17 @@ public HttpsConnectionAdapterOptions() /// public X509Certificate2 ServerCertificate { get; set; } + /// + /// + /// A callback that will be invoked to dynamically select a server certificate. This is higher priority than ServerCertificate. + /// If SNI is not avialable then the name parameter will be null. + /// + /// + /// If the server certificate has an Extended Key Usage extension, the usages must include Server Authentication (OID 1.3.6.1.5.5.7.3.1). + /// + /// + public Func ServerCertificateSelector { get; set; } + /// /// Specifies the client certificate requirements for a HTTPS connection. Defaults to . /// diff --git a/src/Kestrel.Core/Internal/HttpsConnectionAdapter.cs b/src/Kestrel.Core/Internal/HttpsConnectionAdapter.cs index c71aedfed..4107da511 100644 --- a/src/Kestrel.Core/Internal/HttpsConnectionAdapter.cs +++ b/src/Kestrel.Core/Internal/HttpsConnectionAdapter.cs @@ -22,6 +22,8 @@ public class HttpsConnectionAdapter : IConnectionAdapter private readonly HttpsConnectionAdapterOptions _options; private readonly X509Certificate2 _serverCertificate; + private readonly Func _serverCertificateSelector; + private readonly ILogger _logger; public HttpsConnectionAdapter(HttpsConnectionAdapterOptions options) @@ -36,15 +38,24 @@ public HttpsConnectionAdapter(HttpsConnectionAdapterOptions options, ILoggerFact throw new ArgumentNullException(nameof(options)); } - if (options.ServerCertificate == null) + // capture the certificate now so it can't be switched after validation + _serverCertificate = options.ServerCertificate; + _serverCertificateSelector = options.ServerCertificateSelector; + if (_serverCertificate == null && _serverCertificateSelector == null) { - throw new ArgumentException(CoreStrings.ServiceCertificateRequired, nameof(options)); + throw new ArgumentException(CoreStrings.ServerCertificateRequired, nameof(options)); } - // capture the certificate now so it can be switched after validation - _serverCertificate = options.ServerCertificate; - - EnsureCertificateIsAllowedForServerAuth(_serverCertificate); + // If a selector is provided then ignore the cert, it may be a default cert. + if (_serverCertificateSelector != null) + { + // SslStream doesn't allow both. + _serverCertificate = null; + } + else + { + EnsureCertificateIsAllowedForServerAuth(_serverCertificate); + } _options = options; _logger = loggerFactory?.CreateLogger(nameof(HttpsConnectionAdapter)); @@ -115,9 +126,26 @@ private async Task InnerOnConnectionAsync(ConnectionAdapterC try { #if NETCOREAPP2_1 + // Adapt to the SslStream signature + ServerCertificateSelectionCallback selector = null; + if (_serverCertificateSelector != null) + { + selector = (sender, name) => + { + context.Features.Set(sslStream); + var cert = _serverCertificateSelector(context.Features, name); + if (cert != null) + { + EnsureCertificateIsAllowedForServerAuth(cert); + } + return cert; + }; + } + var sslOptions = new SslServerAuthenticationOptions() { ServerCertificate = _serverCertificate, + ServerCertificateSelectionCallback = selector, ClientCertificateRequired = certificateRequired, EnabledSslProtocols = _options.SslProtocols, CertificateRevocationCheckMode = _options.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, @@ -137,7 +165,17 @@ private async Task InnerOnConnectionAsync(ConnectionAdapterC await sslStream.AuthenticateAsServerAsync(sslOptions, CancellationToken.None); #else - await sslStream.AuthenticateAsServerAsync(_serverCertificate, certificateRequired, + var serverCert = _serverCertificate; + if (_serverCertificateSelector != null) + { + context.Features.Set(sslStream); + serverCert = _serverCertificateSelector(context.Features, null); + if (serverCert != null) + { + EnsureCertificateIsAllowedForServerAuth(serverCert); + } + } + await sslStream.AuthenticateAsServerAsync(serverCert, certificateRequired, _options.SslProtocols, _options.CheckCertificateRevocation); #endif } diff --git a/src/Kestrel.Core/KestrelConfigurationLoader.cs b/src/Kestrel.Core/KestrelConfigurationLoader.cs index 71337a11d..b6468374b 100644 --- a/src/Kestrel.Core/KestrelConfigurationLoader.cs +++ b/src/Kestrel.Core/KestrelConfigurationLoader.cs @@ -236,7 +236,7 @@ public void Load() // EndpointDefaults or configureEndpoint may have added an https adapter. if (https && !listenOptions.ConnectionAdapters.Any(f => f.IsHttps)) { - if (httpsOptions.ServerCertificate == null) + if (httpsOptions.ServerCertificate == null && httpsOptions.ServerCertificateSelector == null) { throw new InvalidOperationException(CoreStrings.NoCertSpecifiedNoDevelopmentCertificateFound); } diff --git a/src/Kestrel.Core/ListenOptionsHttpsExtensions.cs b/src/Kestrel.Core/ListenOptionsHttpsExtensions.cs index 7cc267c5d..162ee59d4 100644 --- a/src/Kestrel.Core/ListenOptionsHttpsExtensions.cs +++ b/src/Kestrel.Core/ListenOptionsHttpsExtensions.cs @@ -179,7 +179,7 @@ public static ListenOptions UseHttps(this ListenOptions listenOptions, Action /// The server certificate parameter is required. /// - internal static string ServiceCertificateRequired + internal static string ServerCertificateRequired { - get => GetString("ServiceCertificateRequired"); + get => GetString("ServerCertificateRequired"); } /// /// The server certificate parameter is required. /// - internal static string FormatServiceCertificateRequired() - => GetString("ServiceCertificateRequired"); + internal static string FormatServerCertificateRequired() + => GetString("ServerCertificateRequired"); /// /// No listening endpoints were configured. Binding to {address0} and {address1} by default. diff --git a/test/Kestrel.FunctionalTests/HttpsConnectionAdapterTests.cs b/test/Kestrel.FunctionalTests/HttpsConnectionAdapterTests.cs index c08d02fb3..43ff38ce2 100644 --- a/test/Kestrel.FunctionalTests/HttpsConnectionAdapterTests.cs +++ b/test/Kestrel.FunctionalTests/HttpsConnectionAdapterTests.cs @@ -26,7 +26,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests { public class HttpsConnectionAdapterTests { - private static X509Certificate2 _x509Certificate2 = new X509Certificate2(TestResources.TestCertificatePath, "testPassword"); + private static X509Certificate2 _x509Certificate2 = TestResources.GetTestCertificate(); + private static X509Certificate2 _x509Certificate2NoExt = TestResources.GetTestCertificate("no_extensions.pfx"); private readonly ITestOutputHelper _output; public HttpsConnectionAdapterTests(ITestOutputHelper output) @@ -148,6 +149,211 @@ public async Task UsesProvidedServerCertificate() } } + [Fact] + public async Task UsesProvidedServerCertificateSelector() + { + var selectorCalled = 0; + var serviceContext = new TestServiceContext(); + var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)) + { + ConnectionAdapters = + { + new HttpsConnectionAdapter(new HttpsConnectionAdapterOptions + { + ServerCertificateSelector = (features, name) => + { + Assert.NotNull(features); + Assert.NotNull(features.Get()); +#if NETCOREAPP2_1 + Assert.Equal("localhost", name); +#else + Assert.Null(name); +#endif + selectorCalled++; + return _x509Certificate2; + } + }) + } + }; + using (var server = new TestServer(context => Task.CompletedTask, serviceContext, listenOptions)) + { + using (var client = new TcpClient()) + { + // SslStream is used to ensure the certificate is actually passed to the server + // HttpClient might not send the certificate because it is invalid or it doesn't match any + // of the certificate authorities sent by the server in the SSL handshake. + var stream = await OpenSslStream(client, server); + await stream.AuthenticateAsClientAsync("localhost", new X509CertificateCollection(), SslProtocols.Tls12 | SslProtocols.Tls11, false); + Assert.True(stream.RemoteCertificate.Equals(_x509Certificate2)); + Assert.Equal(1, selectorCalled); + } + } + } + + [Fact] + public async Task UsesProvidedServerCertificateSelectorEachTime() + { + var selectorCalled = 0; + var serviceContext = new TestServiceContext(); + var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)) + { + ConnectionAdapters = + { + new HttpsConnectionAdapter(new HttpsConnectionAdapterOptions + { + ServerCertificateSelector = (features, name) => + { + Assert.NotNull(features); + Assert.NotNull(features.Get()); +#if NETCOREAPP2_1 + Assert.Equal("localhost", name); +#else + Assert.Null(name); +#endif + selectorCalled++; + if (selectorCalled == 1) + { + return _x509Certificate2; + } + return _x509Certificate2NoExt; + } + }) + } + }; + using (var server = new TestServer(context => Task.CompletedTask, serviceContext, listenOptions)) + { + using (var client = new TcpClient()) + { + // SslStream is used to ensure the certificate is actually passed to the server + // HttpClient might not send the certificate because it is invalid or it doesn't match any + // of the certificate authorities sent by the server in the SSL handshake. + var stream = await OpenSslStream(client, server); + await stream.AuthenticateAsClientAsync("localhost", new X509CertificateCollection(), SslProtocols.Tls12 | SslProtocols.Tls11, false); + Assert.True(stream.RemoteCertificate.Equals(_x509Certificate2)); + Assert.Equal(1, selectorCalled); + } + using (var client = new TcpClient()) + { + // SslStream is used to ensure the certificate is actually passed to the server + // HttpClient might not send the certificate because it is invalid or it doesn't match any + // of the certificate authorities sent by the server in the SSL handshake. + var stream = await OpenSslStream(client, server); + await stream.AuthenticateAsClientAsync("localhost", new X509CertificateCollection(), SslProtocols.Tls12 | SslProtocols.Tls11, false); + Assert.True(stream.RemoteCertificate.Equals(_x509Certificate2NoExt)); + Assert.Equal(2, selectorCalled); + } + } + } + + [Fact] + public async Task UsesProvidedServerCertificateSelectorValidatesEkus() + { + var selectorCalled = 0; + var serviceContext = new TestServiceContext(); + var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)) + { + ConnectionAdapters = + { + new HttpsConnectionAdapter(new HttpsConnectionAdapterOptions + { + ServerCertificateSelector = (features, name) => + { + selectorCalled++; + return TestResources.GetTestCertificate("eku.code_signing.pfx"); + } + }) + } + }; + using (var server = new TestServer(context => Task.CompletedTask, serviceContext, listenOptions)) + { + using (var client = new TcpClient()) + { + // SslStream is used to ensure the certificate is actually passed to the server + // HttpClient might not send the certificate because it is invalid or it doesn't match any + // of the certificate authorities sent by the server in the SSL handshake. + var stream = await OpenSslStream(client, server); + await Assert.ThrowsAsync(() => + stream.AuthenticateAsClientAsync("localhost", new X509CertificateCollection(), SslProtocols.Tls12 | SslProtocols.Tls11, false)); + Assert.Equal(1, selectorCalled); + } + } + } + + [Fact] + public async Task UsesProvidedServerCertificateSelectorOverridesServerCertificate() + { + var selectorCalled = 0; + var serviceContext = new TestServiceContext(); + var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)) + { + ConnectionAdapters = + { + new HttpsConnectionAdapter(new HttpsConnectionAdapterOptions + { + ServerCertificate = _x509Certificate2NoExt, + ServerCertificateSelector = (features, name) => + { + Assert.NotNull(features); + Assert.NotNull(features.Get()); +#if NETCOREAPP2_1 + Assert.Equal("localhost", name); +#else + Assert.Null(name); +#endif + selectorCalled++; + return _x509Certificate2; + } + }) + } + }; + using (var server = new TestServer(context => Task.CompletedTask, serviceContext, listenOptions)) + { + using (var client = new TcpClient()) + { + // SslStream is used to ensure the certificate is actually passed to the server + // HttpClient might not send the certificate because it is invalid or it doesn't match any + // of the certificate authorities sent by the server in the SSL handshake. + var stream = await OpenSslStream(client, server); + await stream.AuthenticateAsClientAsync("localhost", new X509CertificateCollection(), SslProtocols.Tls12 | SslProtocols.Tls11, false); + Assert.True(stream.RemoteCertificate.Equals(_x509Certificate2)); + Assert.Equal(1, selectorCalled); + } + } + } + + [Fact] + public async Task UsesProvidedServerCertificateSelectorFailsIfYouReturnNull() + { + var selectorCalled = 0; + var serviceContext = new TestServiceContext(); + var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)) + { + ConnectionAdapters = + { + new HttpsConnectionAdapter(new HttpsConnectionAdapterOptions + { + ServerCertificateSelector = (features, name) => + { + selectorCalled++; + return null; + } + }) + } + }; + using (var server = new TestServer(context => Task.CompletedTask, serviceContext, listenOptions)) + { + using (var client = new TcpClient()) + { + // SslStream is used to ensure the certificate is actually passed to the server + // HttpClient might not send the certificate because it is invalid or it doesn't match any + // of the certificate authorities sent by the server in the SSL handshake. + var stream = await OpenSslStream(client, server); + await Assert.ThrowsAsync(() => + stream.AuthenticateAsClientAsync("localhost", new X509CertificateCollection(), SslProtocols.Tls12 | SslProtocols.Tls11, false)); + Assert.Equal(1, selectorCalled); + } + } + } [Fact] public async Task CertificatePassedToHttpContext() diff --git a/test/shared/TestResources.cs b/test/shared/TestResources.cs index b8ae46d18..3218a1eac 100644 --- a/test/shared/TestResources.cs +++ b/test/shared/TestResources.cs @@ -17,5 +17,10 @@ public static X509Certificate2 GetTestCertificate() { return new X509Certificate2(TestCertificatePath, "testPassword"); } + + public static X509Certificate2 GetTestCertificate(string certName) + { + return new X509Certificate2(GetCertPath(certName), "testPassword"); + } } }