diff --git a/src/Servers/Kestrel/Core/src/ClientCertificateMode.cs b/src/Servers/Kestrel/Core/src/ClientCertificateMode.cs index caff5e041ac8..afa129aba3f3 100644 --- a/src/Servers/Kestrel/Core/src/ClientCertificateMode.cs +++ b/src/Servers/Kestrel/Core/src/ClientCertificateMode.cs @@ -21,6 +21,12 @@ public enum ClientCertificateMode /// /// A client certificate will be requested, and the client must provide a valid certificate for authentication to succeed. /// - RequireCertificate + RequireCertificate, + + /// + /// A client certificate is not required and will not be requested from clients at the start of the connection. + /// It may be requested by the application later. + /// + DelayCertificate, } } diff --git a/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs b/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs index b12923981084..3f78bb13b208 100644 --- a/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs +++ b/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs @@ -84,7 +84,8 @@ public SniOptionsSelector( if (clientCertificateMode != ClientCertificateMode.NoCertificate) { - sslOptions.ClientCertificateRequired = true; + sslOptions.ClientCertificateRequired = clientCertificateMode == ClientCertificateMode.AllowCertificate + || clientCertificateMode == ClientCertificateMode.RequireCertificate; sslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => HttpsConnectionMiddleware.RemoteCertificateValidationCallback( clientCertificateMode, fallbackHttpsOptions.ClientCertificateValidation, certificate, chain, sslPolicyErrors); @@ -94,7 +95,7 @@ public SniOptionsSelector( httpProtocols = HttpsConnectionMiddleware.ValidateAndNormalizeHttpProtocols(httpProtocols, logger); HttpsConnectionMiddleware.ConfigureAlpn(sslOptions, httpProtocols); - var sniOptions = new SniOptions(sslOptions, httpProtocols); + var sniOptions = new SniOptions(sslOptions, httpProtocols, clientCertificateMode); if (name.Equals(WildcardHost, StringComparison.Ordinal)) { @@ -112,7 +113,7 @@ public SniOptionsSelector( } } - public SslServerAuthenticationOptions GetOptions(ConnectionContext connection, string serverName) + public (SslServerAuthenticationOptions, ClientCertificateMode) GetOptions(ConnectionContext connection, string serverName) { SniOptions? sniOptions = null; @@ -172,14 +173,14 @@ public SslServerAuthenticationOptions GetOptions(ConnectionContext connection, s _onAuthenticateCallback(connection, sslOptions); } - return sslOptions; + return (sslOptions, sniOptions.ClientCertificateMode); } - public static ValueTask OptionsCallback(ConnectionContext connection, SslStream stream, SslClientHelloInfo clientHelloInfo, object state, CancellationToken cancellationToken) + public static ValueTask<(SslServerAuthenticationOptions, ClientCertificateMode)> OptionsCallback(ConnectionContext connection, SslStream stream, SslClientHelloInfo clientHelloInfo, object state, CancellationToken cancellationToken) { var sniOptionsSelector = (SniOptionsSelector)state; - var options = sniOptionsSelector.GetOptions(connection, clientHelloInfo.ServerName); - return new ValueTask(options); + var (options, clientCertificateMode) = sniOptionsSelector.GetOptions(connection, clientHelloInfo.ServerName); + return new ValueTask<(SslServerAuthenticationOptions, ClientCertificateMode)>((options, clientCertificateMode)); } internal static SslServerAuthenticationOptions CloneSslOptions(SslServerAuthenticationOptions sslOptions) => @@ -200,14 +201,16 @@ internal static SslServerAuthenticationOptions CloneSslOptions(SslServerAuthenti private class SniOptions { - public SniOptions(SslServerAuthenticationOptions sslOptions, HttpProtocols httpProtocols) + public SniOptions(SslServerAuthenticationOptions sslOptions, HttpProtocols httpProtocols, ClientCertificateMode clientCertificateMode) { SslOptions = sslOptions; HttpProtocols = httpProtocols; + ClientCertificateMode = clientCertificateMode; } public SslServerAuthenticationOptions SslOptions { get; } public HttpProtocols HttpProtocols { get; } + public ClientCertificateMode ClientCertificateMode { get; } } private class LongestStringFirstComparer : IComparer diff --git a/src/Servers/Kestrel/Core/src/Internal/TlsConnectionFeature.cs b/src/Servers/Kestrel/Core/src/Internal/TlsConnectionFeature.cs index e5631645790d..869b6c98dc1e 100644 --- a/src/Servers/Kestrel/Core/src/Internal/TlsConnectionFeature.cs +++ b/src/Servers/Kestrel/Core/src/Internal/TlsConnectionFeature.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Features; +using Microsoft.AspNetCore.Server.Kestrel.Https; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal { @@ -25,6 +26,7 @@ internal class TlsConnectionFeature : ITlsConnectionFeature, ITlsApplicationProt private int? _hashStrength; private ExchangeAlgorithmType? _keyExchangeAlgorithm; private int? _keyExchangeStrength; + private Task? _clientCertTask; public TlsConnectionFeature(SslStream sslStream) { @@ -36,6 +38,8 @@ public TlsConnectionFeature(SslStream sslStream) _sslStream = sslStream; } + internal ClientCertificateMode ClientCertificateMode { get; set; } + public X509Certificate2? ClientCertificate { get @@ -99,7 +103,27 @@ public int KeyExchangeStrength public Task GetClientCertificateAsync(CancellationToken cancellationToken) { - return Task.FromResult(ClientCertificate); + // Only try once per connection + if (_clientCertTask != null) + { + return _clientCertTask; + } + + if (ClientCertificate != null + || ClientCertificateMode != ClientCertificateMode.DelayCertificate + // Delayed client cert negotiation is not allowed on HTTP/2 (or HTTP/3, but that's implemented elsewhere). + || _sslStream.NegotiatedApplicationProtocol == SslApplicationProtocol.Http2) + { + return _clientCertTask = Task.FromResult(ClientCertificate); + } + + return _clientCertTask = GetClientCertificateAsyncCore(cancellationToken); + } + + private async Task GetClientCertificateAsyncCore(CancellationToken cancellationToken) + { + await _sslStream.NegotiateClientCertificateAsync(cancellationToken); + return ClientCertificate; } private static X509Certificate2? ConvertToX509Certificate2(X509Certificate? certificate) diff --git a/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs b/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs index ff2b007e66a4..1aeb0fd460b0 100644 --- a/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs +++ b/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs @@ -5,6 +5,9 @@ using System.IO; using System.Net.Security; using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; @@ -256,12 +259,16 @@ public static ListenOptions UseHttps(this ListenOptions listenOptions, ServerOpt /// The . public static ListenOptions UseHttps(this ListenOptions listenOptions, ServerOptionsSelectionCallback serverOptionsSelectionCallback, object state, TimeSpan handshakeTimeout) { - // HttpsOptionsCallback is an internal delegate that is just the ServerOptionsSelectionCallback + a ConnectionContext parameter. + // HttpsOptionsCallback is an internal delegate that is the ServerOptionsSelectionCallback, a ConnectionContext, and the ClientCertificateMode. // Given that ConnectionContext will eventually be replaced by System.Net.Connections, it doesn't make much sense to make the HttpsOptionsCallback delegate public. - HttpsOptionsCallback adaptedCallback = (connection, stream, clientHelloInfo, state, cancellationToken) => - serverOptionsSelectionCallback(stream, clientHelloInfo, state, cancellationToken); + return listenOptions.UseHttps(GetTlsOptionsAsync, state, handshakeTimeout); - return listenOptions.UseHttps(adaptedCallback, state, handshakeTimeout); + async ValueTask<(SslServerAuthenticationOptions, ClientCertificateMode)> GetTlsOptionsAsync( + ConnectionContext connection, SslStream stream, SslClientHelloInfo clientHelloInfo, object state, CancellationToken cancellationToken) + { + var tlsOptions = await serverOptionsSelectionCallback(stream, clientHelloInfo, state, cancellationToken); + return new (tlsOptions, ClientCertificateMode.DelayCertificate); + } } /// diff --git a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs index c46aff747b7f..3cc706334528 100644 --- a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs +++ b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs @@ -26,7 +26,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https.Internal { - internal delegate ValueTask HttpsOptionsCallback(ConnectionContext connection, SslStream stream, SslClientHelloInfo clientHelloInfo, object state, CancellationToken cancellationToken); + internal delegate ValueTask<(SslServerAuthenticationOptions, ClientCertificateMode)> HttpsOptionsCallback(ConnectionContext connection, SslStream stream, SslClientHelloInfo clientHelloInfo, object state, CancellationToken cancellationToken); internal class HttpsConnectionMiddleware { @@ -148,6 +148,8 @@ public async Task OnConnectionAsync(ConnectionContext context) var sslStream = sslDuplexPipe.Stream; var feature = new Core.Internal.TlsConnectionFeature(sslStream); + // Set the mode if options were used. If the callback is used it will set the mode later. + feature.ClientCertificateMode = _options?.ClientCertificateMode ?? ClientCertificateMode.NoCertificate; context.Features.Set(feature); context.Features.Set(feature); context.Features.Set(feature); @@ -321,7 +323,8 @@ private Task DoOptionsBasedHandshakeAsync(ConnectionContext context, SslStream s ServerCertificate = _serverCertificate, ServerCertificateContext = _serverCertificateContext, ServerCertificateSelectionCallback = selector, - ClientCertificateRequired = _options.ClientCertificateMode != ClientCertificateMode.NoCertificate, + ClientCertificateRequired = _options.ClientCertificateMode == ClientCertificateMode.AllowCertificate + || _options.ClientCertificateMode == ClientCertificateMode.RequireCertificate, EnabledSslProtocols = _options.SslProtocols, CertificateRevocationCheckMode = _options.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, }; @@ -424,7 +427,8 @@ private static async ValueTask ServerOptionsCall feature.HostName = clientHelloInfo.ServerName; context.Features.Set(sslStream); - var sslOptions = await middleware._httpsOptionsCallback!(context, sslStream, clientHelloInfo, middleware._httpsOptionsCallbackState!, cancellationToken); + var (sslOptions, clientCertificateMode) = await middleware._httpsOptionsCallback!(context, sslStream, clientHelloInfo, middleware._httpsOptionsCallbackState!, cancellationToken); + feature.ClientCertificateMode = clientCertificateMode; KestrelEventSource.Log.TlsHandshakeStart(context, sslOptions); diff --git a/src/Servers/Kestrel/Core/src/PublicAPI.Unshipped.txt b/src/Servers/Kestrel/Core/src/PublicAPI.Unshipped.txt index cf79ecc855dd..8495f35e086c 100644 --- a/src/Servers/Kestrel/Core/src/PublicAPI.Unshipped.txt +++ b/src/Servers/Kestrel/Core/src/PublicAPI.Unshipped.txt @@ -95,6 +95,7 @@ *REMOVED*~static Microsoft.AspNetCore.Hosting.ListenOptionsHttpsExtensions.UseHttps(this Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions listenOptions, string fileName, string password) -> Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions *REMOVED*~static Microsoft.AspNetCore.Hosting.ListenOptionsHttpsExtensions.UseHttps(this Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions listenOptions, string fileName, string password, System.Action configureOptions) -> Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions *REMOVED*~static Microsoft.AspNetCore.Server.Kestrel.Https.CertificateLoader.LoadFromStoreCert(string subject, string storeName, System.Security.Cryptography.X509Certificates.StoreLocation storeLocation, bool allowInvalid) -> System.Security.Cryptography.X509Certificates.X509Certificate2 +Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode.DelayCertificate = 3 -> Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode static Microsoft.AspNetCore.Hosting.ListenOptionsHttpsExtensions.UseHttps(this Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions! listenOptions, Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions! httpsOptions) -> Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions! static Microsoft.AspNetCore.Hosting.ListenOptionsHttpsExtensions.UseHttps(this Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions! listenOptions, System.Action! configureOptions) -> Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions! static Microsoft.AspNetCore.Hosting.ListenOptionsHttpsExtensions.UseHttps(this Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions! listenOptions, System.Net.Security.ServerOptionsSelectionCallback! serverOptionsSelectionCallback, object! state) -> Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions! diff --git a/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs b/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs index bbe148069d99..c4d20185d92a 100644 --- a/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs +++ b/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs @@ -73,22 +73,22 @@ public void PrefersExactMatchOverWildcardPrefixOverWildcardOnly() fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var wwwSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (wwwSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); Assert.Equal("Exact", pathDictionary[wwwSubdomainOptions.ServerCertificate]); - var baSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "b.a.example.org"); + var (baSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "b.a.example.org"); Assert.Equal("WildcardPrefix", pathDictionary[baSubdomainOptions.ServerCertificate]); - var aSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "a.example.org"); + var (aSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "a.example.org"); Assert.Equal("WildcardPrefix", pathDictionary[aSubdomainOptions.ServerCertificate]); // "*.example.org" is preferred over "*", but "*.example.org" doesn't match "example.org". // REVIEW: Are we OK with "example.org" matching "*" instead of "*.example.org"? It feels annoying to me to have to configure example.org twice. // Unfortunately, the alternative would have "a.example.org" match "*.a.example.org" before "*.example.org", and that just seems wrong. - var noSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "example.org"); + var (noSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "example.org"); Assert.Equal("WildcardOnly", pathDictionary[noSubdomainOptions.ServerCertificate]); - var anotherTldOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "dot.net"); + var (anotherTldOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "dot.net"); Assert.Equal("WildcardOnly", pathDictionary[anotherTldOptions.ServerCertificate]); } @@ -130,11 +130,11 @@ public void PerfersLongerWildcardPrefixOverShorterWildcardPrefix() fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var baSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "b.a.example.org"); + var (baSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "b.a.example.org"); Assert.Equal("Long", pathDictionary[baSubdomainOptions.ServerCertificate]); // "*.a.example.org" is preferred over "*.example.org", but "a.example.org" doesn't match "*.a.example.org". - var aSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "a.example.org"); + var (aSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "a.example.org"); Assert.Equal("Short", pathDictionary[aSubdomainOptions.ServerCertificate]); } @@ -176,13 +176,13 @@ public void ServerNameMatchingIsCaseInsensitive() fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var wwwSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "wWw.eXample.oRg"); + var (wwwSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "wWw.eXample.oRg"); Assert.Equal("Exact", pathDictionary[wwwSubdomainOptions.ServerCertificate]); - var baSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "B.a.eXample.oRg"); + var (baSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "B.a.eXample.oRg"); Assert.Equal("WildcardPrefix", pathDictionary[baSubdomainOptions.ServerCertificate]); - var aSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "A.eXample.oRg"); + var (aSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "A.eXample.oRg"); Assert.Equal("WildcardPrefix", pathDictionary[aSubdomainOptions.ServerCertificate]); } @@ -232,7 +232,7 @@ public void WildcardOnlyMatchesNullServerNameDueToNoAlpn() fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), null); + var (options, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), null); Assert.Equal("WildcardOnly", pathDictionary[options.ServerCertificate]); } @@ -258,8 +258,8 @@ public void CachesSslServerAuthenticationOptions() fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var options1 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); - var options2 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options1, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options2, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); Assert.Same(options1, options2); } @@ -295,10 +295,10 @@ public void ClonesSslServerAuthenticationOptionsIfAnOnAuthenticateCallbackIsDefi fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var options1 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options1, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); Assert.Same(lastSeenSslOptions, options1); - var options2 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options2, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); Assert.Same(lastSeenSslOptions, options2); Assert.NotSame(options1, options2); @@ -338,19 +338,19 @@ public void ClonesSslServerAuthenticationOptionsIfTheFallbackServerCertificateSe fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var selectorOptions1 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "selector.example.org"); + var (selectorOptions1, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "selector.example.org"); Assert.Same(selectorCertificate, selectorOptions1.ServerCertificate); - var selectorOptions2 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "selector.example.org"); + var (selectorOptions2, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "selector.example.org"); Assert.Same(selectorCertificate, selectorOptions2.ServerCertificate); // The SslServerAuthenticationOptions were cloned because the cert came from the ServerCertificateSelector fallback. Assert.NotSame(selectorOptions1, selectorOptions2); - var configOptions1 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "config.example.org"); + var (configOptions1, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "config.example.org"); Assert.NotSame(selectorCertificate, configOptions1.ServerCertificate); - var configOptions2 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "config.example.org"); + var (configOptions2, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "config.example.org"); Assert.NotSame(selectorCertificate, configOptions2.ServerCertificate); // The SslServerAuthenticationOptions don't need to be cloned if a static cert is defined in config for the given server name. @@ -397,7 +397,7 @@ public void FallsBackToHttpsConnectionAdapterCertificate() fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); Assert.Same(fallbackOptions.ServerCertificate, options.ServerCertificate); } @@ -425,7 +425,7 @@ public void FallsBackToHttpsConnectionAdapterServerCertificateSelectorOverServer fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); Assert.Same(selectorCertificate, options.ServerCertificate); } @@ -485,7 +485,7 @@ public void ConfiguresAlpnBasedOnConfiguredHttpProtocols() fallbackHttpProtocols: HttpProtocols.None, logger: Mock.Of>()); - var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); var alpnList = options.ApplicationProtocols; Assert.NotNull(alpnList); @@ -549,7 +549,7 @@ public void PrefersSslProtocolsDefinedInSniConfig() fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); Assert.Equal(SslProtocols.Tls13 | SslProtocols.Tls11, options.EnabledSslProtocols); } @@ -578,7 +578,7 @@ public void FallsBackToFallbackSslProtocols() fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); Assert.Equal(SslProtocols.Tls13, options.EnabledSslProtocols); } @@ -592,7 +592,7 @@ public void PrefersClientCertificateModeDefinedInSniConfig() "www.example.org", new SniConfig { - ClientCertificateMode = ClientCertificateMode.RequireCertificate, + ClientCertificateMode = ClientCertificateMode.DelayCertificate, Certificate = new CertificateConfig() } } @@ -609,13 +609,14 @@ public void PrefersClientCertificateModeDefinedInSniConfig() fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options, certMode) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); - Assert.True(options.ClientCertificateRequired); + Assert.Equal(ClientCertificateMode.DelayCertificate, certMode); + Assert.False(options.ClientCertificateRequired); Assert.NotNull(options.RemoteCertificateValidationCallback); - // The RemoteCertificateValidationCallback should first check if the certificate is null and return false since it's required. - Assert.False(options.RemoteCertificateValidationCallback(sender: null, certificate: null, chain: null, SslPolicyErrors.None)); + // The RemoteCertificateValidationCallback should first check if the certificate is null and return true since it's optional. + Assert.True(options.RemoteCertificateValidationCallback(sender: null, certificate: null, chain: null, SslPolicyErrors.None)); } [Fact] @@ -643,8 +644,9 @@ public void FallsBackToFallbackClientCertificateMode() fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, logger: Mock.Of>()); - var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var (options, certMode) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + Assert.Equal(ClientCertificateMode.AllowCertificate, certMode); // Despite the confusing name, ClientCertificateRequired being true simply requests a certificate from the client, but doesn't require it. Assert.True(options.ClientCertificateRequired); diff --git a/src/Servers/Kestrel/samples/SampleApp/ClientCertBufferingFeature.cs b/src/Servers/Kestrel/samples/SampleApp/ClientCertBufferingFeature.cs new file mode 100644 index 000000000000..e14d20d1b684 --- /dev/null +++ b/src/Servers/Kestrel/samples/SampleApp/ClientCertBufferingFeature.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.WebUtilities; + +namespace SampleApp +{ + internal static class ClientCertBufferingExtensions + { + // Buffers HTTP/1.x request bodies received over TLS (https) if a client certificate needs to be negotiated. + // This avoids the issue where POST data is received during the certificate negotiation: + // InvalidOperationException: Received data during renegotiation. + public static IApplicationBuilder UseClientCertBuffering(this IApplicationBuilder builder) + { + return builder.Use((context, next) => + { + var tlsFeature = context.Features.Get(); + var bodyFeature = context.Features.Get(); + var connectionItems = context.Features.Get(); + + // Look for TLS connections that don't already have a client cert, and requests that could have a body. + if (tlsFeature != null && tlsFeature.ClientCertificate == null && bodyFeature.CanHaveBody + && !connectionItems.Items.TryGetValue("tls.clientcert.negotiated", out var _)) + { + context.Features.Set(new ClientCertBufferingFeature(tlsFeature, context)); + } + + return next(context); + }); + } + } + + internal class ClientCertBufferingFeature : ITlsConnectionFeature + { + private ITlsConnectionFeature _tlsFeature; + private HttpContext _context; + + public ClientCertBufferingFeature(ITlsConnectionFeature tlsFeature, HttpContext context) + { + _tlsFeature = tlsFeature; + _context = context; + } + + public X509Certificate2 ClientCertificate + { + get => _tlsFeature.ClientCertificate; + set => _tlsFeature.ClientCertificate = value; + } + + public async Task GetClientCertificateAsync(CancellationToken cancellationToken) + { + // Note: This doesn't set its own size limit for the buffering or draining, it relies on the server's + // 30mb default request size limit. + if (!_context.Request.Body.CanSeek) + { + _context.Request.EnableBuffering(); + } + + var body = _context.Request.Body; + await body.DrainAsync(cancellationToken); + body.Position = 0; + + // Negative caching, prevent buffering on future requests even if the client does not give a cert when prompted. + var connectionItems = _context.Features.Get(); + connectionItems.Items["tls.clientcert.negotiated"] = true; + + return await _tlsFeature.GetClientCertificateAsync(cancellationToken); + } + } +} diff --git a/src/Servers/Kestrel/samples/SampleApp/Startup.cs b/src/Servers/Kestrel/samples/SampleApp/Startup.cs index dabd15ccf9e7..2b489a1a4d7c 100644 --- a/src/Servers/Kestrel/samples/SampleApp/Startup.cs +++ b/src/Servers/Kestrel/samples/SampleApp/Startup.cs @@ -11,6 +11,7 @@ using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -27,6 +28,8 @@ public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) { var logger = loggerFactory.CreateLogger("Default"); + app.UseClientCertBuffering(); + // Add an exception handler that prevents throwing due to large request body size app.Use(async (context, next) => { @@ -43,12 +46,16 @@ public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) app.Run(async context => { // Drain the request body - await context.Request.Body.CopyToAsync(Stream.Null); + // await context.Request.Body.CopyToAsync(Stream.Null); + + var cert = await context.Connection.GetClientCertificateAsync(); var connectionFeature = context.Connection; logger.LogDebug($"Peer: {connectionFeature.RemoteIpAddress?.ToString()}:{connectionFeature.RemotePort}" + $"{Environment.NewLine}" - + $"Sock: {connectionFeature.LocalIpAddress?.ToString()}:{connectionFeature.LocalPort}"); + + $"Sock: {connectionFeature.LocalIpAddress?.ToString()}:{connectionFeature.LocalPort}" + + $"{Environment.NewLine}" + + cert); var response = $"hello, world{Environment.NewLine}"; context.Response.ContentLength = response.Length; @@ -80,6 +87,7 @@ public static Task Main(string[] args) options.ConfigureHttpsDefaults(httpsOptions => { httpsOptions.SslProtocols = SslProtocols.Tls12; + httpsOptions.ClientCertificateMode = ClientCertificateMode.DelayCertificate; }); options.Listen(IPAddress.Loopback, basePort, listenOptions => @@ -92,6 +100,7 @@ public static Task Main(string[] args) options.Listen(IPAddress.Loopback, basePort + 1, listenOptions => { + listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1; listenOptions.UseHttps(); listenOptions.UseConnectionLogging(); }); diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs index 61fdb0586b0f..62d0beecc511 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs @@ -11,6 +11,7 @@ using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Hosting; @@ -69,7 +70,7 @@ public async Task CanReadAndWriteWithHttpsConnectionMiddlewareWithPemCertificate var env = new Mock(); env.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory()); - var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); options.ApplicationServices = serviceProvider; var logger = serviceProvider.GetRequiredService>(); @@ -480,13 +481,270 @@ void ConfigureListenOptions(ListenOptions listenOptions) // 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. + // Use a random host name to avoid the TLS session resumption cache. var stream = OpenSslStreamWithCert(connection.Stream); - await stream.AuthenticateAsClientAsync("localhost"); + await stream.AuthenticateAsClientAsync(Guid.NewGuid().ToString()); await AssertConnectionResult(stream, true); } } } + [Fact] + public async Task RenegotiateForClientCertificateOnHttp1DisabledByDefault() + { + void ConfigureListenOptions(ListenOptions listenOptions) + { + listenOptions.Protocols = HttpProtocols.Http1; + listenOptions.UseHttps(options => + { + options.ServerCertificate = _x509Certificate2; + options.ClientCertificateMode = ClientCertificateMode.NoCertificate; + options.AllowAnyClientCertificate(); + }); + } + + await using var server = new TestServer(async context => + { + var tlsFeature = context.Features.Get(); + Assert.NotNull(tlsFeature); + Assert.Null(tlsFeature.ClientCertificate); + Assert.Null(context.Connection.ClientCertificate); + + var clientCert = await context.Connection.GetClientCertificateAsync(); + Assert.Null(clientCert); + Assert.Null(tlsFeature.ClientCertificate); + Assert.Null(context.Connection.ClientCertificate); + + await context.Response.WriteAsync("hello world"); + }, new TestServiceContext(LoggerFactory), ConfigureListenOptions); + + using var connection = server.CreateConnection(); + // 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. + // Use a random host name to avoid the TLS session resumption cache. + var stream = OpenSslStreamWithCert(connection.Stream); + await stream.AuthenticateAsClientAsync(Guid.NewGuid().ToString()); + await AssertConnectionResult(stream, true); + } + + [ConditionalTheory] + [InlineData(HttpProtocols.Http1)] + [InlineData(HttpProtocols.Http1AndHttp2)] // Make sure turning on Http/2 doesn't regress HTTP/1 + [OSSkipCondition(OperatingSystems.MacOSX | OperatingSystems.Linux, SkipReason = "Not supported yet.")] + public async Task CanRenegotiateForClientCertificate(HttpProtocols httpProtocols) + { + void ConfigureListenOptions(ListenOptions listenOptions) + { + listenOptions.Protocols = httpProtocols; + listenOptions.UseHttps(options => + { + options.ServerCertificate = _x509Certificate2; + options.ClientCertificateMode = ClientCertificateMode.DelayCertificate; + options.AllowAnyClientCertificate(); + }); + } + + await using var server = new TestServer(async context => + { + var tlsFeature = context.Features.Get(); + Assert.NotNull(tlsFeature); + Assert.Null(tlsFeature.ClientCertificate); + Assert.Null(context.Connection.ClientCertificate); + + var clientCert = await context.Connection.GetClientCertificateAsync(); + Assert.NotNull(clientCert); + Assert.NotNull(tlsFeature.ClientCertificate); + Assert.NotNull(context.Connection.ClientCertificate); + + await context.Response.WriteAsync("hello world"); + }, new TestServiceContext(LoggerFactory), ConfigureListenOptions); + + using var connection = server.CreateConnection(); + // 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. + // Use a random host name to avoid the TLS session resumption cache. + var stream = OpenSslStreamWithCert(connection.Stream); + await stream.AuthenticateAsClientAsync(Guid.NewGuid().ToString()); + await AssertConnectionResult(stream, true); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.MacOSX | OperatingSystems.Linux, SkipReason = "Not supported yet.")] + public async Task CanRenegotiateForServerOptionsSelectionCallback() + { + void ConfigureListenOptions(ListenOptions listenOptions) + { + listenOptions.UseHttps((SslStream stream, SslClientHelloInfo clientHelloInfo, object state, CancellationToken cancellationToken) => + { + return ValueTask.FromResult(new SslServerAuthenticationOptions() + { + ServerCertificate = _x509Certificate2, + ClientCertificateRequired = false, + RemoteCertificateValidationCallback = (_, _, _, _) => true, + }); + }, state: null); + } + + await using var server = new TestServer(async context => + { + var tlsFeature = context.Features.Get(); + Assert.NotNull(tlsFeature); + Assert.Null(tlsFeature.ClientCertificate); + Assert.Null(context.Connection.ClientCertificate); + + var clientCert = await context.Connection.GetClientCertificateAsync(); + Assert.NotNull(clientCert); + Assert.NotNull(tlsFeature.ClientCertificate); + Assert.NotNull(context.Connection.ClientCertificate); + + await context.Response.WriteAsync("hello world"); + }, new TestServiceContext(LoggerFactory), ConfigureListenOptions); + + using var connection = server.CreateConnection(); + // 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. + // Use a random host name to avoid the TLS session resumption cache. + var stream = OpenSslStreamWithCert(connection.Stream); + await stream.AuthenticateAsClientAsync(Guid.NewGuid().ToString()); + await AssertConnectionResult(stream, true); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.MacOSX | OperatingSystems.Linux, SkipReason = "Not supported yet.")] + public async Task CanRenegotiateForClientCertificateOnHttp1CanReturnNoCert() + { + void ConfigureListenOptions(ListenOptions listenOptions) + { + listenOptions.Protocols = HttpProtocols.Http1; + listenOptions.UseHttps(options => + { + options.ServerCertificate = _x509Certificate2; + options.ClientCertificateMode = ClientCertificateMode.DelayCertificate; + options.AllowAnyClientCertificate(); + }); + } + + await using var server = new TestServer(async context => + { + var tlsFeature = context.Features.Get(); + Assert.NotNull(tlsFeature); + Assert.Null(tlsFeature.ClientCertificate); + Assert.Null(context.Connection.ClientCertificate); + + var clientCert = await context.Connection.GetClientCertificateAsync(); + Assert.Null(clientCert); + Assert.Null(tlsFeature.ClientCertificate); + Assert.Null(context.Connection.ClientCertificate); + + await context.Response.WriteAsync("hello world"); + }, new TestServiceContext(LoggerFactory), ConfigureListenOptions); + + using var connection = server.CreateConnection(); + // 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. + // Use a random host name to avoid the TLS session resumption cache. + var stream = new SslStream(connection.Stream); + var clientOptions = new SslClientAuthenticationOptions() + { + TargetHost = Guid.NewGuid().ToString(), + EnabledSslProtocols = SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12, + }; + clientOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true; + + await stream.AuthenticateAsClientAsync(clientOptions); + await AssertConnectionResult(stream, true); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.MacOSX | OperatingSystems.Linux, SkipReason = "Not supported yet.")] + public async Task RenegotiateForClientCertificateOnPostWithoutBufferingThrows() + { + void ConfigureListenOptions(ListenOptions listenOptions) + { + listenOptions.Protocols = HttpProtocols.Http1; + listenOptions.UseHttps(options => + { + options.ServerCertificate = _x509Certificate2; + options.ClientCertificateMode = ClientCertificateMode.DelayCertificate; + options.AllowAnyClientCertificate(); + }); + } + + // Under 4kb can sometimes work because it fits into Kestrel's header parsing buffer. + var expectedBody = new string('a', 1024 * 4); + + await using var server = new TestServer(async context => + { + var tlsFeature = context.Features.Get(); + Assert.NotNull(tlsFeature); + Assert.Null(tlsFeature.ClientCertificate); + Assert.Null(context.Connection.ClientCertificate); + + var ex = await Assert.ThrowsAsync(() => context.Connection.GetClientCertificateAsync()); + Assert.Equal("Received data during renegotiation.", ex.Message); + Assert.Null(tlsFeature.ClientCertificate); + Assert.Null(context.Connection.ClientCertificate); + }, new TestServiceContext(LoggerFactory), ConfigureListenOptions); + + using var connection = server.CreateConnection(); + // 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. + // Use a random host name to avoid the TLS session resumption cache. + var stream = OpenSslStreamWithCert(connection.Stream); + await stream.AuthenticateAsClientAsync(Guid.NewGuid().ToString()); + await AssertConnectionResult(stream, false, expectedBody); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.MacOSX | OperatingSystems.Linux, SkipReason = "Not supported yet.")] + public async Task CanRenegotiateForClientCertificateOnPostIfDrained() + { + void ConfigureListenOptions(ListenOptions listenOptions) + { + listenOptions.Protocols = HttpProtocols.Http1; + listenOptions.UseHttps(options => + { + options.ServerCertificate = _x509Certificate2; + options.ClientCertificateMode = ClientCertificateMode.DelayCertificate; + options.AllowAnyClientCertificate(); + }); + } + + var expectedBody = new string('a', 1024 * 4); + + await using var server = new TestServer(async context => + { + var tlsFeature = context.Features.Get(); + Assert.NotNull(tlsFeature); + Assert.Null(tlsFeature.ClientCertificate); + Assert.Null(context.Connection.ClientCertificate); + + // Read the body before requesting the client cert + var body = await new StreamReader(context.Request.Body).ReadToEndAsync(); + Assert.Equal(expectedBody, body); + + var clientCert = await context.Connection.GetClientCertificateAsync(); + Assert.NotNull(clientCert); + Assert.NotNull(tlsFeature.ClientCertificate); + Assert.NotNull(context.Connection.ClientCertificate); + await context.Response.WriteAsync("hello world"); + }, new TestServiceContext(LoggerFactory), ConfigureListenOptions); + + using var connection = server.CreateConnection(); + // 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. + // Use a random host name to avoid the TLS session resumption cache. + var stream = OpenSslStreamWithCert(connection.Stream); + await stream.AuthenticateAsClientAsync(Guid.NewGuid().ToString()); + await AssertConnectionResult(stream, true, expectedBody); + } + [Fact] public async Task HttpsSchemePassedToRequestFeature() { @@ -854,9 +1112,10 @@ private static SslStream OpenSslStreamWithCert(Stream rawStream, X509Certificate (sender, host, certificates, certificate, issuers) => clientCertificate ?? _x509Certificate2); } - private static async Task AssertConnectionResult(SslStream stream, bool success) + private static async Task AssertConnectionResult(SslStream stream, bool success, string body = null) { - var request = Encoding.UTF8.GetBytes("GET / HTTP/1.0\r\n\r\n"); + var request = body == null ? Encoding.UTF8.GetBytes("GET / HTTP/1.0\r\n\r\n") + : Encoding.UTF8.GetBytes($"POST / HTTP/1.0\r\nContent-Length: {body.Length}\r\n\r\n{body}"); await stream.WriteAsync(request, 0, request.Length); var reader = new StreamReader(stream); string line = null; diff --git a/src/Servers/Kestrel/test/Interop.FunctionalTests/HttpClientHttp2InteropTests.cs b/src/Servers/Kestrel/test/Interop.FunctionalTests/HttpClientHttp2InteropTests.cs index cd762cba334b..a0c4a69e3e1a 100644 --- a/src/Servers/Kestrel/test/Interop.FunctionalTests/HttpClientHttp2InteropTests.cs +++ b/src/Servers/Kestrel/test/Interop.FunctionalTests/HttpClientHttp2InteropTests.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Testing; @@ -1591,6 +1592,94 @@ public async Task UrlEncoding(string scheme) await host.StopAsync().DefaultTimeout(); } + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX, SkipReason = "Not supported yet")] + public async Task ClientCertificate_Required() + { + var hostBuilder = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder.UseKestrel(options => + { + options.Listen(IPAddress.Loopback, 0, listenOptions => + { + listenOptions.Protocols = HttpProtocols.Http2; + listenOptions.UseHttps(httpsOptions => + { + httpsOptions.ServerCertificate = TestResources.GetTestCertificate(); + httpsOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate; + httpsOptions.AllowAnyClientCertificate(); + }); + }); + }); + webHostBuilder.ConfigureServices(AddTestLogging) + .Configure(app => app.Run(async context => + { + Assert.NotNull(context.Connection.ClientCertificate); + Assert.NotNull(await context.Connection.GetClientCertificateAsync()); + await context.Response.WriteAsync("Hello World"); + })); + }); + using var host = await hostBuilder.StartAsync().DefaultTimeout(); + + var handler = new SocketsHttpHandler(); + handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; + handler.SslOptions.LocalCertificateSelectionCallback = (_, _, _, _, _) => TestResources.GetTestCertificate(); + using var client = new HttpClient(handler); + client.DefaultRequestVersion = HttpVersion.Version20; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; + var url = host.MakeUrl(Uri.UriSchemeHttps); + var response = await client.GetAsync(url).DefaultTimeout(); + + Assert.Equal(HttpVersion.Version20, response.Version); + Assert.Equal("Hello World", await response.Content.ReadAsStringAsync()); + await host.StopAsync().DefaultTimeout(); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX, SkipReason = "Not supported yet")] + public async Task ClientCertificate_DelayedNotSupported() + { + var hostBuilder = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder.UseKestrel(options => + { + options.Listen(IPAddress.Loopback, 0, listenOptions => + { + listenOptions.Protocols = HttpProtocols.Http2; + listenOptions.UseHttps(httpsOptions => + { + httpsOptions.ServerCertificate = TestResources.GetTestCertificate(); + httpsOptions.ClientCertificateMode = ClientCertificateMode.DelayCertificate; + httpsOptions.AllowAnyClientCertificate(); + }); + }); + }); + webHostBuilder.ConfigureServices(AddTestLogging) + .Configure(app => app.Run(async context => + { + Assert.Null(context.Connection.ClientCertificate); + Assert.Null(await context.Connection.GetClientCertificateAsync()); + await context.Response.WriteAsync("Hello World"); + })); + }); + using var host = await hostBuilder.StartAsync().DefaultTimeout(); + + var handler = new SocketsHttpHandler(); + handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; + handler.SslOptions.LocalCertificateSelectionCallback = (_, _, _, _, _) => TestResources.GetTestCertificate(); + using var client = new HttpClient(handler); + client.DefaultRequestVersion = HttpVersion.Version20; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; + var url = host.MakeUrl(Uri.UriSchemeHttps); + var response = await client.GetAsync(url).DefaultTimeout(); + + Assert.Equal(HttpVersion.Version20, response.Version); + Assert.Equal("Hello World", await response.Content.ReadAsStringAsync()); + await host.StopAsync().DefaultTimeout(); + } + private static HttpClient CreateClient() { var handler = new HttpClientHandler();