Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Kestrel: Support full cert chain #41944

Merged
merged 30 commits into from
Aug 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -168,5 +168,4 @@ private static void RequireAuthorizationCore<TBuilder>(TBuilder builder, IEnumer
}
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,21 @@ public HttpsConnectionAdapterOptions()

/// <summary>
/// <para>
/// Specifies the server certificate used to authenticate HTTPS connections. This is ignored if ServerCertificateSelector is set.
/// Specifies the server certificate information presented when an https connection is initiated. This is ignored if ServerCertificateSelector is set.
/// </para>
/// <para>
/// 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).
/// </para>
/// </summary>
public X509Certificate2? ServerCertificate { get; set; }

/// <summary>
/// <para>
/// Specifies the full server certificate chain presented when an https connection is initiated
/// </para>
/// </summary>
public X509Certificate2Collection? ServerCertificateChain { get; set; }

/// <summary>
/// <para>
/// A callback that will be invoked to dynamically select a server certificate. This is higher priority than ServerCertificate.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger<Kestrel

public bool IsTestMock => false;

public X509Certificate2? LoadCertificate(CertificateConfig? certInfo, string endpointName)
public (X509Certificate2?, X509Certificate2Collection?) LoadCertificate(CertificateConfig? certInfo, string endpointName)
{
if (certInfo is null)
{
return null;
return (null, null);
}

if (certInfo.IsFileCert && certInfo.IsStoreCert)
Expand All @@ -37,6 +37,9 @@ public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger<Kestrel
else if (certInfo.IsFileCert)
{
var certificatePath = Path.Combine(HostEnvironment.ContentRootPath, certInfo.Path!);
var fullChain = new X509Certificate2Collection();
fullChain.ImportFromPemFile(certificatePath);

if (certInfo.KeyPath != null)
{
var certificateKeyPath = Path.Combine(HostEnvironment.ContentRootPath, certInfo.KeyPath);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this if block, you could just make certificate be fullChain[0] (assuming it's non-empty), and then you could remove certificate from the collection; depending on what public API guarantees you're making.

The two halves of that sentence are:

  • We've already done the work of loading the certificate instance, why go open the file and process the contents a second time?
  • The perf of a chain build will be marginally faster if you remove unnecessary elements. Since the target certificate is already specified as the target it won't need to be found from the collection. By removing it early you save a "nope, not the one I'm looking for, next!"

If you don't remove it, because you want the "full" chain to be in the HttpsOptions.ServerCertificateChain collection, then you have to decide if you want to have the same instance in the property and the collection. If "yes" to the full chain and "no" to the same instance... then leave the code as-is 😄.

And the reason I put "full" in quotes is that I don't think Let's Encrypt puts their root cert in that file, so the "full" chain is really "the chain except the root". But it's as "full" as that file is, I suppose.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adityamandaleeka @blowdart @davidfowl @halter73 I can't say I have anywhere near enough context for this feature request/area to be able to personally decide how this should work...

Do we want to just vote or something? Personally I like what @bartonjs is suggesting, i.e. we just use fullChain[0] and remove the cert from the collection.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea remove it. A chain is everything above the leaf in my mind. Need to document carefully though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a bunch of research here a year ago and I can dig up my notes. When you get the certs from certbot for lets encrypt you can get both the fullchain.pem or the chain.pem + cert.pem (https://community.letsencrypt.org/t/generating-cert-pem-chain-pem-and-fullchain-pem-from-order-certificate/78376/6).

We should also support loading the full chai from PFX files if it is there. NGINX supports the full chain as well (https://serverfault.com/questions/987612/nginx-ssl-config-for-cert-pem-and-chain-pem).

I'd like us to support both providing just the intermediates without the leaf cert and the full chain and we can remove the leaf cert (assuming this is easy). That makes us a bit more friendly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the nuances here. I updated the PR to Jeremy's suggestion so we remove the cert from the chain if we have a key path.

Is pfx support something different or does that just work?

I'd like us to support both providing just the intermediates without the leaf cert and the full chain and we can remove the leaf cert (assuming this is easy). That makes us a bit more friendly.

The PR currently only supports full chain right? So is the intermediates only scenario we don't assume fullChain[0] is the leaf?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some differences in using the cert from the chain vs the current GetCertificate method, as a bunch of tests now fail. So I think I'm just leave this code alone for now, and I will file a new issue to track @davidfowl and @bartonjs suggestions which I'd imagine we can take in rc2 still. I just want to try and get something in for rc1 this week

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bartonjs so the MacOS error is: "Status: One or more certificates required to validate this certificate cannot be found." This is a bit weird since this works fine on ubuntu and windows, but I know we've skipped on MacOS many of our other cert tests today... I'm tempted to just skip this on macOS and file an issue to look at this later, unless this is something that is indicative of something seriously being broken

I filed #43193 to track resolving the full chain/intermediate/removing a cert.

I think many tests were all failing because the new test I copied from runtime was nuking all of our test certs which broke all the other tests as part of its cleanup, I scoped its cleanup to only the certs it was creating and things seem happier now.

Expand All @@ -55,10 +58,10 @@ public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger<Kestrel
{
if (OperatingSystem.IsWindows())
{
return PersistKey(certificate);
return (PersistKey(certificate), fullChain);
}

return certificate;
return (certificate, fullChain);
}
else
{
Expand All @@ -68,14 +71,14 @@ public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger<Kestrel
throw new InvalidOperationException(CoreStrings.InvalidPemKey);
}

return new X509Certificate2(Path.Combine(HostEnvironment.ContentRootPath, certInfo.Path!), certInfo.Password);
return (new X509Certificate2(Path.Combine(HostEnvironment.ContentRootPath, certInfo.Path!), certInfo.Password), fullChain);
}
else if (certInfo.IsStoreCert)
{
return LoadFromStoreCert(certInfo);
return (LoadFromStoreCert(certInfo), null);
}

return null;
return (null, null);
}

private static X509Certificate2 PersistKey(X509Certificate2 fullCertificate)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ internal interface ICertificateConfigLoader
{
bool IsTestMock { get; }

X509Certificate2? LoadCertificate(CertificateConfig? certInfo, string endpointName);
(X509Certificate2?, X509Certificate2Collection?) LoadCertificate(CertificateConfig? certInfo, string endpointName);
}
6 changes: 4 additions & 2 deletions src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ public SniOptionsSelector(

foreach (var (name, sniConfig) in sniDictionary)
{
var (serverCert, fullChain) = certifcateConfigLoader.LoadCertificate(sniConfig.Certificate, $"{endpointName}:Sni:{name}");
var sslOptions = new SslServerAuthenticationOptions
{
ServerCertificate = certifcateConfigLoader.LoadCertificate(sniConfig.Certificate, $"{endpointName}:Sni:{name}"),

ServerCertificate = serverCert,
EnabledSslProtocols = sniConfig.SslProtocols ?? fallbackHttpsOptions.SslProtocols,
CertificateRevocationCheckMode = fallbackHttpsOptions.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck,
};
Expand All @@ -68,7 +70,7 @@ public SniOptionsSelector(
{
// This might be do blocking IO but it'll resolve the certificate chain up front before any connections are
// made to the server
sslOptions.ServerCertificateContext = SslStreamCertificateContext.Create((X509Certificate2)sslOptions.ServerCertificate, additionalCertificates: null);
sslOptions.ServerCertificateContext = SslStreamCertificateContext.Create((X509Certificate2)sslOptions.ServerCertificate, additionalCertificates: fullChain);
}

if (!certifcateConfigLoader.IsTestMock && sslOptions.ServerCertificate is X509Certificate2 cert2)
Expand Down
7 changes: 4 additions & 3 deletions src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,9 @@ public void Load()
}

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

if (httpsOptions.ServerCertificate == null && httpsOptions.ServerCertificateSelector == null)
{
Expand Down Expand Up @@ -423,7 +424,7 @@ private void LoadDefaultCert()
{
if (ConfigurationReader.Certificates.TryGetValue("Default", out var defaultCertConfig))
{
var defaultCert = CertificateConfigLoader.LoadCertificate(defaultCertConfig, "Default");
var (defaultCert, _ /* cert chain */) = CertificateConfigLoader.LoadCertificate(defaultCertConfig, "Default");
if (defaultCert != null)
{
DefaultCertificateConfig = defaultCertConfig;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapter

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

var remoteCertificateValidationCallback = _options.ClientCertificateMode == ClientCertificateMode.NoCertificate ?
Expand Down
2 changes: 2 additions & 0 deletions src/Servers/Kestrel/Core/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions.ServerCertificateChain.get -> System.Security.Cryptography.X509Certificates.X509Certificate2Collection?
Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions.ServerCertificateChain.set -> void
79 changes: 72 additions & 7 deletions src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.IO.Pipelines;
using System.Linq;
using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
Expand All @@ -17,7 +14,6 @@
using Microsoft.AspNetCore.Testing;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;

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

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

[Fact]
public void FullChainCertsCanBeLoaded()
{
var sniDictionary = new Dictionary<string, SniConfig>
{
{
"Www.Example.Org",
new SniConfig
{
Certificate = new CertificateConfig
{
Path = "Exact"
}
}
},
{
"*.Example.Org",
new SniConfig
{
Certificate = new CertificateConfig
{
Path = "WildcardPrefix"
}
}
}
};

var mockCertificateConfigLoader = new MockCertificateConfigLoader();
var pathDictionary = mockCertificateConfigLoader.CertToPathDictionary;
var fullChainDictionary = mockCertificateConfigLoader.CertToFullChain;

var sniOptionsSelector = new SniOptionsSelector(
"TestEndpointName",
sniDictionary,
mockCertificateConfigLoader,
fallbackHttpsOptions: new HttpsConnectionAdapterOptions(),
fallbackHttpProtocols: HttpProtocols.Http1AndHttp2,
logger: Mock.Of<ILogger<HttpsConnectionMiddleware>>());

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");
Assert.Equal("WildcardPrefix", pathDictionary[baSubdomainOptions.ServerCertificate]);

var (aSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "A.eXample.oRg");
Assert.Equal("WildcardPrefix", pathDictionary[aSubdomainOptions.ServerCertificate]);

/*
* Chain test certs were created using smallstep cli: https://github.com/smallstep/cli
* root_ca(pwd: testroot) ->
* intermediate_ca 1(pwd: inter) ->
* intermediate_ca 2(pwd: inter) ->
* leaf.com(pwd: leaf) (bundled)
*/
var fullChain = fullChainDictionary[aSubdomainOptions.ServerCertificate];
// Expect intermediate 2 cert and leaf.com
Assert.Equal(2, fullChain.Count);
Assert.Equal("CN=leaf.com", fullChain[0].Subject);
Assert.Equal("CN=Test Intermediate CA 2", fullChain[0].IssuerName.Name);
Assert.Equal("CN=Test Intermediate CA 2", fullChain[1].Subject);
Assert.Equal("CN=Test Intermediate CA 1", fullChain[1].IssuerName.Name);
}

[Fact]
public void MultipleWildcardPrefixServerNamesOfSameLengthAreAllowed()
{
Expand Down Expand Up @@ -848,19 +908,24 @@ public void CloneSslOptionsClonesAllProperties()
private class MockCertificateConfigLoader : ICertificateConfigLoader
{
public Dictionary<object, string> CertToPathDictionary { get; } = new Dictionary<object, string>(ReferenceEqualityComparer.Instance);
public Dictionary<object, X509Certificate2Collection> CertToFullChain { get; } = new Dictionary<object, X509Certificate2Collection>(ReferenceEqualityComparer.Instance);

public bool IsTestMock => true;

public X509Certificate2 LoadCertificate(CertificateConfig certInfo, string endpointName)
public (X509Certificate2, X509Certificate2Collection) LoadCertificate(CertificateConfig certInfo, string endpointName)
{
if (certInfo is null)
{
return null;
return (null, null);
}

var cert = TestResources.GetTestCertificate();
CertToPathDictionary.Add(cert, certInfo.Path);
return cert;

var fullChain = TestResources.GetTestChain();
CertToFullChain[cert] = fullChain;

return (cert, fullChain);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
Expand All @@ -16,7 +12,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Xunit;

namespace Microsoft.AspNetCore.Server.Kestrel.Tests;

Expand Down Expand Up @@ -140,6 +135,7 @@ public void ConfigureDefaultsAppliesToNewConfigureEndpoints()
serverOptions.ConfigureHttpsDefaults(opt =>
{
opt.ServerCertificate = TestResources.GetTestCertificate();
opt.ServerCertificateChain = TestResources.GetTestChain();
opt.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
});

Expand All @@ -155,6 +151,8 @@ public void ConfigureDefaultsAppliesToNewConfigureEndpoints()
ran1 = true;
Assert.True(opt.IsHttps);
Assert.NotNull(opt.HttpsOptions.ServerCertificate);
Assert.NotNull(opt.HttpsOptions.ServerCertificateChain);
Assert.Equal(2, opt.HttpsOptions.ServerCertificateChain.Count);
Assert.Equal(ClientCertificateMode.RequireCertificate, opt.HttpsOptions.ClientCertificateMode);
Assert.Equal(HttpProtocols.Http1, opt.ListenOptions.Protocols);
})
Expand Down
Loading