diff --git a/samples/SampleApp/Startup.cs b/samples/SampleApp/Startup.cs
index 02c66a20be..3a4f29a3fd 100644
--- a/samples/SampleApp/Startup.cs
+++ b/samples/SampleApp/Startup.cs
@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Builder;
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 @@ namespace SampleApp
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 5eb49c1320..0ba93c9219 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 6ada701707..760c29cfc1 100644
--- a/src/Kestrel.Core/HttpsConnectionAdapterOptions.cs
+++ b/src/Kestrel.Core/HttpsConnectionAdapterOptions.cs
@@ -6,6 +6,7 @@ using System.Net.Security;
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 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https
///
///
- /// 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 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https
///
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 c71aedfedb..4107da5118 100644
--- a/src/Kestrel.Core/Internal/HttpsConnectionAdapter.cs
+++ b/src/Kestrel.Core/Internal/HttpsConnectionAdapter.cs
@@ -22,6 +22,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https.Internal
private readonly HttpsConnectionAdapterOptions _options;
private readonly X509Certificate2 _serverCertificate;
+ private readonly Func _serverCertificateSelector;
+
private readonly ILogger _logger;
public HttpsConnectionAdapter(HttpsConnectionAdapterOptions options)
@@ -36,15 +38,24 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https.Internal
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 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https.Internal
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 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https.Internal
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 71337a11d2..b6468374b2 100644
--- a/src/Kestrel.Core/KestrelConfigurationLoader.cs
+++ b/src/Kestrel.Core/KestrelConfigurationLoader.cs
@@ -236,7 +236,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel
// 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 7cc267c5de..162ee59d40 100644
--- a/src/Kestrel.Core/ListenOptionsHttpsExtensions.cs
+++ b/src/Kestrel.Core/ListenOptionsHttpsExtensions.cs
@@ -179,7 +179,7 @@ namespace Microsoft.AspNetCore.Hosting
listenOptions.KestrelServerOptions.ApplyHttpsDefaults(options);
configureOptions(options);
- if (options.ServerCertificate == null)
+ if (options.ServerCertificate == null && options.ServerCertificateSelector == null)
{
throw new InvalidOperationException(CoreStrings.NoCertSpecifiedNoDevelopmentCertificateFound);
}
@@ -192,7 +192,7 @@ namespace Microsoft.AspNetCore.Hosting
var options = new HttpsConnectionAdapterOptions();
listenOptions.KestrelServerOptions.ApplyHttpsDefaults(options);
- if (options.ServerCertificate == null)
+ if (options.ServerCertificate == null && options.ServerCertificateSelector == null)
{
return false;
}
diff --git a/src/Kestrel.Core/Properties/CoreStrings.Designer.cs b/src/Kestrel.Core/Properties/CoreStrings.Designer.cs
index f964f3db90..dab35d664d 100644
--- a/src/Kestrel.Core/Properties/CoreStrings.Designer.cs
+++ b/src/Kestrel.Core/Properties/CoreStrings.Designer.cs
@@ -1693,16 +1693,16 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core
///
/// 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 c08d02fb3e..43ff38ce26 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 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
}
}
+ [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 b8ae46d18f..3218a1eaca 100644
--- a/test/shared/TestResources.cs
+++ b/test/shared/TestResources.cs
@@ -17,5 +17,10 @@ namespace Microsoft.AspNetCore.Testing
{
return new X509Certificate2(TestCertificatePath, "testPassword");
}
+
+ public static X509Certificate2 GetTestCertificate(string certName)
+ {
+ return new X509Certificate2(GetCertPath(certName), "testPassword");
+ }
}
}