Skip to content

[HTTPS] Adds PEM support for Kestrel #23584

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

Merged
merged 9 commits into from
Jul 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions src/Servers/Kestrel/Core/src/CoreStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -614,4 +614,10 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l
<data name="ArgumentTimeSpanGreaterOrEqual" xml:space="preserve">
<value>A TimeSpan value greater than or equal to {value} is required.</value>
</data>
<data name="InvalidPemKey" xml:space="preserve">
<value>The provided key file is missing or invalid.</value>
</data>
<data name="UnrecognizedCertificateKeyOid" xml:space="preserve">
<value>Unknown algorithm for certificate with public key type '{0}'.</value>
</data>
</root>
2 changes: 2 additions & 0 deletions src/Servers/Kestrel/Core/src/Internal/ConfigurationReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ public CertificateConfig(IConfigurationSection configSection)

public string Path { get; set; }

public string KeyPath { get; set; }

public string Password { get; set; }

// Cert store
Expand Down
15 changes: 15 additions & 0 deletions src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ internal static class LoggerExtensions
new EventId(5, "DeveloperCertificateFirstRun"),
"{Message}");

private static readonly Action<ILogger, string, Exception> _failedToLoadCertificate =
LoggerMessage.Define<string>(
LogLevel.Error,
new EventId(6, "MissingOrInvalidCertificateFile"),
"The certificate file at '{CertificateFilePath}' can not be found, contains malformed data or does not contain a certificate.");

private static readonly Action<ILogger, string, Exception> _failedToLoadCertificateKey =
LoggerMessage.Define<string>(
LogLevel.Error,
new EventId(7, "MissingOrInvalidCertificateKeyFile"),
"The certificate key file at '{CertificateKeyFilePath}' can not be found, contains malformed data or does not contain a PEM encoded key in PKCS8 format.");

public static void LocatedDevelopmentCertificate(this ILogger logger, X509Certificate2 certificate) => _locatedDevelopmentCertificate(logger, certificate.Subject, certificate.Thumbprint, null);

public static void UnableToLocateDevelopmentCertificate(this ILogger logger) => _unableToLocateDevelopmentCertificate(logger, null);
Expand All @@ -57,5 +69,8 @@ internal static class LoggerExtensions
public static void BadDeveloperCertificateState(this ILogger logger) => _badDeveloperCertificateState(logger, null);

public static void DeveloperCertificateFirstRun(this ILogger logger, string message) => _developerCertificateFirstRun(logger, message, null);

public static void FailedToLoadCertificate(this ILogger logger, string certificatePath) => _failedToLoadCertificate(logger, certificatePath, null);
public static void FailedToLoadCertificateKey(this ILogger logger, string certificateKeyPath) => _failedToLoadCertificateKey(logger, certificateKeyPath, null);
}
}
118 changes: 116 additions & 2 deletions src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.IO;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
Expand Down Expand Up @@ -429,20 +430,133 @@ private bool TryGetCertificatePath(out string path)

private X509Certificate2 LoadCertificate(CertificateConfig certInfo, string endpointName)
{
var logger = Options.ApplicationServices.GetRequiredService<ILogger<KestrelConfigurationLoader>>();
if (certInfo.IsFileCert && certInfo.IsStoreCert)
{
throw new InvalidOperationException(CoreStrings.FormatMultipleCertificateSources(endpointName));
}
else if (certInfo.IsFileCert)
{
var env = Options.ApplicationServices.GetRequiredService<IHostEnvironment>();
return new X509Certificate2(Path.Combine(env.ContentRootPath, certInfo.Path), certInfo.Password);
var environment = Options.ApplicationServices.GetRequiredService<IHostEnvironment>();
var certificatePath = Path.Combine(environment.ContentRootPath, certInfo.Path);
if (certInfo.KeyPath != null)
{
var certificateKeyPath = Path.Combine(environment.ContentRootPath, certInfo.KeyPath);
var certificate = GetCertificate(certificatePath);

if (certificate != null)
{
certificate = LoadCertificateKey(certificate, certificateKeyPath, certInfo.Password);
}
else
{
logger.FailedToLoadCertificate(certificateKeyPath);
}

if (certificate != null)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return PersistKey(certificate);
}

return certificate;
}
else
{
logger.FailedToLoadCertificateKey(certificateKeyPath);
}

throw new InvalidOperationException(CoreStrings.InvalidPemKey);
Copy link
Member

Choose a reason for hiding this comment

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

This could throw if the cert is missing, not just because the key was missing or invalid right? This exception message implies the key must be the problem?

And why do we use the exact same exception and log message any time the key is missing or invalid? It would be a lot better to log exactly why the key is invalid and to be very clear when the key is actually missing vs being invalid in both the exception and log messages.

}

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

static X509Certificate2 PersistKey(X509Certificate2 fullCertificate)
{
// We need to force the key to be persisted.
// See https://github.com/dotnet/runtime/issues/23749
var certificateBytes = fullCertificate.Export(X509ContentType.Pkcs12, "");
return new X509Certificate2(certificateBytes, "", X509KeyStorageFlags.DefaultKeySet);
}

static X509Certificate2 LoadCertificateKey(X509Certificate2 certificate, string keyPath, string password)
{
// OIDs for the certificate key types.
const string RSAOid = "1.2.840.113549.1.1.1";
const string DSAOid = "1.2.840.10040.4.1";
const string ECDsaOid = "1.2.840.10045.2.1";

var keyText = File.ReadAllText(keyPath);
return certificate.PublicKey.Oid.Value switch
{
RSAOid => AttachPemRSAKey(certificate, keyText, password),
ECDsaOid => AttachPemECDSAKey(certificate, keyText, password),
DSAOid => AttachPemDSAKey(certificate, keyText, password),
_ => throw new InvalidOperationException(string.Format(CoreStrings.UnrecognizedCertificateKeyOid, certificate.PublicKey.Oid.Value))
};
}

static X509Certificate2 GetCertificate(string certificatePath)
{
if (X509Certificate2.GetCertContentType(certificatePath) == X509ContentType.Cert)
{
return new X509Certificate2(certificatePath);
}

return null;
}
}

private static X509Certificate2 AttachPemRSAKey(X509Certificate2 certificate, string keyText, string password)
{
using var rsa = RSA.Create();
if (password == null)
{
rsa.ImportFromPem(keyText);
}
else
{
rsa.ImportFromEncryptedPem(keyText, password);
}

return certificate.CopyWithPrivateKey(rsa);
}

private static X509Certificate2 AttachPemDSAKey(X509Certificate2 certificate, string keyText, string password)
{
using var dsa = DSA.Create();
if (password == null)
{
dsa.ImportFromPem(keyText);
}
else
{
dsa.ImportFromEncryptedPem(keyText, password);
}

return certificate.CopyWithPrivateKey(dsa);
}

private static X509Certificate2 AttachPemECDSAKey(X509Certificate2 certificate, string keyText, string password)
{
using var ecdsa = ECDsa.Create();
if (password == null)
{
ecdsa.ImportFromPem(keyText);
}
else
{
ecdsa.ImportFromEncryptedPem(keyText, password);
}

return certificate.CopyWithPrivateKey(ecdsa);
}

private static X509Certificate2 LoadFromStoreCert(CertificateConfig certInfo)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<Compile Include="$(KestrelSharedSourceRoot)test\*.cs" LinkBase="shared" />
<Compile Include="$(KestrelSharedSourceRoot)KnownHeaders.cs" LinkBase="shared" />
<Content Include="$(KestrelSharedSourceRoot)test\TestCertificates\*.pfx" LinkBase="shared\TestCertificates" CopyToOutputDirectory="PreserveNewest" />
<Content Include="$(KestrelSharedSourceRoot)test\TestCertificates\*.crt" LinkBase="shared\TestCertificates" CopyToOutputDirectory="PreserveNewest" />
<Content Include="$(KestrelSharedSourceRoot)test\TestCertificates\*.key" LinkBase="shared\TestCertificates" CopyToOutputDirectory="PreserveNewest" />
<Compile Include="$(RepoRoot)src\Shared\Buffers.MemoryPool\*.cs" LinkBase="MemoryPool" />
<Compile Include="$(KestrelSharedSourceRoot)\CorrelationIdGenerator.cs" Link="Internal\CorrelationIdGenerator.cs" />
<Compile Include="$(SharedSourceRoot)test\Shared.Tests\runtime\**\*.cs" Link="Shared\runtime\%(Filename)%(Extension)" />
Expand Down
118 changes: 117 additions & 1 deletion src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.IO;
using System.Linq;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Microsoft.AspNetCore.Hosting;
Expand All @@ -25,7 +26,7 @@ public class KestrelConfigurationLoaderTests
private KestrelServerOptions CreateServerOptions()
{
var serverOptions = new KestrelServerOptions();
var env = new MockHostingEnvironment { ApplicationName = "TestApplication" };
var env = new MockHostingEnvironment { ApplicationName = "TestApplication", ContentRootPath = Directory.GetCurrentDirectory() };
serverOptions.ApplicationServices = new ServiceCollection()
.AddLogging()
.AddSingleton<IHostEnvironment>(env)
Expand Down Expand Up @@ -254,6 +255,121 @@ public void ConfigureEndpointDevelopmentCertificateGetsLoadedWhenPresent()
}
}

[Fact]
public void ConfigureEndpoint_ThrowsWhen_The_PasswordIsMissing()
{
var serverOptions = CreateServerOptions();
var certificate = new X509Certificate2(TestResources.GetCertPath("https-aspnet.crt"));

var config = new ConfigurationBuilder().AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("Endpoints:End1:Url", "https://*:5001"),
new KeyValuePair<string, string>("Certificates:Default:Path", Path.Combine("shared", "TestCertificates", "https-aspnet.crt")),
new KeyValuePair<string, string>("Certificates:Default:KeyPath", Path.Combine("shared", "TestCertificates", "https-aspnet.key"))
}).Build();

var ex = Assert.Throws<ArgumentException>(() =>
{
serverOptions
.Configure(config)
.Endpoint("End1", opt =>
{
Assert.True(opt.IsHttps);
}).Load();
});
}

[Fact]
public void ConfigureEndpoint_ThrowsWhen_TheKeyDoesntMatchTheCertificateKey()
{
var serverOptions = CreateServerOptions();
var certificate = new X509Certificate2(TestResources.GetCertPath("https-aspnet.crt"));

var config = new ConfigurationBuilder().AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("Endpoints:End1:Url", "https://*:5001"),
new KeyValuePair<string, string>("Certificates:Default:Path", Path.Combine("shared", "TestCertificates", "https-aspnet.crt")),
new KeyValuePair<string, string>("Certificates:Default:KeyPath", Path.Combine("shared", "TestCertificates", "https-ecdsa.key")),
new KeyValuePair<string, string>("Certificates:Default:Password", "aspnetcore")
}).Build();

var ex = Assert.Throws<ArgumentException>(() =>
{
serverOptions
.Configure(config)
.Endpoint("End1", opt =>
{
Assert.True(opt.IsHttps);
}).Load();
});
}

[Fact]
public void ConfigureEndpoint_ThrowsWhen_The_PasswordIsIncorrect()
{
var serverOptions = CreateServerOptions();
var certificate = new X509Certificate2(TestResources.GetCertPath("https-aspnet.crt"));

var config = new ConfigurationBuilder().AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("Endpoints:End1:Url", "https://*:5001"),
new KeyValuePair<string, string>("Certificates:Default:Path", Path.Combine("shared", "TestCertificates", "https-aspnet.crt")),
new KeyValuePair<string, string>("Certificates:Default:KeyPath", Path.Combine("shared", "TestCertificates", "https-aspnet.key")),
new KeyValuePair<string, string>("Certificates:Default:Password", "abcde"),
}).Build();

var ex = Assert.Throws<CryptographicException>(() =>
{
serverOptions
.Configure(config)
.Endpoint("End1", opt =>
{
Assert.True(opt.IsHttps);
}).Load();
});
}

[Theory]
[InlineData("https-rsa.pem", "https-rsa.key", null)]
[InlineData("https-rsa.pem", "https-rsa-protected.key", "aspnetcore")]
[InlineData("https-rsa.crt", "https-rsa.key", null)]
[InlineData("https-rsa.crt", "https-rsa-protected.key", "aspnetcore")]
[InlineData("https-ecdsa.pem", "https-ecdsa.key", null)]
[InlineData("https-ecdsa.pem", "https-ecdsa-protected.key", "aspnetcore")]
[InlineData("https-ecdsa.crt", "https-ecdsa.key", null)]
[InlineData("https-ecdsa.crt", "https-ecdsa-protected.key", "aspnetcore")]
[InlineData("https-dsa.pem", "https-dsa.key", null)]
[InlineData("https-dsa.pem", "https-dsa-protected.key", "test")]
[InlineData("https-dsa.crt", "https-dsa.key", null)]
[InlineData("https-dsa.crt", "https-dsa-protected.key", "test")]
public void ConfigureEndpoint_CanLoadPemCertificates(string certificateFile, string certificateKey, string password)
{
var serverOptions = CreateServerOptions();
var certificate = new X509Certificate2(TestResources.GetCertPath(Path.ChangeExtension(certificateFile, "crt")));

var ran1 = false;
var config = new ConfigurationBuilder().AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("Endpoints:End1:Url", "https://*:5001"),
new KeyValuePair<string, string>("Certificates:Default:Path", Path.Combine("shared", "TestCertificates", certificateFile)),
new KeyValuePair<string, string>("Certificates:Default:KeyPath", Path.Combine("shared", "TestCertificates", certificateKey)),
}
.Concat(password != null ? new[] { new KeyValuePair<string, string>("Certificates:Default:Password", password) } : Array.Empty<KeyValuePair<string, string>>()))
.Build();

serverOptions
.Configure(config)
.Endpoint("End1", opt =>
{
ran1 = true;
Assert.True(opt.IsHttps);
Assert.Equal(opt.HttpsOptions.ServerCertificate.SerialNumber, certificate.SerialNumber);
}).Load();

Assert.True(ran1);
Assert.NotNull(serverOptions.DefaultCertificate);
}

[Fact]
public void ConfigureEndpointDevelopmentCertificateGetsIgnoredIfPasswordIsNotCorrect()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
<Compile Include="$(SharedSourceRoot)NullScope.cs" />
<Compile Include="$(KestrelSharedSourceRoot)test\*.cs" LinkBase="shared" />
<Content Include="$(KestrelSharedSourceRoot)test\TestCertificates\*.pfx" LinkBase="shared\TestCertificates" CopyToOutputDirectory="PreserveNewest" />
<Content Include="$(KestrelSharedSourceRoot)test\TestCertificates\*.crt" LinkBase="shared\TestCertificates" CopyToOutputDirectory="PreserveNewest" />
<Content Include="$(KestrelSharedSourceRoot)test\TestCertificates\*.key" LinkBase="shared\TestCertificates" CopyToOutputDirectory="PreserveNewest" />
<Content Include="$(KestrelSharedSourceRoot)test\TestCertificates\*.pem" LinkBase="shared\TestCertificates" CopyToOutputDirectory="PreserveNewest" />
<Content Include="$(KestrelRoot)Core\src\Internal\Http\HttpHeaders.Generated.cs" LinkBase="shared\GeneratedContent" CopyToOutputDirectory="PreserveNewest" />
<Content Include="$(KestrelRoot)Core\src\Internal\Http\HttpProtocol.Generated.cs" LinkBase="shared\GeneratedContent" CopyToOutputDirectory="PreserveNewest" />
<Content Include="$(KestrelRoot)Core\src\Internal\Infrastructure\HttpUtilities.Generated.cs" LinkBase="shared\GeneratedContent" CopyToOutputDirectory="PreserveNewest" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.key binary
*.pem binary
Binary file not shown.
30 changes: 30 additions & 0 deletions src/Servers/Kestrel/shared/test/TestCertificates/https-aspnet.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFNjBgBgkqhkiG9w0BBQ0wUzAyBgkqhkiG9w0BBQwwJQQQ93oRxzJ5UoNOb/zN
x5cdsAIDAYagMAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBAuHsE18X/Z9ZVe
aBl7C55nBIIE0AABqjc9ERcLYNpCRpA6c/TFG62m4Mr9J4dU4g1WD07t7uLxZiRi
Pl0YOjCulljMsevAW5PlLxi4ffJ+I0/UB1WOfzEMhcj7o1qG0Uv55B7WRuWKw1Zr
jo0bDY5Man48ZjpqMMBdnWhyHdIDm+WD0OyN98mpsN6SCQMjvx91M+klrsp7LOMM
88HHS0RFVKGF9hYSy6rCwMJWf+7QGO2wXfq+MKvJ/bBgPGDLwN4phUCyocnR0swD
/XZNiiw0xIC8OxAKhc6BV4AJkjNs32THdBOCGY6B4P/9Zo5W29S3ja/hGsMQAA27
QtIDg74HpX7TgIyqoc1oiLNIWW/+jUHSEYJsTPlg5VYWsXUfSHZpz8EJvKt2tyvt
vBGOCLDDZD4GVXhPigKG6zJSJeTe94/VlwPhNSEucKeaALdax5t3HvPNzWKFX57E
aC82/IxRrjgHmgsGSZdMi08HY6K9GAVBFpIGvXOGtRq7w8zO/KagAvSwAOLLtOs7
iEuAQxD+cKLRT59c4E7r5W7BT+faq85ovqdXe5Edtl3cT81zsl27pZvQrcrTPbZe
4OeIdWxOmOnC/bXvRHNd9XuYadXXazBoFbe9yPwjqnflEh39CyvlOZXeaQXSdsEM
1IBhddRTorO/I8M/znu9glqIa5ya1NA+4ujmf4OnJLtsrlKQa65VPVTrFdeYuMr0
VfOuuIye2OdyJ6jS0a1PYQm4bEEz6UR88dnmnhDx6i8/l2wW5+CArA/x8IBYboBM
NJpJY9bHpic1AhjnjnTtFz2s4uYPi5g9peBizarZn+6OJvgYqs4a8SI92dA3E2o4
a/1j7xlLlgXnVRLBMibxqzjMt4Zt7Nj+BaN1owrB/q04AWS2M4TSQz+NYOZwNFxB
dzb+fysTLK5XNEYq6rSg+0i+EKZl8Jb/t4d8SLPVr/tdfDt9BtZ0nTgjvy1HWy1p
kQdm13XfK1/9KsePH/Jb6dvN/u6ubV+ZqI7Bc7VyTi0bKMdpH2K8/dtopNyDZ/P+
/IsyyDYTorgJB/klSih/W0hqpSBbEAmlSBfBxP1/ozBEGR2oF20JOCFyD6UXQR/1
V7r2KtplpyfXaIWh4fABitAMHz7VgmEIQ2H9cB4Ey9jdRPQ/1p+OgGjfaFJQ0uYM
987TDtjkuukJYnPZNIIx0Yv3iAX16XmhzJixWSMUIJiWfSiz0aTjBxsPQVPTQV+M
6BgFf3riBApZYlVVJsGIie2XTvu/tHRhfQrxccl63HN7yAeJheQnoscin6Z5TKN/
U8Ouy/QGiATatKUEUjr4lN+BYySf8F6e3cAAeAx/ZnFvGw5z8fwNYBjVWg/83bTw
9rS+tSk8VsvTdkcKoNbbDtw+SwYfZSbMUBFm0B13190iJZoyWI+5ZKPnZ2CvOZhX
PjGTOnh6Diq907l2Q7S/v8SLe0bCHCHVBy+CcPWVDZ6Z7V5cJ/W8TvFPcSGw1UCl
tKPp862uDaPKvGxqGDq0vGouEUrtJKZ279Lnrtz1n8raUj0Gxa+KXqLACh8dXCzK
ZgCTPhfAjZcYgA73edW0whNNH9MNInDGulT/arCK3HTkFPczD+7wA8Ojw/LxKFJs
0d8vtILbmLv46CO+wvIdWrW1c7PCrGJDf9Zuw06vIH7hpW9swSM55k9/
-----END ENCRYPTED PRIVATE KEY-----
Loading