using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Bit.Core.Platform.X509ChainCustomization; using Bit.Core.Settings; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using Xunit; namespace Bit.Core.Test.Platform.X509ChainCustomization; public class X509ChainCustomizationServiceCollectionExtensionsTests { private static X509Certificate2 CreateSelfSignedCert(string commonName) { using var rsa = RSA.Create(2048); var certRequest = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return certRequest.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); } [Fact] public async Task OptionsPatternReturnsCachedValue() { var tempDir = Directory.CreateTempSubdirectory("certs"); var tempCert = Path.Combine(tempDir.FullName, "test.crt"); await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); var services = CreateServices((gs, environment, config) => { config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; }); // Create options once var firstOptions = services.GetRequiredService>().Value; Assert.NotNull(firstOptions.AdditionalCustomTrustCertificates); var cert = Assert.Single(firstOptions.AdditionalCustomTrustCertificates); Assert.Equal("CN=localhost", cert.Subject); // Since the second resolution should have cached values, deleting the file during operation // should have no impact. File.Delete(tempCert); // This is expected to be a cached version and doesn't actually need to go and read the file system var secondOptions = services.GetRequiredService>().Value; Assert.Same(firstOptions, secondOptions); // This is the same reference as the first one so it shouldn't be different but just in case. Assert.NotNull(secondOptions.AdditionalCustomTrustCertificates); Assert.Single(secondOptions.AdditionalCustomTrustCertificates); } [Fact] public async Task DoesNotProvideCustomCallbackOnCloud() { var tempDir = Directory.CreateTempSubdirectory("certs"); var tempCert = Path.Combine(tempDir.FullName, "test.crt"); await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); var options = CreateOptions((gs, environment, config) => { gs.SelfHosted = false; config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; }); Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); } [Fact] public async Task ManuallyAddingOptionsTakesPrecedence() { var tempDir = Directory.CreateTempSubdirectory("certs"); var tempCert = Path.Combine(tempDir.FullName, "test.crt"); await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); var services = CreateServices((gs, environment, config) => { config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; }, services => { services.Configure(options => { options.AdditionalCustomTrustCertificates = [CreateSelfSignedCert("example.com")]; }); }); var options = services.GetRequiredService>().Value; Assert.True(options.TryGetCustomRemoteCertificateValidationCallback(out var callback)); var cert = Assert.Single(options.AdditionalCustomTrustCertificates); Assert.Equal("CN=example.com", cert.Subject); var fakeLogCollector = services.GetFakeLogCollector(); Assert.Contains(fakeLogCollector.GetSnapshot(), r => r.Message == $"Additional custom trust certificates were added directly, skipping loading them from '{tempDir}'"); } [Fact] public void NullCustomDirectory_SkipsTryingToLoad() { var services = CreateServices((gs, environment, config) => { config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = null; }); var options = services.GetRequiredService>().Value; Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); } [Theory] [InlineData("Development", LogLevel.Debug)] [InlineData("Production", LogLevel.Warning)] public void CustomDirectoryDoesNotExist_Logs(string environment, LogLevel logLevel) { var fakeDir = "/fake/dir/that/does/not/exist"; var services = CreateServices((gs, hostEnvironment, config) => { hostEnvironment.EnvironmentName = environment; config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = fakeDir; }); var options = services.GetRequiredService>().Value; Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); var fakeLogCollector = services.GetFakeLogCollector(); Assert.Contains(fakeLogCollector.GetSnapshot(), r => r.Message == $"An additional custom trust certificate directory was given '{fakeDir}' but that directory does not exist." && r.Level == logLevel ); } [Fact] public async Task NamedOptions_NotConfiguredAsync() { // To help make sure this fails for the right reason we should add certs to the directory var tempDir = Directory.CreateTempSubdirectory("certs"); var tempCert = Path.Combine(tempDir.FullName, "test.crt"); await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); var services = CreateServices((gs, environment, config) => { config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; }); var options = services.GetRequiredService>(); var namedOptions = options.Get("SomeName"); Assert.Null(namedOptions.AdditionalCustomTrustCertificates); } [Fact] public void CustomLocation_NoCertificates_Logs() { var tempDir = Directory.CreateTempSubdirectory("certs"); var services = CreateServices((gs, hostEnvironment, config) => { config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; }); var options = services.GetRequiredService>().Value; Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); var fakeLogCollector = services.GetFakeLogCollector(); Assert.Contains(fakeLogCollector.GetSnapshot(), r => r.Message == $"No additional custom trust certificates were found in '{tempDir.FullName}'" ); } [Fact] public async Task CallHttpWithSelfSignedCert_SelfSignedCertificateConfigured_Works() { var selfSignedCertificate = CreateSelfSignedCert("localhost"); await using var app = await CreateServerAsync(55555, options => { options.ServerCertificate = selfSignedCertificate; }); var services = CreateServices((gs, environment, config) => {}, services => { services.Configure(options => { options.AdditionalCustomTrustCertificates = [selfSignedCertificate]; }); }); var httpClient = services.GetRequiredService().CreateClient(); var response = await httpClient.GetStringAsync("https://localhost:55555"); Assert.Equal("Hi", response); } [Fact] public async Task CallHttpWithSelfSignedCert_SelfSignedCertificateNotConfigured_Throws() { var selfSignedCertificate = CreateSelfSignedCert("localhost"); await using var app = await CreateServerAsync(55556, options => { options.ServerCertificate = selfSignedCertificate; }); var services = CreateServices((gs, environment, config) => {}, services => { services.Configure(options => { options.AdditionalCustomTrustCertificates = [CreateSelfSignedCert("example.com")]; }); }); var httpClient = services.GetRequiredService().CreateClient(); var requestException = await Assert.ThrowsAsync(async () => await httpClient.GetStringAsync("https://localhost:55556")); Assert.NotNull(requestException.InnerException); var authenticationException = Assert.IsAssignableFrom(requestException.InnerException); Assert.Equal("The remote certificate was rejected by the provided RemoteCertificateValidationCallback.", authenticationException.Message); } [Fact] public async Task CallHttpWithSelfSignedCert_SelfSignedCertificateConfigured_WithExtraCert_Works() { var selfSignedCertificate = CreateSelfSignedCert("localhost"); await using var app = await CreateServerAsync(55557, options => { options.ServerCertificate = selfSignedCertificate; }); var services = CreateServices((gs, environment, config) => {}, services => { services.Configure(options => { options.AdditionalCustomTrustCertificates = [selfSignedCertificate, CreateSelfSignedCert("example.com")]; }); }); var httpClient = services.GetRequiredService().CreateClient(); var response = await httpClient.GetStringAsync("https://localhost:55557"); Assert.Equal("Hi", response); } private static async Task CreateServerAsync(int port, Action configure) { // Start HTTP Server with self signed cert var builder = WebApplication.CreateSlimBuilder(); builder.Logging.AddFakeLogging(); builder.Services.AddRoutingCore(); builder.WebHost.UseKestrelCore() .ConfigureKestrel(options => { options.ListenLocalhost(port, listenOptions => { listenOptions.UseHttps(httpsOptions => { configure(httpsOptions); }); }); }); var app = builder.Build(); app.MapGet("/", () => "Hi"); await app.StartAsync(); return app; } private static X509ChainOptions CreateOptions(Action> configure, Action? after = null) { var services = CreateServices(configure, after); return services.GetRequiredService>().Value; } private static IServiceProvider CreateServices(Action> configure, Action? after = null) { var globalSettings = new GlobalSettings { // A solid default for these tests as these settings aren't allowed to work in cloud. SelfHosted = true, }; var hostEnvironment = Substitute.For(); hostEnvironment.EnvironmentName = "Development"; var config = new Dictionary(); configure(globalSettings, hostEnvironment, config); var services = new ServiceCollection(); services.AddLogging(logging => { logging.SetMinimumLevel(LogLevel.Debug); logging.AddFakeLogging(); }); services.AddSingleton(globalSettings); services.AddSingleton(hostEnvironment); services.AddSingleton( new ConfigurationBuilder() .AddInMemoryCollection(config) .Build() ); services.AddX509ChainCustomization(); after?.Invoke(services); return services.BuildServiceProvider(); } }