diff --git a/Hosting.sln b/Hosting.sln index 8aafc60dd3..f61f83d717 100644 --- a/Hosting.sln +++ b/Hosting.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26510.0 +VisualStudioVersion = 15.0.26524.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E0497F39-AFFB-4819-A116-E39E361915AB}" EndProject @@ -34,6 +34,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Hostin EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Hosting.WindowsServices", "src\Microsoft.AspNetCore.Hosting.WindowsServices\Microsoft.AspNetCore.Hosting.WindowsServices.csproj", "{9C93A93B-270A-4785-8F41-46C38DC33825}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Certificates.Configuration", "src\Microsoft.AspNetCore.Certificates.Configuration\Microsoft.AspNetCore.Certificates.Configuration.csproj", "{A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Certificates.Configuration.Tests", "test\Microsoft.AspNetCore.Certificates.Configuration.Tests\Microsoft.AspNetCore.Certificates.Configuration.Tests.csproj", "{AB0B7394-278D-4838-A59C-276ED88D00CC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -192,6 +196,30 @@ Global {9C93A93B-270A-4785-8F41-46C38DC33825}.Release|Mixed Platforms.Build.0 = Release|Any CPU {9C93A93B-270A-4785-8F41-46C38DC33825}.Release|x86.ActiveCfg = Release|Any CPU {9C93A93B-270A-4785-8F41-46C38DC33825}.Release|x86.Build.0 = Release|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Debug|x86.ActiveCfg = Debug|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Debug|x86.Build.0 = Debug|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Release|Any CPU.Build.0 = Release|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Release|x86.ActiveCfg = Release|Any CPU + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4}.Release|x86.Build.0 = Release|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Debug|x86.ActiveCfg = Debug|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Debug|x86.Build.0 = Debug|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Release|Any CPU.Build.0 = Release|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Release|x86.ActiveCfg = Release|Any CPU + {AB0B7394-278D-4838-A59C-276ED88D00CC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -210,5 +238,7 @@ Global {39D3B138-37DB-4D03-A5A0-3F2B02EFC671} = {FEB39027-9158-4DE2-997F-7ADAEF8188D0} {96BC7EEA-64D9-4DA5-8E87-1C18CBFE7D12} = {E0497F39-AFFB-4819-A116-E39E361915AB} {9C93A93B-270A-4785-8F41-46C38DC33825} = {E0497F39-AFFB-4819-A116-E39E361915AB} + {A911A891-EAC5-4AA1-B17E-B83A1BB1E8B4} = {E0497F39-AFFB-4819-A116-E39E361915AB} + {AB0B7394-278D-4838-A59C-276ED88D00CC} = {FEB39027-9158-4DE2-997F-7ADAEF8188D0} EndGlobalSection EndGlobal diff --git a/src/Microsoft.AspNetCore.Certificates.Configuration/CertificateFileLoader.cs b/src/Microsoft.AspNetCore.Certificates.Configuration/CertificateFileLoader.cs new file mode 100644 index 0000000000..bcaa93d3e3 --- /dev/null +++ b/src/Microsoft.AspNetCore.Certificates.Configuration/CertificateFileLoader.cs @@ -0,0 +1,15 @@ +// 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; + +namespace Microsoft.AspNetCore.Certificates.Configuration +{ + internal class CertificateFileLoader : ICertificateFileLoader + { + public X509Certificate2 Load(string path, string password, X509KeyStorageFlags flags) + { + return new X509Certificate2(path, password, flags); + } + } +} diff --git a/src/Microsoft.AspNetCore.Certificates.Configuration/CertificateLoader.cs b/src/Microsoft.AspNetCore.Certificates.Configuration/CertificateLoader.cs new file mode 100644 index 0000000000..03c2f8885f --- /dev/null +++ b/src/Microsoft.AspNetCore.Certificates.Configuration/CertificateLoader.cs @@ -0,0 +1,248 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Certificates.Configuration +{ + /// + /// A helper class to load certificates from files and certificate stores based on data. + /// + public class CertificateLoader + { + private readonly IConfiguration _certificatesConfiguration; + private readonly string _environmentName; + private readonly ICertificateFileLoader _certificateFileLoader; + private readonly ICertificateStoreLoader _certificateStoreLoader; + private readonly ILogger _logger; + + /// + /// Creates a new instance of that can load certificate references from configuration. + /// + /// An with information about certificates. + public CertificateLoader(IConfiguration certificatesConfiguration) + : this(certificatesConfiguration, null, null) + { + } + + /// + /// Creates a new instance of that can load certificate references from configuration. + /// + /// An with information about certificates. + /// An instance. + public CertificateLoader(IConfiguration certificatesConfiguration, ILoggerFactory loggerFactory) + : this(certificatesConfiguration, loggerFactory, null) + { + } + + /// + /// Creates a new instance of that can load certificate references from configuration. + /// + /// An with information about certificates. + /// An instance. + /// The name of the environment the application is running in. + public CertificateLoader(IConfiguration certificatesConfiguration, ILoggerFactory loggerFactory, string environmentName) + : this(certificatesConfiguration, loggerFactory, environmentName, new CertificateFileLoader(), new CertificateStoreLoader()) + { + } + + internal CertificateLoader( + IConfiguration certificatesConfiguration, + ILoggerFactory loggerFactory, + string environmentName, + ICertificateFileLoader certificateFileLoader, + ICertificateStoreLoader certificateStoreLoader) + { + _environmentName = environmentName; + _certificatesConfiguration = certificatesConfiguration; + _certificateFileLoader = certificateFileLoader; + _certificateStoreLoader = certificateStoreLoader; + _logger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.CertificateLoader"); + } + + /// + /// Loads one or more certificates based on the information found in a configuration section. + /// + /// A configuration section containing either a string value referencing certificates + /// by name, or one or more inline certificate specifications. + /// + /// One or more loaded certificates. + public IEnumerable Load(IConfigurationSection certificateConfiguration) + { + var certificateNames = certificateConfiguration.Value; + var certificates = new List(); + + if (certificateNames != null) + { + foreach (var certificateName in certificateNames.Split(';')) + { + var certificate = LoadSingle(certificateName); + if (certificate != null) + { + certificates.Add(certificate); + } + } + } + else + { + if (certificateConfiguration["Source"] != null) + { + var certificate = LoadSingle(certificateConfiguration); + if (certificate != null) + { + certificates.Add(certificate); + } + } + else + { + certificates.AddRange(LoadMultiple(certificateConfiguration)); + } + } + + return certificates; + } + + /// + /// Loads a certificate by name. + /// + /// The certificate name. + /// The loaded certificate + /// This method only works if the instance was constructed with + /// a reference to an instance containing named certificates. + /// + private X509Certificate2 LoadSingle(string certificateName) + { + var certificateConfiguration = _certificatesConfiguration?.GetSection(certificateName); + + if (!certificateConfiguration.Exists()) + { + var environmentName = _environmentName != null ? $" ({_environmentName})" : ""; + throw new KeyNotFoundException($"No certificate named '{certificateName}' found in configuration for the current environment{environmentName}."); + } + + return LoadSingle(certificateConfiguration); + } + + private X509Certificate2 LoadSingle(IConfigurationSection certificateConfiguration) + { + var sourceKind = certificateConfiguration["Source"]; + + CertificateSource certificateSource; + switch (sourceKind.ToLowerInvariant()) + { + case "file": + certificateSource = new CertificateFileSource(_certificateFileLoader); + break; + case "store": + certificateSource = new CertificateStoreSource(_certificateStoreLoader, _logger); + break; + default: + throw new InvalidOperationException($"Invalid certificate source kind '{sourceKind}'."); + } + + certificateConfiguration.Bind(certificateSource); + + return certificateSource.Load(); + } + + private IEnumerable LoadMultiple(IConfigurationSection certificatesConfiguration) + => certificatesConfiguration.GetChildren() + .Select(LoadSingle) + .Where(c => c != null); + + private abstract class CertificateSource + { + public string Source { get; set; } + + public abstract X509Certificate2 Load(); + } + + private class CertificateFileSource : CertificateSource + { + private ICertificateFileLoader _certificateFileLoader; + + public CertificateFileSource(ICertificateFileLoader certificateFileLoader) + { + _certificateFileLoader = certificateFileLoader; + } + + public string Path { get; set; } + + public string Password { get; set; } + + public override X509Certificate2 Load() + { + var certificate = TryLoad(X509KeyStorageFlags.DefaultKeySet, out var error) + ?? TryLoad(X509KeyStorageFlags.UserKeySet, out error) +#if NETCOREAPP2_0 + ?? TryLoad(X509KeyStorageFlags.EphemeralKeySet, out error) +#elif NETSTANDARD2_0 +#else +#error target frameworks need to be updated +#endif + ; + + if (error != null) + { + throw new InvalidOperationException($"Unable to load certificate from file '{Path}'. Error details: '{error.Message}'.", error); + } + + return certificate; + } + + private X509Certificate2 TryLoad(X509KeyStorageFlags flags, out Exception exception) + { + try + { + var loadedCertificate = _certificateFileLoader.Load(Path, Password, flags); + exception = null; + return loadedCertificate; + } + catch (Exception e) + { + exception = e; + return null; + } + } + } + + private class CertificateStoreSource : CertificateSource + { + private readonly ICertificateStoreLoader _certificateStoreLoader; + private readonly ILogger _logger; + + public CertificateStoreSource(ICertificateStoreLoader certificateStoreLoader, ILogger logger) + { + _certificateStoreLoader = certificateStoreLoader; + _logger = logger; + } + + public string Subject { get; set; } + public string StoreName { get; set; } + public string StoreLocation { get; set; } + public bool AllowInvalid { get; set; } + + public override X509Certificate2 Load() + { + if (!Enum.TryParse(StoreLocation, ignoreCase: true, result: out StoreLocation storeLocation)) + { + throw new InvalidOperationException($"The certificate store location '{StoreLocation}' is invalid."); + } + + var certificate = _certificateStoreLoader.Load(Subject, StoreName, storeLocation, !AllowInvalid); + + if (certificate == null) + { + _logger?.LogWarning($"Unable to find a matching certificate for subject '{Subject}' in store '{StoreName}' in '{StoreLocation}'."); + } + + return certificate; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Certificates.Configuration/CertificateStoreLoader.cs b/src/Microsoft.AspNetCore.Certificates.Configuration/CertificateStoreLoader.cs new file mode 100644 index 0000000000..1329fd4475 --- /dev/null +++ b/src/Microsoft.AspNetCore.Certificates.Configuration/CertificateStoreLoader.cs @@ -0,0 +1,67 @@ +// 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.Linq; +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.AspNetCore.Certificates.Configuration +{ + /// + /// Loads certificates from certificate stores. + /// + public class CertificateStoreLoader : ICertificateStoreLoader + { + /// + /// Load a ceritificate from the given store location. + /// + /// The certificate subject name to match. + /// The store to open. + /// The store location to open. + /// Only load currently valid certs. + /// The closest matching vertificate. + public X509Certificate2 Load(string subject, string storeName, StoreLocation storeLocation, bool validOnly) + { + using (var store = new X509Store(storeName, storeLocation)) + { + X509Certificate2Collection storeCertificates = null; + X509Certificate2Collection foundCertificates = null; + X509Certificate2 foundCertificate = null; + + try + { + store.Open(OpenFlags.ReadOnly); + storeCertificates = store.Certificates; + foundCertificates = storeCertificates.Find(X509FindType.FindBySubjectDistinguishedName, subject, validOnly); + foundCertificate = foundCertificates + .OfType() + .OrderByDescending(certificate => certificate.NotAfter) + .FirstOrDefault(); + + return foundCertificate; + } + finally + { + if (foundCertificate != null) + { + storeCertificates.Remove(foundCertificate); + foundCertificates.Remove(foundCertificate); + } + + DisposeCertificates(storeCertificates); + DisposeCertificates(foundCertificates); + } + } + } + + private void DisposeCertificates(X509Certificate2Collection certificates) + { + if (certificates != null) + { + foreach (var certificate in certificates) + { + certificate.Dispose(); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Certificates.Configuration/ICertificateFileLoader.cs b/src/Microsoft.AspNetCore.Certificates.Configuration/ICertificateFileLoader.cs new file mode 100644 index 0000000000..2a820eea39 --- /dev/null +++ b/src/Microsoft.AspNetCore.Certificates.Configuration/ICertificateFileLoader.cs @@ -0,0 +1,12 @@ +// 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; + +namespace Microsoft.AspNetCore.Certificates.Configuration +{ + internal interface ICertificateFileLoader + { + X509Certificate2 Load(string path, string password, X509KeyStorageFlags flags); + } +} diff --git a/src/Microsoft.AspNetCore.Certificates.Configuration/ICertificateStoreLoader.cs b/src/Microsoft.AspNetCore.Certificates.Configuration/ICertificateStoreLoader.cs new file mode 100644 index 0000000000..2a7c334759 --- /dev/null +++ b/src/Microsoft.AspNetCore.Certificates.Configuration/ICertificateStoreLoader.cs @@ -0,0 +1,12 @@ +// 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; + +namespace Microsoft.AspNetCore.Certificates.Configuration +{ + internal interface ICertificateStoreLoader + { + X509Certificate2 Load(string subject, string storeName, StoreLocation storeLocation, bool validOnly); + } +} diff --git a/src/Microsoft.AspNetCore.Certificates.Configuration/Microsoft.AspNetCore.Certificates.Configuration.csproj b/src/Microsoft.AspNetCore.Certificates.Configuration/Microsoft.AspNetCore.Certificates.Configuration.csproj new file mode 100644 index 0000000000..5275d54e04 --- /dev/null +++ b/src/Microsoft.AspNetCore.Certificates.Configuration/Microsoft.AspNetCore.Certificates.Configuration.csproj @@ -0,0 +1,17 @@ + + + + + + netcoreapp2.0;netstandard2.0 + aspnetcore;certificates + Helpers for loading certificates from configuration. + true + + + + + + + + diff --git a/src/Microsoft.AspNetCore.Certificates.Configuration/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Certificates.Configuration/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..61148132fc --- /dev/null +++ b/src/Microsoft.AspNetCore.Certificates.Configuration/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Certificates.Configuration.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Certificates.Configuration.Tests/CertificateLoaderTests.cs b/test/Microsoft.AspNetCore.Certificates.Configuration.Tests/CertificateLoaderTests.cs new file mode 100644 index 0000000000..b3550bf3d8 --- /dev/null +++ b/test/Microsoft.AspNetCore.Certificates.Configuration.Tests/CertificateLoaderTests.cs @@ -0,0 +1,1044 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Certificates.Configuration.Tests +{ + public class CertificateLoaderTests + { + [Fact] + public void Loads_SingleCertificateName_File() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "File", + ["Certificates:Certificate1:Path"] = "Certificate1.pfx", + ["Certificates:Certificate1:Password"] = "Password1", + ["TestConfig:Certificate"] = "Certificate1" + }) + .Build(); + + var certificate = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(certificate); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + certificateFileLoader.Object, + Mock.Of()); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(1, loadedCertificates.Count()); + Assert.Same(certificate, loadedCertificates.ElementAt(0)); + certificateFileLoader.VerifyAll(); + } + + [Fact] + public void Throws_SingleCertificateName_KeyNotFound() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificate"] = "Certificate1" + }) + .Build(); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + Mock.Of(), + Mock.Of()); + + var exception = Assert.Throws(() => certificateLoader.Load(configuration.GetSection("TestConfig:Certificate"))); + Assert.Equal("No certificate named 'Certificate1' found in configuration for the current environment.", exception.Message); + } + + [Fact] + public void Throws_SingleCertificateName_File_FileLoadError() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "File", + ["Certificates:Certificate1:Path"] = "Certificate1.pfx", + ["Certificates:Certificate1:Password"] = "Password1", + ["TestConfig:Certificate"] = "Certificate1" + }) + .Build(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Callback(() => throw new Exception(nameof(Throws_SingleCertificateName_File_FileLoadError))); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + certificateFileLoader.Object, + Mock.Of()); + + var exception = Assert.Throws(() => certificateLoader.Load(configuration.GetSection("TestConfig:Certificate"))); + Assert.Equal($"Unable to load certificate from file 'Certificate1.pfx'. Error details: '{nameof(Throws_SingleCertificateName_File_FileLoadError)}'.", exception.Message); + } + + [Fact] + public void Loads_SingleCertificateName_Store() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "Store", + ["Certificates:Certificate1:Subject"] = "localhost", + ["Certificates:Certificate1:StoreName"] = "My", + ["Certificates:Certificate1:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificate"] = "Certificate1" + }) + .Build(); + + var certificate = new X509Certificate2(); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(certificate); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + Mock.Of(), + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(1, loadedCertificates.Count()); + Assert.Same(certificate, loadedCertificates.ElementAt(0)); + certificateStoreLoader.VerifyAll(); + } + + [Fact] + public void ReturnsNull_SingleCertificateName_Store_NotFoundInStore() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "Store", + ["Certificates:Certificate1:Subject"] = "localhost", + ["Certificates:Certificate1:StoreName"] = "My", + ["Certificates:Certificate1:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificate"] = "Certificate1" + }) + .Build(); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(null); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + Mock.Of(), + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(0, loadedCertificates.Count()); + certificateStoreLoader.VerifyAll(); + } + + [Fact] + public void Loads_MultipleCertificateNames_File() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "File", + ["Certificates:Certificate1:Path"] = "Certificate1.pfx", + ["Certificates:Certificate1:Password"] = "Password1", + ["Certificates:Certificate2:Source"] = "File", + ["Certificates:Certificate2:Path"] = "Certificate2.pfx", + ["Certificates:Certificate2:Password"] = "Password2", + ["TestConfig:Certificate"] = "Certificate1;Certificate2" + }) + .Build(); + + var certificate1 = new X509Certificate2(); + var certificate2 = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(certificate1); + certificateFileLoader + .Setup(loader => loader.Load("Certificate2.pfx", "Password2", It.IsAny())) + .Returns(certificate2); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + certificateFileLoader.Object, + Mock.Of()); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(2, loadedCertificates.Count()); + Assert.Same(certificate1, loadedCertificates.ElementAt(0)); + Assert.Same(certificate2, loadedCertificates.ElementAt(1)); + certificateFileLoader.VerifyAll(); + } + + [Theory] + [InlineData("Certificate1;Certificate2")] + [InlineData("Certificate2;Certificate1")] + public void Throws_MultipleCertificateNames_File_FileLoadError(string certificateNames) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "File", + ["Certificates:Certificate1:Path"] = "Certificate1.pfx", + ["Certificates:Certificate1:Password"] = "Password1", + ["Certificates:Certificate2:Source"] = "File", + ["Certificates:Certificate2:Path"] = "Certificate2.pfx", + ["Certificates:Certificate2:Password"] = "Password2", + ["TestConfig:Certificate"] = certificateNames + }) + .Build(); + + var certificate1 = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(certificate1); + certificateFileLoader + .Setup(loader => loader.Load("Certificate2.pfx", "Password2", It.IsAny())) + .Throws(new Exception(nameof(Throws_MultipleCertificateNames_File_FileLoadError))); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + certificateFileLoader.Object, + Mock.Of()); + + var exception = Assert.Throws(() => certificateLoader.Load(configuration.GetSection("TestConfig:Certificate"))); + Assert.Equal($"Unable to load certificate from file 'Certificate2.pfx'. Error details: '{nameof(Throws_MultipleCertificateNames_File_FileLoadError)}'.", exception.Message); + } + + [Fact] + public void Loads_MultipleCertificateNames_Store() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "Store", + ["Certificates:Certificate1:Subject"] = "localhost", + ["Certificates:Certificate1:StoreName"] = "My", + ["Certificates:Certificate1:StoreLocation"] = "CurrentUser", + ["Certificates:Certificate2:Source"] = "Store", + ["Certificates:Certificate2:Subject"] = "example.com", + ["Certificates:Certificate2:StoreName"] = "Root", + ["Certificates:Certificate2:StoreLocation"] = "LocalMachine", + ["TestConfig:Certificate"] = "Certificate1;Certificate2" + }) + .Build(); + + var certificate1 = new X509Certificate2(); + var certificate2 = new X509Certificate2(); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(certificate1); + certificateStoreLoader + .Setup(loader => loader.Load("example.com", "Root", StoreLocation.LocalMachine, It.IsAny())) + .Returns(certificate2); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + Mock.Of(), + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(2, loadedCertificates.Count()); + Assert.Same(certificate1, loadedCertificates.ElementAt(0)); + Assert.Same(certificate2, loadedCertificates.ElementAt(1)); + certificateStoreLoader.VerifyAll(); + } + + [Theory] + [InlineData("Certificate1;Certificate2", 1)] + [InlineData("Certificate2;Certificate1", 1)] + [InlineData("Certificate1;Certificate2;Certificate3", 1)] + [InlineData("Certificate2;Certificate3", 0)] + [InlineData("Certificate2;Certificate3;Certificate1", 1)] + public void ReturnsNull_MultipleCertificateNames_Store_NotFoundInStore(string certificateNames, int expectedFoundCertificates) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "Store", + ["Certificates:Certificate1:Subject"] = "localhost", + ["Certificates:Certificate1:StoreName"] = "My", + ["Certificates:Certificate1:StoreLocation"] = "CurrentUser", + ["Certificates:Certificate2:Source"] = "Store", + ["Certificates:Certificate2:Subject"] = "example.com", + ["Certificates:Certificate2:StoreName"] = "Root", + ["Certificates:Certificate2:StoreLocation"] = "LocalMachine", + ["Certificates:Certificate3:Source"] = "Store", + ["Certificates:Certificate3:Subject"] = "notfound.com", + ["Certificates:Certificate3:StoreName"] = "Root", + ["Certificates:Certificate3:StoreLocation"] = "LocalMachine", + ["TestConfig:Certificate"] = certificateNames + }) + .Build(); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(new X509Certificate2()); + certificateStoreLoader + .Setup(loader => loader.Load("example.com", "Root", StoreLocation.LocalMachine, It.IsAny())) + .Returns(null); + certificateStoreLoader + .Setup(loader => loader.Load("notfound.com", "Root", StoreLocation.LocalMachine, It.IsAny())) + .Returns(null); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + Mock.Of(), + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(expectedFoundCertificates, loadedCertificates.Count()); + } + + [Fact] + public void Loads_MultipleCertificateNames_FileAndStore() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "File", + ["Certificates:Certificate1:Path"] = "Certificate1.pfx", + ["Certificates:Certificate1:Password"] = "Password1", + ["Certificates:Certificate2:Source"] = "Store", + ["Certificates:Certificate2:Subject"] = "localhost", + ["Certificates:Certificate2:StoreName"] = "My", + ["Certificates:Certificate2:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificate"] = "Certificate1;Certificate2" + }) + .Build(); + + var fileCertificate = new X509Certificate2(); + var storeCertificate = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(fileCertificate); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(storeCertificate); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + certificateFileLoader.Object, + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(2, loadedCertificates.Count()); + Assert.Same(fileCertificate, loadedCertificates.ElementAt(0)); + Assert.Same(storeCertificate, loadedCertificates.ElementAt(1)); + certificateFileLoader.VerifyAll(); + certificateStoreLoader.VerifyAll(); + } + + [Theory] + [InlineData("Certificate1;Certificate2;NotFound")] + [InlineData("Certificate1;NotFound;Certificate2")] + [InlineData("NotFound;Certificate1;Certificate2")] + public void Throws_MultipleCertificateNames_KeyNotFound(string certificateNames) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "File", + ["Certificates:Certificate1:Path"] = "Certificate1.pfx", + ["Certificates:Certificate1:Password"] = "Password1", + ["Certificates:Certificate2:Source"] = "Store", + ["Certificates:Certificate2:Subject"] = "localhost", + ["Certificates:Certificate2:StoreName"] = "My", + ["Certificates:Certificate2:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificate"] = certificateNames + }) + .Build(); + + var fileCertificate = new X509Certificate2(); + var storeCertificate = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(fileCertificate); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(storeCertificate); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + certificateFileLoader.Object, + certificateStoreLoader.Object); + + var exception = Assert.Throws(() => certificateLoader.Load(configuration.GetSection("TestConfig:Certificate"))); + Assert.Equal("No certificate named 'NotFound' found in configuration for the current environment.", exception.Message); + } + + [Theory] + [InlineData("Certificate1;Certificate2")] + [InlineData("Certificate2;Certificate1")] + public void Throws_MultipleCertificateNames_FileAndStore_FileLoadError(string certificateNames) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "File", + ["Certificates:Certificate1:Path"] = "Certificate1.pfx", + ["Certificates:Certificate1:Password"] = "Password1", + ["Certificates:Certificate2:Source"] = "Store", + ["Certificates:Certificate2:Subject"] = "localhost", + ["Certificates:Certificate2:StoreName"] = "My", + ["Certificates:Certificate2:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificate"] = certificateNames + }) + .Build(); + + var storeCertificate = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Throws(new Exception(nameof(Throws_MultipleCertificateNames_FileAndStore_FileLoadError))); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(storeCertificate); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + certificateFileLoader.Object, + certificateStoreLoader.Object); + + var exception = Assert.Throws(() => certificateLoader.Load(configuration.GetSection("TestConfig:Certificate"))); + Assert.Equal($"Unable to load certificate from file 'Certificate1.pfx'. Error details: '{nameof(Throws_MultipleCertificateNames_FileAndStore_FileLoadError)}'.", exception.Message); + } + + [Theory] + [InlineData("Certificate1;Certificate2")] + [InlineData("Certificate2;Certificate1")] + public void ReturnsNull_MultipleCertificateNames_FileAndStore_NotFoundInStore(string certificateNames) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Certificates:Certificate1:Source"] = "File", + ["Certificates:Certificate1:Path"] = "Certificate1.pfx", + ["Certificates:Certificate1:Password"] = "Password1", + ["Certificates:Certificate2:Source"] = "Store", + ["Certificates:Certificate2:Subject"] = "localhost", + ["Certificates:Certificate2:StoreName"] = "My", + ["Certificates:Certificate2:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificate"] = "Certificate1;Certificate2" + }) + .Build(); + + var certificate = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(certificate); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(null); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + certificateFileLoader.Object, + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(1, loadedCertificates.Count()); + Assert.Same(certificate, loadedCertificates.ElementAt(0)); + certificateFileLoader.VerifyAll(); + certificateStoreLoader.VerifyAll(); + } + + [Fact] + public void Loads_SingleCertificateInline_File() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificate:Source"] = "File", + ["TestConfig:Certificate:Path"] = "Certificate1.pfx", + ["TestConfig:Certificate:Password"] = "Password1" + }) + .Build(); + + var certificate = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(certificate); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + certificateFileLoader.Object, + Mock.Of()); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(1, loadedCertificates.Count()); + Assert.Same(certificate, loadedCertificates.ElementAt(0)); + certificateFileLoader.VerifyAll(); + } + + [Fact] + public void Throws_SingleCertificateInline_FileLoadError() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificate:Source"] = "File", + ["TestConfig:Certificate:Path"] = "Certificate1.pfx", + ["TestConfig:Certificate:Password"] = "Password1" + }) + .Build(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Throws(new Exception(nameof(Throws_SingleCertificateInline_FileLoadError))); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + certificateFileLoader.Object, + Mock.Of()); + + var exception = Assert.Throws(() => certificateLoader.Load(configuration.GetSection("TestConfig:Certificate"))); + Assert.Equal($"Unable to load certificate from file 'Certificate1.pfx'. Error details: '{nameof(Throws_SingleCertificateInline_FileLoadError)}'.", exception.Message); + certificateFileLoader.VerifyAll(); + } + + [Fact] + public void Loads_SingleCertificateInline_Store() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificate:Source"] = "Store", + ["TestConfig:Certificate:Subject"] = "localhost", + ["TestConfig:Certificate:StoreName"] = "My", + ["TestConfig:Certificate:StoreLocation"] = "CurrentUser", + }) + .Build(); + + var certificate = new X509Certificate2(); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(certificate); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + Mock.Of(), + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(1, loadedCertificates.Count()); + Assert.Same(certificate, loadedCertificates.ElementAt(0)); + certificateStoreLoader.VerifyAll(); + } + + [Fact] + public void ReturnsNull_SingleCertificateInline_Store_NotFoundInStore() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificate:Source"] = "Store", + ["TestConfig:Certificate:Subject"] = "localhost", + ["TestConfig:Certificate:StoreName"] = "My", + ["TestConfig:Certificate:StoreLocation"] = "CurrentUser", + }) + .Build(); + + var certificateStoreLoader = new Mock(); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + Mock.Of(), + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificate")); + Assert.Equal(0, loadedCertificates.Count()); + certificateStoreLoader.Verify(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())); + } + + [Fact] + public void Loads_MultipleCertificatesInline_File() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificates:Certificate1:Source"] = "File", + ["TestConfig:Certificates:Certificate1:Path"] = "Certificate1.pfx", + ["TestConfig:Certificates:Certificate1:Password"] = "Password1", + ["TestConfig:Certificates:Certificate2:Source"] = "File", + ["TestConfig:Certificates:Certificate2:Path"] = "Certificate2.pfx", + ["TestConfig:Certificates:Certificate2:Password"] = "Password2", + }) + .Build(); + + var certificate1 = new X509Certificate2(); + var certificate2 = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(certificate1); + certificateFileLoader + .Setup(loader => loader.Load("Certificate2.pfx", "Password2", It.IsAny())) + .Returns(certificate2); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + certificateFileLoader.Object, + Mock.Of()); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificates")); + Assert.Equal(2, loadedCertificates.Count()); + Assert.Same(certificate1, loadedCertificates.ElementAt(0)); + Assert.Same(certificate2, loadedCertificates.ElementAt(1)); + certificateFileLoader.VerifyAll(); + } + + [Fact] + public void Throws_MultipleCertificatesInline_File_FileLoadError() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificates:Certificate1:Source"] = "File", + ["TestConfig:Certificates:Certificate1:Path"] = "Certificate1.pfx", + ["TestConfig:Certificates:Certificate1:Password"] = "Password1", + ["TestConfig:Certificates:Certificate2:Source"] = "File", + ["TestConfig:Certificates:Certificate2:Path"] = "Certificate2.pfx", + ["TestConfig:Certificates:Certificate2:Password"] = "Password2", + }) + .Build(); + + var certificate1 = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(certificate1); + certificateFileLoader + .Setup(loader => loader.Load("Certificate2.pfx", "Password2", It.IsAny())) + .Throws(new Exception(nameof(Throws_MultipleCertificatesInline_File_FileLoadError))); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + certificateFileLoader.Object, + Mock.Of()); + + var exception = Assert.Throws(() => certificateLoader.Load(configuration.GetSection("TestConfig:Certificates"))); + Assert.Equal($"Unable to load certificate from file 'Certificate2.pfx'. Error details: '{nameof(Throws_MultipleCertificatesInline_File_FileLoadError)}'.", exception.Message); + } + + [Fact] + public void Loads_MultipleCertificatesInline_Store() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificates:Certificate1:Source"] = "Store", + ["TestConfig:Certificates:Certificate1:Subject"] = "localhost", + ["TestConfig:Certificates:Certificate1:StoreName"] = "My", + ["TestConfig:Certificates:Certificate1:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificates:Certificate2:Source"] = "Store", + ["TestConfig:Certificates:Certificate2:Subject"] = "example.com", + ["TestConfig:Certificates:Certificate2:StoreName"] = "Root", + ["TestConfig:Certificates:Certificate2:StoreLocation"] = "LocalMachine" + }) + .Build(); + + var certificate1 = new X509Certificate2(); + var certificate2 = new X509Certificate2(); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(certificate1); + certificateStoreLoader + .Setup(loader => loader.Load("example.com", "Root", StoreLocation.LocalMachine, It.IsAny())) + .Returns(certificate2); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + Mock.Of(), + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificates")); + Assert.Equal(2, loadedCertificates.Count()); + Assert.Same(certificate1, loadedCertificates.ElementAt(0)); + Assert.Same(certificate2, loadedCertificates.ElementAt(1)); + certificateStoreLoader.VerifyAll(); + } + + [Fact] + public void ReturnsNull_MultipleCertificatesInline_Store_NotFoundInStore() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificates:Certificate1:Source"] = "Store", + ["TestConfig:Certificates:Certificate1:Subject"] = "notfound.com", + ["TestConfig:Certificates:Certificate1:StoreName"] = "Root", + ["TestConfig:Certificates:Certificate1:StoreLocation"] = "LocalMachine", + ["TestConfig:Certificates:Certificate2:Source"] = "Store", + ["TestConfig:Certificates:Certificate2:Subject"] = "localhost", + ["TestConfig:Certificates:Certificate2:StoreName"] = "My", + ["TestConfig:Certificates:Certificate2:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificates:Certificate3:Source"] = "Store", + ["TestConfig:Certificates:Certificate3:Subject"] = "example.com", + ["TestConfig:Certificates:Certificate3:StoreName"] = "Root", + ["TestConfig:Certificates:Certificate3:StoreLocation"] = "LocalMachine" + }) + .Build(); + + var certificate = new X509Certificate2(); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(certificate); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + Mock.Of(), + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificates")); + Assert.Equal(1, loadedCertificates.Count()); + Assert.Same(certificate, loadedCertificates.ElementAt(0)); + certificateStoreLoader.Verify(loader => loader.Load("notfound.com", "Root", StoreLocation.LocalMachine, It.IsAny())); + certificateStoreLoader.Verify(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())); + certificateStoreLoader.Verify(loader => loader.Load("example.com", "Root", StoreLocation.LocalMachine, It.IsAny())); + } + + [Fact] + public void Loads_MultipleCertificatesInline_FileAndStore() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificates:Certificate1:Source"] = "Store", + ["TestConfig:Certificates:Certificate1:Subject"] = "localhost", + ["TestConfig:Certificates:Certificate1:StoreName"] = "My", + ["TestConfig:Certificates:Certificate1:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificates:Certificate2:Source"] = "File", + ["TestConfig:Certificates:Certificate2:Path"] = "Certificate1.pfx", + ["TestConfig:Certificates:Certificate2:Password"] = "Password1", + ["TestConfig:Certificates:Certificate3:Source"] = "Store", + ["TestConfig:Certificates:Certificate3:Subject"] = "example.com", + ["TestConfig:Certificates:Certificate3:StoreName"] = "Root", + ["TestConfig:Certificates:Certificate3:StoreLocation"] = "LocalMachine", + ["TestConfig:Certificates:Certificate4:Source"] = "File", + ["TestConfig:Certificates:Certificate4:Path"] = "Certificate2.pfx", + ["TestConfig:Certificates:Certificate4:Password"] = "Password2", + }) + .Build(); + + var fileCertificate1 = new X509Certificate2(); + var fileCertificate2 = new X509Certificate2(); + var storeCertificate1 = new X509Certificate2(); + var storeCertificate2 = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(fileCertificate1); + certificateFileLoader + .Setup(loader => loader.Load("Certificate2.pfx", "Password2", It.IsAny())) + .Returns(fileCertificate2); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(storeCertificate1); + certificateStoreLoader + .Setup(loader => loader.Load("example.com", "Root", StoreLocation.LocalMachine, It.IsAny())) + .Returns(storeCertificate2); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + certificateFileLoader.Object, + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificates")); + Assert.Equal(4, loadedCertificates.Count()); + Assert.Same(storeCertificate1, loadedCertificates.ElementAt(0)); + Assert.Same(fileCertificate1, loadedCertificates.ElementAt(1)); + Assert.Same(storeCertificate2, loadedCertificates.ElementAt(2)); + Assert.Same(fileCertificate2, loadedCertificates.ElementAt(3)); + certificateStoreLoader.VerifyAll(); + } + + [Fact] + public void Throws_MultipleCertificatesInline_FileAndStore_FileLoadError() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificates:Certificate1:Source"] = "Store", + ["TestConfig:Certificates:Certificate1:Subject"] = "localhost", + ["TestConfig:Certificates:Certificate1:StoreName"] = "My", + ["TestConfig:Certificates:Certificate1:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificates:Certificate2:Source"] = "File", + ["TestConfig:Certificates:Certificate2:Path"] = "Certificate1.pfx", + ["TestConfig:Certificates:Certificate2:Password"] = "Password1", + }) + .Build(); + + var certificate = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Throws(new Exception(nameof(Throws_MultipleCertificatesInline_FileAndStore_FileLoadError))); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(certificate); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + certificateFileLoader.Object, + certificateStoreLoader.Object); + + var exception = Assert.Throws(() => certificateLoader.Load(configuration.GetSection("TestConfig:Certificates"))); + Assert.Equal($"Unable to load certificate from file 'Certificate1.pfx'. Error details: '{nameof(Throws_MultipleCertificatesInline_FileAndStore_FileLoadError)}'.", exception.Message); + } + + [Fact] + public void ReturnsNull_MultipleCertificatesInline_FileAndStore_NotFoundInStore() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificates:Certificate1:Source"] = "Store", + ["TestConfig:Certificates:Certificate1:Subject"] = "localhost", + ["TestConfig:Certificates:Certificate1:StoreName"] = "My", + ["TestConfig:Certificates:Certificate1:StoreLocation"] = "CurrentUser", + ["TestConfig:Certificates:Certificate2:Source"] = "File", + ["TestConfig:Certificates:Certificate2:Path"] = "Certificate1.pfx", + ["TestConfig:Certificates:Certificate2:Password"] = "Password1", + }) + .Build(); + + var certificate = new X509Certificate2(); + + var certificateFileLoader = new Mock(); + certificateFileLoader + .Setup(loader => loader.Load("Certificate1.pfx", "Password1", It.IsAny())) + .Returns(certificate); + + var certificateStoreLoader = new Mock(); + certificateStoreLoader + .Setup(loader => loader.Load("localhost", "My", StoreLocation.CurrentUser, It.IsAny())) + .Returns(null); + + var certificateLoader = new CertificateLoader( + null, + null, + null, + certificateFileLoader.Object, + certificateStoreLoader.Object); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificates")); + Assert.Equal(1, loadedCertificates.Count()); + Assert.Same(certificate, loadedCertificates.ElementAt(0)); + } + + [Theory] + [InlineData("Development")] + [InlineData("Production")] + public void IncludesEnvironmentNameInExceptionWhenAvailable(string environmentName) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificate"] = "Certificate1" + }) + .Build(); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + environmentName, + Mock.Of(), + Mock.Of()); + + var exception = Assert.Throws(() => certificateLoader.Load(configuration.GetSection("TestConfig:Certificate"))); + Assert.Equal($"No certificate named 'Certificate1' found in configuration for the current environment ({environmentName}).", exception.Message); + } + + [Fact] + public void DoesNotIncludeEnvironmentNameInExceptionWhenNotAvailable() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificate"] = "Certificate1" + }) + .Build(); + + var certificateLoader = new CertificateLoader( + configuration.GetSection("Certificates"), + null, + null, + Mock.Of(), + Mock.Of()); + + var exception = Assert.Throws(() => certificateLoader.Load(configuration.GetSection("TestConfig:Certificate"))); + Assert.Equal("No certificate named 'Certificate1' found in configuration for the current environment.", exception.Message); + } + + [Fact] + public void WarningLoggedWhenCertificateNotFoundInStore() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TestConfig:Certificates:Certificate1:Source"] = "Store", + ["TestConfig:Certificates:Certificate1:Subject"] = "localhost", + ["TestConfig:Certificates:Certificate1:StoreName"] = "My", + ["TestConfig:Certificates:Certificate1:StoreLocation"] = "CurrentUser", + }) + .Build(); + + var loggerFactory = new Mock(); + var logger = new MockLogger(); + + loggerFactory + .Setup(factory => factory.CreateLogger("Microsoft.AspNetCore.CertificateLoader")) + .Returns(logger); + + var certificateLoader = new CertificateLoader( + null, + loggerFactory.Object, + null, + Mock.Of(), + Mock.Of()); + + var loadedCertificates = certificateLoader.Load(configuration.GetSection("TestConfig:Certificates")); + Assert.Equal(0, loadedCertificates.Count()); + Assert.Single(logger.LogMessages, logMessage => + logMessage.LogLevel == LogLevel.Warning && + logMessage.Message == "Unable to find a matching certificate for subject 'localhost' in store 'My' in 'CurrentUser'."); + } + + private class MockLogger : ILogger + { + private readonly List _logMessages = new List(); + + public IEnumerable LogMessages => _logMessages; + + public IDisposable BeginScope(TState state) + { + throw new NotImplementedException(); + } + + public bool IsEnabled(LogLevel logLevel) + { + throw new NotImplementedException(); + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + _logMessages.Add(new LogMessage + { + LogLevel = logLevel, + Message = formatter(state, exception) + }); + } + + public class LogMessage + { + public LogLevel LogLevel { get; set; } + public string Message { get; set; } + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Certificates.Configuration.Tests/Microsoft.AspNetCore.Certificates.Configuration.Tests.csproj b/test/Microsoft.AspNetCore.Certificates.Configuration.Tests/Microsoft.AspNetCore.Certificates.Configuration.Tests.csproj new file mode 100644 index 0000000000..a72b7d129d --- /dev/null +++ b/test/Microsoft.AspNetCore.Certificates.Configuration.Tests/Microsoft.AspNetCore.Certificates.Configuration.Tests.csproj @@ -0,0 +1,32 @@ + + + + + + netcoreapp2.0;net461 + netcoreapp2.0 + + + true + win7-x64 + + + + + + + + + + + + + + + + + + diff --git a/test/Microsoft.AspNetCore.Certificates.Configuration.Tests/testCert.pfx b/test/Microsoft.AspNetCore.Certificates.Configuration.Tests/testCert.pfx new file mode 100644 index 0000000000..7118908c2d Binary files /dev/null and b/test/Microsoft.AspNetCore.Certificates.Configuration.Tests/testCert.pfx differ