Skip to content

Commit c1d2ac7

Browse files
authored
Kestrel: Support full cert chain (#41944)
1 parent 3193e68 commit c1d2ac7

23 files changed

+1866
-35
lines changed

src/Security/Authorization/Policy/src/AuthorizationEndpointConventionBuilderExtensions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,5 +168,4 @@ private static void RequireAuthorizationCore<TBuilder>(TBuilder builder, IEnumer
168168
}
169169
});
170170
}
171-
172171
}

src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,21 @@ public HttpsConnectionAdapterOptions()
2929

3030
/// <summary>
3131
/// <para>
32-
/// Specifies the server certificate used to authenticate HTTPS connections. This is ignored if ServerCertificateSelector is set.
32+
/// Specifies the server certificate information presented when an https connection is initiated. This is ignored if ServerCertificateSelector is set.
3333
/// </para>
3434
/// <para>
3535
/// 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).
3636
/// </para>
3737
/// </summary>
3838
public X509Certificate2? ServerCertificate { get; set; }
3939

40+
/// <summary>
41+
/// <para>
42+
/// Specifies the full server certificate chain presented when an https connection is initiated
43+
/// </para>
44+
/// </summary>
45+
public X509Certificate2Collection? ServerCertificateChain { get; set; }
46+
4047
/// <summary>
4148
/// <para>
4249
/// A callback that will be invoked to dynamically select a server certificate. This is higher priority than ServerCertificate.

src/Servers/Kestrel/Core/src/Internal/Certificates/CertificateConfigLoader.cs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger<Kestrel
2323

2424
public bool IsTestMock => false;
2525

26-
public X509Certificate2? LoadCertificate(CertificateConfig? certInfo, string endpointName)
26+
public (X509Certificate2?, X509Certificate2Collection?) LoadCertificate(CertificateConfig? certInfo, string endpointName)
2727
{
2828
if (certInfo is null)
2929
{
30-
return null;
30+
return (null, null);
3131
}
3232

3333
if (certInfo.IsFileCert && certInfo.IsStoreCert)
@@ -37,6 +37,9 @@ public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger<Kestrel
3737
else if (certInfo.IsFileCert)
3838
{
3939
var certificatePath = Path.Combine(HostEnvironment.ContentRootPath, certInfo.Path!);
40+
var fullChain = new X509Certificate2Collection();
41+
fullChain.ImportFromPemFile(certificatePath);
42+
4043
if (certInfo.KeyPath != null)
4144
{
4245
var certificateKeyPath = Path.Combine(HostEnvironment.ContentRootPath, certInfo.KeyPath);
@@ -55,10 +58,10 @@ public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger<Kestrel
5558
{
5659
if (OperatingSystem.IsWindows())
5760
{
58-
return PersistKey(certificate);
61+
return (PersistKey(certificate), fullChain);
5962
}
6063

61-
return certificate;
64+
return (certificate, fullChain);
6265
}
6366
else
6467
{
@@ -68,14 +71,14 @@ public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger<Kestrel
6871
throw new InvalidOperationException(CoreStrings.InvalidPemKey);
6972
}
7073

71-
return new X509Certificate2(Path.Combine(HostEnvironment.ContentRootPath, certInfo.Path!), certInfo.Password);
74+
return (new X509Certificate2(Path.Combine(HostEnvironment.ContentRootPath, certInfo.Path!), certInfo.Password), fullChain);
7275
}
7376
else if (certInfo.IsStoreCert)
7477
{
75-
return LoadFromStoreCert(certInfo);
78+
return (LoadFromStoreCert(certInfo), null);
7679
}
7780

78-
return null;
81+
return (null, null);
7982
}
8083

8184
private static X509Certificate2 PersistKey(X509Certificate2 fullCertificate)

src/Servers/Kestrel/Core/src/Internal/Certificates/ICertificateConfigLoader.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ internal interface ICertificateConfigLoader
99
{
1010
bool IsTestMock { get; }
1111

12-
X509Certificate2? LoadCertificate(CertificateConfig? certInfo, string endpointName);
12+
(X509Certificate2?, X509Certificate2Collection?) LoadCertificate(CertificateConfig? certInfo, string endpointName);
1313
}

src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ public SniOptionsSelector(
4343

4444
foreach (var (name, sniConfig) in sniDictionary)
4545
{
46+
var (serverCert, fullChain) = certifcateConfigLoader.LoadCertificate(sniConfig.Certificate, $"{endpointName}:Sni:{name}");
4647
var sslOptions = new SslServerAuthenticationOptions
4748
{
48-
ServerCertificate = certifcateConfigLoader.LoadCertificate(sniConfig.Certificate, $"{endpointName}:Sni:{name}"),
49+
50+
ServerCertificate = serverCert,
4951
EnabledSslProtocols = sniConfig.SslProtocols ?? fallbackHttpsOptions.SslProtocols,
5052
CertificateRevocationCheckMode = fallbackHttpsOptions.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck,
5153
};
@@ -68,7 +70,7 @@ public SniOptionsSelector(
6870
{
6971
// This might be do blocking IO but it'll resolve the certificate chain up front before any connections are
7072
// made to the server
71-
sslOptions.ServerCertificateContext = SslStreamCertificateContext.Create((X509Certificate2)sslOptions.ServerCertificate, additionalCertificates: null);
73+
sslOptions.ServerCertificateContext = SslStreamCertificateContext.Create((X509Certificate2)sslOptions.ServerCertificate, additionalCertificates: fullChain);
7274
}
7375

7476
if (!certifcateConfigLoader.IsTestMock && sslOptions.ServerCertificate is X509Certificate2 cert2)

src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,8 +353,9 @@ public void Load()
353353
}
354354

355355
// A cert specified directly on the endpoint overrides any defaults.
356-
httpsOptions.ServerCertificate = CertificateConfigLoader.LoadCertificate(endpoint.Certificate, endpoint.Name)
357-
?? httpsOptions.ServerCertificate;
356+
var (serverCert, fullChain) = CertificateConfigLoader.LoadCertificate(endpoint.Certificate, endpoint.Name);
357+
httpsOptions.ServerCertificate = serverCert ?? httpsOptions.ServerCertificate;
358+
httpsOptions.ServerCertificateChain = fullChain ?? httpsOptions.ServerCertificateChain;
358359

359360
if (httpsOptions.ServerCertificate == null && httpsOptions.ServerCertificateSelector == null)
360361
{
@@ -423,7 +424,7 @@ private void LoadDefaultCert()
423424
{
424425
if (ConfigurationReader.Certificates.TryGetValue("Default", out var defaultCertConfig))
425426
{
426-
var defaultCert = CertificateConfigLoader.LoadCertificate(defaultCertConfig, "Default");
427+
var (defaultCert, _ /* cert chain */) = CertificateConfigLoader.LoadCertificate(defaultCertConfig, "Default");
427428
if (defaultCert != null)
428429
{
429430
DefaultCertificateConfig = defaultCertConfig;

src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapter
103103

104104
// This might be do blocking IO but it'll resolve the certificate chain up front before any connections are
105105
// made to the server
106-
_serverCertificateContext = SslStreamCertificateContext.Create(certificate, additionalCertificates: null);
106+
_serverCertificateContext = SslStreamCertificateContext.Create(certificate, additionalCertificates: options.ServerCertificateChain);
107107
}
108108

109109
var remoteCertificateValidationCallback = _options.ClientCertificateMode == ClientCertificateMode.NoCertificate ?
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions.ServerCertificateChain.get -> System.Security.Cryptography.X509Certificates.X509Certificate2Collection?
3+
Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions.ServerCertificateChain.set -> void

src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System;
5-
using System.Collections.Generic;
64
using System.IO.Pipelines;
7-
using System.Linq;
85
using System.Net.Security;
96
using System.Security.Authentication;
107
using System.Security.Cryptography.X509Certificates;
@@ -17,7 +14,6 @@
1714
using Microsoft.AspNetCore.Testing;
1815
using Microsoft.Extensions.Logging;
1916
using Moq;
20-
using Xunit;
2117

2218
namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests;
2319

@@ -186,6 +182,70 @@ public void ServerNameMatchingIsCaseInsensitive()
186182
Assert.Equal("WildcardPrefix", pathDictionary[aSubdomainOptions.ServerCertificate]);
187183
}
188184

185+
[Fact]
186+
public void FullChainCertsCanBeLoaded()
187+
{
188+
var sniDictionary = new Dictionary<string, SniConfig>
189+
{
190+
{
191+
"Www.Example.Org",
192+
new SniConfig
193+
{
194+
Certificate = new CertificateConfig
195+
{
196+
Path = "Exact"
197+
}
198+
}
199+
},
200+
{
201+
"*.Example.Org",
202+
new SniConfig
203+
{
204+
Certificate = new CertificateConfig
205+
{
206+
Path = "WildcardPrefix"
207+
}
208+
}
209+
}
210+
};
211+
212+
var mockCertificateConfigLoader = new MockCertificateConfigLoader();
213+
var pathDictionary = mockCertificateConfigLoader.CertToPathDictionary;
214+
var fullChainDictionary = mockCertificateConfigLoader.CertToFullChain;
215+
216+
var sniOptionsSelector = new SniOptionsSelector(
217+
"TestEndpointName",
218+
sniDictionary,
219+
mockCertificateConfigLoader,
220+
fallbackHttpsOptions: new HttpsConnectionAdapterOptions(),
221+
fallbackHttpProtocols: HttpProtocols.Http1AndHttp2,
222+
logger: Mock.Of<ILogger<HttpsConnectionMiddleware>>());
223+
224+
var (wwwSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "wWw.eXample.oRg");
225+
Assert.Equal("Exact", pathDictionary[wwwSubdomainOptions.ServerCertificate]);
226+
227+
var (baSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "B.a.eXample.oRg");
228+
Assert.Equal("WildcardPrefix", pathDictionary[baSubdomainOptions.ServerCertificate]);
229+
230+
var (aSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "A.eXample.oRg");
231+
Assert.Equal("WildcardPrefix", pathDictionary[aSubdomainOptions.ServerCertificate]);
232+
233+
/*
234+
* Chain test certs were created using smallstep cli: https://github.com/smallstep/cli
235+
* root_ca(pwd: testroot) ->
236+
* intermediate_ca 1(pwd: inter) ->
237+
* intermediate_ca 2(pwd: inter) ->
238+
* leaf.com(pwd: leaf) (bundled)
239+
*/
240+
var fullChain = fullChainDictionary[aSubdomainOptions.ServerCertificate];
241+
// Expect intermediate 2 cert and leaf.com
242+
Assert.Equal(2, fullChain.Count);
243+
Assert.Equal("CN=leaf.com", fullChain[0].Subject);
244+
Assert.Equal("CN=Test Intermediate CA 2", fullChain[0].IssuerName.Name);
245+
Assert.Equal("CN=Test Intermediate CA 2", fullChain[1].Subject);
246+
Assert.Equal("CN=Test Intermediate CA 1", fullChain[1].IssuerName.Name);
247+
}
248+
189249
[Fact]
190250
public void MultipleWildcardPrefixServerNamesOfSameLengthAreAllowed()
191251
{
@@ -848,19 +908,24 @@ public void CloneSslOptionsClonesAllProperties()
848908
private class MockCertificateConfigLoader : ICertificateConfigLoader
849909
{
850910
public Dictionary<object, string> CertToPathDictionary { get; } = new Dictionary<object, string>(ReferenceEqualityComparer.Instance);
911+
public Dictionary<object, X509Certificate2Collection> CertToFullChain { get; } = new Dictionary<object, X509Certificate2Collection>(ReferenceEqualityComparer.Instance);
851912

852913
public bool IsTestMock => true;
853914

854-
public X509Certificate2 LoadCertificate(CertificateConfig certInfo, string endpointName)
915+
public (X509Certificate2, X509Certificate2Collection) LoadCertificate(CertificateConfig certInfo, string endpointName)
855916
{
856917
if (certInfo is null)
857918
{
858-
return null;
919+
return (null, null);
859920
}
860921

861922
var cert = TestResources.GetTestCertificate();
862923
CertToPathDictionary.Add(cert, certInfo.Path);
863-
return cert;
924+
925+
var fullChain = TestResources.GetTestChain();
926+
CertToFullChain[cert] = fullChain;
927+
928+
return (cert, fullChain);
864929
}
865930
}
866931

src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System;
5-
using System.Collections.Generic;
6-
using System.IO;
7-
using System.Linq;
84
using System.Security.Authentication;
95
using System.Security.Cryptography;
106
using System.Security.Cryptography.X509Certificates;
@@ -16,7 +12,6 @@
1612
using Microsoft.Extensions.DependencyInjection;
1713
using Microsoft.Extensions.FileProviders;
1814
using Microsoft.Extensions.Hosting;
19-
using Xunit;
2015

2116
namespace Microsoft.AspNetCore.Server.Kestrel.Tests;
2217

@@ -140,6 +135,7 @@ public void ConfigureDefaultsAppliesToNewConfigureEndpoints()
140135
serverOptions.ConfigureHttpsDefaults(opt =>
141136
{
142137
opt.ServerCertificate = TestResources.GetTestCertificate();
138+
opt.ServerCertificateChain = TestResources.GetTestChain();
143139
opt.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
144140
});
145141

@@ -155,6 +151,8 @@ public void ConfigureDefaultsAppliesToNewConfigureEndpoints()
155151
ran1 = true;
156152
Assert.True(opt.IsHttps);
157153
Assert.NotNull(opt.HttpsOptions.ServerCertificate);
154+
Assert.NotNull(opt.HttpsOptions.ServerCertificateChain);
155+
Assert.Equal(2, opt.HttpsOptions.ServerCertificateChain.Count);
158156
Assert.Equal(ClientCertificateMode.RequireCertificate, opt.HttpsOptions.ClientCertificateMode);
159157
Assert.Equal(HttpProtocols.Http1, opt.ListenOptions.Protocols);
160158
})

0 commit comments

Comments
 (0)