From e6bb55101893142f92912c748e66115bc4aacdb4 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 8 Jan 2018 10:28:38 -0800 Subject: [PATCH] Adds support for loading the developer certificate from a pfx file * If we can't find a developer certificate on the certificate store we will look for a developer certificate on the file system if a password has been specified for the Development certificate. * We will look at ${APPDATA}/ASP.NET/https/<>.pfx for windows and fallback to ${HOME}/.aspnet/https/<>.pfx * In case the password wasn't specified through configuration, the file is not found on the file system or can't be loaded, we won't do anything. --- .../Internal/KestrelServerOptionsSetup.cs | 15 ++- src/Kestrel.Core/Internal/LoggerExtensions.cs | 10 ++ .../KestrelConfigurationLoader.cs | 76 +++++++++++- .../KestrelConfigurationBuilderTests.cs | 115 ++++++++++++++++++ .../shared/TestCertificates/aspnetdevcert.pfx | Bin 0 -> 2700 bytes 5 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 test/shared/TestCertificates/aspnetdevcert.pfx diff --git a/src/Kestrel.Core/Internal/KestrelServerOptionsSetup.cs b/src/Kestrel.Core/Internal/KestrelServerOptionsSetup.cs index 18831f0b49..013ce0ac80 100644 --- a/src/Kestrel.Core/Internal/KestrelServerOptionsSetup.cs +++ b/src/Kestrel.Core/Internal/KestrelServerOptionsSetup.cs @@ -29,10 +29,19 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal private void UseDefaultDeveloperCertificate(KestrelServerOptions options) { - var certificateManager = new CertificateManager(); - var certificate = certificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true) - .FirstOrDefault(); var logger = options.ApplicationServices.GetRequiredService>(); + X509Certificate2 certificate = null; + try + { + var certificateManager = new CertificateManager(); + certificate = certificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true) + .FirstOrDefault(); + } + catch + { + logger.UnableToLocateDevelopmentCertificate(); + } + if (certificate != null) { logger.LocatedDevelopmentCertificate(certificate); diff --git a/src/Kestrel.Core/Internal/LoggerExtensions.cs b/src/Kestrel.Core/Internal/LoggerExtensions.cs index 1f3b8d131d..0a4c6f3a5e 100644 --- a/src/Kestrel.Core/Internal/LoggerExtensions.cs +++ b/src/Kestrel.Core/Internal/LoggerExtensions.cs @@ -13,8 +13,18 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal private static readonly Action _unableToLocateDevelopmentCertificate = LoggerMessage.Define(LogLevel.Debug, new EventId(1, nameof(UnableToLocateDevelopmentCertificate)), "Unable to locate an appropriate development https certificate."); + private static readonly Action _failedToLocateDevelopmentCertificateFile = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, nameof(FailedToLocateDevelopmentCertificateFile)), "Failed to locate the development https certificate at '{certificatePath}'."); + + private static readonly Action _failedToLoadDevelopmentCertificate = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, nameof(FailedToLoadDevelopmentCertificate)), "Failed to load the development https certificate at '{certificatePath}'."); + 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); + + public static void FailedToLocateDevelopmentCertificateFile(this ILogger logger, string certificatePath) => _failedToLocateDevelopmentCertificateFile(logger, certificatePath, null); + + public static void FailedToLoadDevelopmentCertificate(this ILogger logger, string certificatePath) => _failedToLoadDevelopmentCertificate(logger, certificatePath, null); } } diff --git a/src/Kestrel.Core/KestrelConfigurationLoader.cs b/src/Kestrel.Core/KestrelConfigurationLoader.cs index 1dfb9d262c..71337a11d2 100644 --- a/src/Kestrel.Core/KestrelConfigurationLoader.cs +++ b/src/Kestrel.Core/KestrelConfigurationLoader.cs @@ -6,14 +6,18 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Certificates.Generation; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Internal; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Server.Kestrel { @@ -205,7 +209,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel var configReader = new ConfigurationReader(Configuration); LoadDefaultCert(configReader); - + foreach (var endpoint in configReader.Endpoints) { var listenOptions = AddressBinder.ParseAddress(endpoint.Url, out var https); @@ -259,6 +263,76 @@ namespace Microsoft.AspNetCore.Server.Kestrel Options.DefaultCertificate = defaultCert; } } + else + { + var logger = Options.ApplicationServices.GetRequiredService>(); + var certificate = FindDeveloperCertificateFile(configReader, logger); + if (certificate != null) + { + logger.LocatedDevelopmentCertificate(certificate); + Options.DefaultCertificate = certificate; + } + } + } + + private X509Certificate2 FindDeveloperCertificateFile(ConfigurationReader configReader, ILogger logger) + { + string certificatePath = null; + try + { + if (configReader.Certificates.TryGetValue("Development", out var certificateConfig) && + certificateConfig.Path == null && + certificateConfig.Password != null && + TryGetCertificatePath(out certificatePath) && + File.Exists(certificatePath)) + { + var certificate = new X509Certificate2(certificatePath, certificateConfig.Password); + return IsDevelopmentCertificate(certificate) ? certificate : null; + } + else if (!File.Exists(certificatePath)) + { + logger.FailedToLocateDevelopmentCertificateFile(certificatePath); + } + } + catch (CryptographicException) + { + logger.FailedToLoadDevelopmentCertificate(certificatePath); + } + + return null; + } + + private bool IsDevelopmentCertificate(X509Certificate2 certificate) + { + if (!string.Equals(certificate.Subject, "CN=localhost", StringComparison.Ordinal)) + { + return false; + } + + foreach (var ext in certificate.Extensions) + { + if (string.Equals(ext.Oid.Value, CertificateManager.AspNetHttpsOid, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private bool TryGetCertificatePath(out string path) + { + var hostingEnvironment = Options.ApplicationServices.GetRequiredService(); + var appName = hostingEnvironment.ApplicationName; + + // This will go away when we implement + // https://github.com/aspnet/Hosting/issues/1294 + var appData = Environment.GetEnvironmentVariable("APPDATA"); + var home = Environment.GetEnvironmentVariable("HOME"); + var basePath = appData != null ? Path.Combine(appData, "ASP.NET", "https") : null; + basePath = basePath ?? (home != null ? Path.Combine(home, ".aspnet", "https") : null); + path = basePath != null ? Path.Combine(basePath, $"{appName}.pfx") : null; + return path != null; } private X509Certificate2 LoadCertificate(CertificateConfig certInfo, string endpointName) diff --git a/test/Kestrel.Tests/KestrelConfigurationBuilderTests.cs b/test/Kestrel.Tests/KestrelConfigurationBuilderTests.cs index bdd9bff4c1..33caeb1d34 100644 --- a/test/Kestrel.Tests/KestrelConfigurationBuilderTests.cs +++ b/test/Kestrel.Tests/KestrelConfigurationBuilderTests.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Testing; @@ -22,6 +24,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Tests var serverOptions = new KestrelServerOptions(); serverOptions.ApplicationServices = new ServiceCollection() .AddLogging() + .AddSingleton(new HostingEnvironment() { ApplicationName = "TestApplication" }) .BuildServiceProvider(); return serverOptions; } @@ -209,5 +212,117 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Tests Assert.NotNull(serverOptions.ListenOptions[0].ConnectionAdapters.Where(adapter => adapter.IsHttps).SingleOrDefault()); Assert.NotNull(serverOptions.ListenOptions[1].ConnectionAdapters.Where(adapter => adapter.IsHttps).SingleOrDefault()); } + + [Fact] + public void ConfigureEndpointDevelopmentCertificateGetsLoadedWhenPresent() + { + try + { + var serverOptions = CreateServerOptions(); + var certificate = new X509Certificate2(TestResources.GetCertPath("aspnetdevcert.pfx"), "aspnetdevcert", X509KeyStorageFlags.Exportable); + var bytes = certificate.Export(X509ContentType.Pkcs12, "1234"); + var path = GetCertificatePath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllBytes(path, bytes); + + var ran1 = false; + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "https://*:5001"), + new KeyValuePair("Certificates:Development:Password", "1234"), + }).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); + } + finally + { + if (File.Exists(GetCertificatePath())) + { + File.Delete(GetCertificatePath()); + } + } + } + + [Fact] + public void ConfigureEndpointDevelopmentCertificateGetsIgnoredIfPasswordIsNotCorrect() + { + try + { + var serverOptions = CreateServerOptions(); + var certificate = new X509Certificate2(TestResources.GetCertPath("aspnetdevcert.pfx"), "aspnetdevcert", X509KeyStorageFlags.Exportable); + var bytes = certificate.Export(X509ContentType.Pkcs12, "1234"); + var path = GetCertificatePath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllBytes(path, bytes); + + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Certificates:Development:Password", "12341234"), + }).Build(); + + serverOptions + .Configure(config) + .Load(); + + Assert.Null(serverOptions.DefaultCertificate); + } + finally + { + if (File.Exists(GetCertificatePath())) + { + File.Delete(GetCertificatePath()); + } + } + } + + [Fact] + public void ConfigureEndpointDevelopmentCertificateGetsIgnoredIfPfxFileDoesNotExist() + { + try + { + var serverOptions = CreateServerOptions(); + if (File.Exists(GetCertificatePath())) + { + File.Delete(GetCertificatePath()); + } + + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Certificates:Development:Password", "12341234") + }).Build(); + + serverOptions + .Configure(config) + .Load(); + + Assert.Null(serverOptions.DefaultCertificate); + } + finally + { + if (File.Exists(GetCertificatePath())) + { + File.Delete(GetCertificatePath()); + } + } + } + + private static string GetCertificatePath() + { + var appData = Environment.GetEnvironmentVariable("APPDATA"); + var home = Environment.GetEnvironmentVariable("HOME"); + var basePath = appData != null ? Path.Combine(appData, "ASP.NET", "https") : null; + basePath = basePath ?? (home != null ? Path.Combine(home, ".aspnet", "https") : null); + return Path.Combine(basePath, $"TestApplication.pfx"); + } } } diff --git a/test/shared/TestCertificates/aspnetdevcert.pfx b/test/shared/TestCertificates/aspnetdevcert.pfx new file mode 100644 index 0000000000000000000000000000000000000000..e6eeeaa2e1f2a7cbee60365229c5e40e64c352ff GIT binary patch literal 2700 zcmZXUc{tQ-8^?dM7|Y;9W6N5W>}HN-tXUdcmPVrNgphqH;ba+7osjI=8WJL8UlO6Q z4#rlpWb8Gvj3tc9JE!Zs=Ungg{&7Fg_x^mJ`@WvPzDOL)eJ}`u#Iayt%+m2j@%wBb zMi3syq722cpy*f*iDTIKAB$lQies3j;}7&2gTeovVr2n=@i^!P5(lLq1z;@y#q{Ou zP^Rnlj?U}dIR;=bBMFLw_8ti~)c3}(M>9Ji{b!<-iJ&Wbk-u=;rCR(tp&mERKKqBotKSNj$*Sg!b@UDOMt^l9m>3iv4 zw03P*G$+Po15fE^K(Ec3UugOW(_K@*r>!?pnLe-XVaqP|-5nuP@8sJ0r5JG3tlmdk zq{_8_&^}&5w(@_;>a1I<{K4_9>WIMi(Cms0Sj8fI>!z|lp})sLfGihKI2=B7q)ooj z_mef9+ae%5@9J*C#ejIDX2!tlC%Nh%UooPMYveVOR_15fR1jWKA}$Z?<}hM?x#{_S z8;_U0O%kt${sLEEVa(25E2nFDy=>{7OU1FO$x-UfA=2pYw*`j1A5R%T3Nlt0c8`|K z!4wTr)WM4TxTr-8>bKBhRs%25H!S&^E<+t%y@s(2taGePlg1AdUK+nI3Gu0O#M}@l z8AlOz)<#Qp2-7@Re>gm;=<~<(W1Nuzq-Q!wri|{UMT4x-gVp)@un(@JjvGDh4NB53i>n)xgX0ND7+f8g}L6wNugUo>^ zkcut-gwr3#*m(&SWPPRf-A<8y-?787S5h8gy67#TCi$=ERebr0gJ3QhE^U_|T^94J zcT(^#tGZ))as#PM*{cGVEiuRPxJCxg=lajqGNpZwP{C^Z3(

w?sA?(I<-UH~YH9 zxv69c*^gI;7nxnmbF?Rt#WvZjQp+w?%1>q4tW8dzwmhxq+omGS^_9{>;uIpSrgA;fu(!*R7t|~7VGUWa{O$GQ zaQ3~LAI25_;S24#{sAWY#=4~!JwF%NuG1>*vN@z*y_op=Dvx*EER%8hTs)gVuaCoh zRro_W^(n?!vHnz%cpN)R&Yyn9E3=PiJoB5|9i_@b7U40Bx&t|!L+slxV{I0kZdzqz zT8Vp7vD2KG@~g22jG4sklbqfOOa_LYF5jYu5ham6_4xwooB3zLb(qfcY=Y6JdlTnG zdZVIdLvy3R)5pW^5A+pu^n+h0D7e0T)6%r}(fWuwT!pz}L0v1V_6lfG@3_frLpEoN z3*r&*&wQ_vCi+KYVVNywM%$mfq=y~2bVTdw0NF?+aoHy^UFsc{vg*1m*E-tTnNy4D zUV8g3EkmwwBMfeJ0Rw{er&Z_A-?*8JIDK>lL|zv;XfrwD>K{pT?D%|HFq6$L$(u~W zb#73n6C*x&AxU*!cgvFTpggIO002PR{R3fnPQVsFsED)?O z)aZQXU{}C7o@3T=Ci}zF*R{D1r8Ds}y?O6yJkh$7$QkFNP3aWqQKZ@Cz4^`rUwtFK zjEYyW_Ppw%nru$5#;MIe3);U#lA4>9dq9*wa;K~YKiyf13jAo`y1>zA*FOn{PwYcx z4#G~sxs}88>hpct)y^XUGI1>jY#zdC~KnQGe`%CbChvP^BJH+tgr5^c@c_vw6Xd>|_m{ zl78y_A?&Vr`Sp}G0%>SSRep4SdB-L-oieSkZK;aFm;*SgA)++_Cv5kA_+C~|#q6HA zdWExH*IgeNqY~8;uOPCR<|iI4oXXD&d2xLqb*w|#F}0TP^GR8^FoEjV(o8e0e0@G8 z#>9M9Ymm__s~KXyEAaG_898yT{=47+Igtq24+#?&=8{s&zOnAi!PzH6adEKCNA#^l zuv}5}6d$boV$*WgntbJ~rfy4}GydiYob;c2KHoMH*3ygQbzOP{CYXTeyJ;`O7u8ji~3aggRfvmjfR?|0wyy3mKSaE6O{qlN;xqP0*K1$^ z)N`XUv}CVq|ADdWVzf;1D4F-MC5qJNn_T+^e~9!ObfX+