From 5366482648c3bb81dd8843e224f37e1746eaf392 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 14 Sep 2026 18:35:48 +0200 Subject: [PATCH 1/3] Load the HTTPS certificate while validating settings instead of at Kestrel bind time AddServiceControlHttps loaded the certificate inside the ConfigureHttpsDefaults callback, which Kestrel invokes when binding endpoints. An unusable certificate therefore failed only after RavenDB, the transport and every hosted service had started and had to be torn down again. ValidateCertificateConfiguration now loads the certificate too, and the failure names the file, its size and last-modified time, and whether a password was configured, but never the password itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../Https/HostApplicationBuilderExtensions.cs | 18 +++---- .../HttpsSettings.cs | 30 +++++++++++ .../Settings/HttpsSettingsTests.cs | 54 +++++++++++++++++-- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs index 1f8df12831..f5152869ed 100644 --- a/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs @@ -1,7 +1,6 @@ namespace ServiceControl.Hosting.Https; using System; -using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpsPolicy; @@ -35,23 +34,18 @@ public static void AddServiceControlHttps(this WebApplicationBuilder hostBuilder // Kestrel HTTPS is disabled by default if (settings.Enabled) { + // The certificate was loaded and validated when HttpsSettings was constructed. Doing it + // here instead would defer the failure to endpoint binding, which happens after every + // hosted service has already started and has to be torn down again. + var certificate = settings.Certificate ?? throw new InvalidOperationException("HTTPS is enabled but no certificate was loaded."); + hostBuilder.WebHost.ConfigureKestrel(kestrel => { kestrel.ConfigureHttpsDefaults(httpsOptions => { - httpsOptions.ServerCertificate = LoadCertificate(settings); + httpsOptions.ServerCertificate = certificate; }); }); } } - - static X509Certificate2 LoadCertificate(HttpsSettings settings) - { - if (string.IsNullOrEmpty(settings.CertificatePassword)) - { - return X509CertificateLoader.LoadPkcs12FromFile(settings.CertificatePath, null); - } - - return X509CertificateLoader.LoadPkcs12FromFile(settings.CertificatePath, settings.CertificatePassword); - } } diff --git a/src/ServiceControl.Infrastructure/HttpsSettings.cs b/src/ServiceControl.Infrastructure/HttpsSettings.cs index 1db700c3c8..9d98256f70 100644 --- a/src/ServiceControl.Infrastructure/HttpsSettings.cs +++ b/src/ServiceControl.Infrastructure/HttpsSettings.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Infrastructure; using System; using System.IO; +using System.Security.Cryptography.X509Certificates; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using ServiceControl.Configuration; @@ -57,6 +58,12 @@ public HttpsSettings(SettingsRootNamespace rootNamespace) [JsonIgnore] public string CertificatePassword { get; } + /// + /// The certificate loaded from , or null when HTTPS is disabled. + /// + [JsonIgnore] + public X509Certificate2 Certificate { get; private set; } + /// /// When true, HTTP requests will be redirected to HTTPS. /// Requires HTTPS to be properly configured. Default is false. @@ -103,6 +110,29 @@ void ValidateCertificateConfiguration() logger.LogCritical(message); throw new InvalidOperationException(message); } + + // Loaded here rather than when Kestrel binds its endpoints: an unusable certificate is a + // configuration error, and binding happens only after every hosted service has started. + try + { + Certificate = string.IsNullOrEmpty(CertificatePassword) + ? X509CertificateLoader.LoadPkcs12FromFile(CertificatePath, null) + : X509CertificateLoader.LoadPkcs12FromFile(CertificatePath, CertificatePassword); + } + catch (Exception ex) + { + // .NET reports several unrelated causes as "the password may be incorrect", so describe + // the file itself too. Never the password, only whether one was configured. + var file = new FileInfo(CertificatePath); + var message = $"The HTTPS certificate could not be loaded, so this instance cannot start. " + + $"Https.CertificatePath: '{CertificatePath}' ({file.Length} bytes, last modified {file.LastWriteTimeUtc:u}). " + + $"Https.CertificatePassword configured: {!string.IsNullOrEmpty(CertificatePassword)}. " + + $"{ex.GetType().Name}: {ex.Message} " + + $"Check that the file is a PKCS#12/PFX holding both the certificate and its private key, and that Https.CertificatePassword matches it. " + + $"To start without HTTPS while investigating, set Https.Enabled to false."; + logger.LogCritical(message); + throw new InvalidOperationException(message, ex); + } } void LogConfiguration() diff --git a/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs b/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs index 8a0730e49b..0caba4ba81 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs @@ -2,6 +2,8 @@ namespace ServiceControl.UnitTests.Infrastructure.Settings; using System; using System.IO; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using NUnit.Framework; using ServiceControl.Configuration; using ServiceControl.Infrastructure; @@ -22,9 +24,21 @@ public class HttpsSettingsTests string tempCertPath; [SetUp] - public void SetUp() => - // Create a temporary file to simulate a certificate file - tempCertPath = Path.GetTempFileName(); + public void SetUp() + { + // The certificate is loaded as part of validation, so tests that get that far need a real PFX + tempCertPath = Path.Combine(Path.GetTempPath(), $"sc-test-{Guid.NewGuid():n}.pfx"); + WritePfx(tempCertPath); + } + + static void WritePfx(string path, string password = null) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=ServiceControl.Tests", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + + File.WriteAllBytes(path, certificate.Export(X509ContentType.Pkcs12, password)); + } [TearDown] public void TearDown() @@ -89,6 +103,8 @@ public void Should_read_certificate_path() [Test] public void Should_read_certificate_password() { + WritePfx(tempCertPath, "my-secret-password"); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true"); Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", tempCertPath); Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD", "my-secret-password"); @@ -119,6 +135,38 @@ public void Should_throw_when_certificate_path_does_not_exist() Assert.That(ex.Message, Does.Contain("does not exist")); } + [Test] + public void Should_load_certificate_when_https_enabled() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true"); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", tempCertPath); + + var settings = new HttpsSettings(TestNamespace); + + Assert.That(settings.Certificate, Is.Not.Null); + } + + [Test] + public void Should_throw_when_certificate_cannot_be_loaded() + { + WritePfx(tempCertPath, "correct-password"); + + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true"); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", tempCertPath); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD", "wrong-password"); + + var ex = Assert.Throws(() => new HttpsSettings(TestNamespace)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.Message, Does.Contain("could not be loaded")); + Assert.That(ex.Message, Does.Contain(tempCertPath)); + Assert.That(ex.Message, Does.Contain("Https.CertificatePassword configured: True")); + Assert.That(ex.Message, Does.Not.Contain("correct-password")); + Assert.That(ex.Message, Does.Not.Contain("wrong-password")); + } + } + [Test] public void Should_enable_redirect_when_configured() { From 3408365330e22de0027000d1e0da11e3cc436e52 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 16 Sep 2026 11:19:49 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20Reject=20HTTPS=20certificates?= =?UTF-8?q?=20without=20a=20private=20key=20and=20report=20the=20root=20ca?= =?UTF-8?q?use=20of=20load=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kestrel does not verify the private key when binding, so a certificate-only PFX started fine and then failed every TLS handshake. The load-failure message now uses the base exception, which for an empty file exposes the EndOfStreamException that CryptographicException otherwise hides. The certificate is assigned in the constructor like the other properties, and the PFX-only wording replaces the stale ".pfx or .pem" references. --- .../HttpsSettings.cs | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/ServiceControl.Infrastructure/HttpsSettings.cs b/src/ServiceControl.Infrastructure/HttpsSettings.cs index 9d98256f70..a3903ef2e2 100644 --- a/src/ServiceControl.Infrastructure/HttpsSettings.cs +++ b/src/ServiceControl.Infrastructure/HttpsSettings.cs @@ -25,7 +25,7 @@ public HttpsSettings(SettingsRootNamespace rootNamespace) CertificatePath = SettingsReader.Read(rootNamespace, "Https.CertificatePath"); CertificatePassword = SettingsReader.Read(rootNamespace, "Https.CertificatePassword"); - ValidateCertificateConfiguration(); + Certificate = LoadCertificate(); } // HTTPS redirection - disabled by default for backwards compatibility @@ -46,7 +46,7 @@ public HttpsSettings(SettingsRootNamespace rootNamespace) public bool Enabled { get; } /// - /// Path to the HTTPS certificate file (.pfx or .pem). + /// Path to the HTTPS certificate file (PKCS#12 / .pfx). /// Required when Https.Enabled is true. /// public string CertificatePath { get; } @@ -62,7 +62,7 @@ public HttpsSettings(SettingsRootNamespace rootNamespace) /// The certificate loaded from , or null when HTTPS is disabled. /// [JsonIgnore] - public X509Certificate2 Certificate { get; private set; } + public X509Certificate2 Certificate { get; } /// /// When true, HTTP requests will be redirected to HTTPS. @@ -95,11 +95,11 @@ public HttpsSettings(SettingsRootNamespace rootNamespace) /// public bool HstsIncludeSubDomains { get; } - void ValidateCertificateConfiguration() + X509Certificate2 LoadCertificate() { if (string.IsNullOrWhiteSpace(CertificatePath)) { - var message = "Https.CertificatePath is required when HTTPS is enabled. Please specify the path to a valid HTTPS certificate file (.pfx or .pem)"; + var message = "Https.CertificatePath is required when HTTPS is enabled. Please specify the path to a valid PKCS#12 (.pfx) certificate file"; logger.LogCritical(message); throw new InvalidOperationException(message); } @@ -113,9 +113,10 @@ void ValidateCertificateConfiguration() // Loaded here rather than when Kestrel binds its endpoints: an unusable certificate is a // configuration error, and binding happens only after every hosted service has started. + X509Certificate2 certificate; try { - Certificate = string.IsNullOrEmpty(CertificatePassword) + certificate = string.IsNullOrEmpty(CertificatePassword) ? X509CertificateLoader.LoadPkcs12FromFile(CertificatePath, null) : X509CertificateLoader.LoadPkcs12FromFile(CertificatePath, CertificatePassword); } @@ -123,16 +124,31 @@ void ValidateCertificateConfiguration() { // .NET reports several unrelated causes as "the password may be incorrect", so describe // the file itself too. Never the password, only whether one was configured. + var cause = ex.GetBaseException(); var file = new FileInfo(CertificatePath); var message = $"The HTTPS certificate could not be loaded, so this instance cannot start. " + $"Https.CertificatePath: '{CertificatePath}' ({file.Length} bytes, last modified {file.LastWriteTimeUtc:u}). " + $"Https.CertificatePassword configured: {!string.IsNullOrEmpty(CertificatePassword)}. " + - $"{ex.GetType().Name}: {ex.Message} " + + $"{cause.GetType().Name}: {cause.Message} " + $"Check that the file is a PKCS#12/PFX holding both the certificate and its private key, and that Https.CertificatePassword matches it. " + $"To start without HTTPS while investigating, set Https.Enabled to false."; logger.LogCritical(message); throw new InvalidOperationException(message, ex); } + + // Kestrel does not check this when binding. Without the private key every TLS handshake + // fails instead, which surfaces only as clients being unable to connect. + if (!certificate.HasPrivateKey) + { + var message = $"The HTTPS certificate does not contain a private key, so this instance cannot start. " + + $"Https.CertificatePath: '{CertificatePath}' (subject '{certificate.Subject}', thumbprint {certificate.Thumbprint}). " + + $"Export the certificate as PKCS#12/PFX including its private key. " + + $"To start without HTTPS while investigating, set Https.Enabled to false."; + logger.LogCritical(message); + throw new InvalidOperationException(message); + } + + return certificate; } void LogConfiguration() From b95cb108340d44770c227665a6727d6cac1d4309 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 16 Sep 2026 11:26:23 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20Reject=20HTTPS=20certificates?= =?UTF-8?q?=20whose=20Extended=20Key=20Usage=20excludes=20Server=20Authent?= =?UTF-8?q?ication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the rule Kestrel applies when binding the HTTPS endpoint, so the failure is reported during settings validation instead of after every hosted service has started. A certificate without an EKU extension is accepted, as Kestrel does. --- .../HttpsSettings.cs | 35 +++++++++++++++++++ .../Settings/HttpsSettingsTests.cs | 26 +++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/ServiceControl.Infrastructure/HttpsSettings.cs b/src/ServiceControl.Infrastructure/HttpsSettings.cs index a3903ef2e2..87c1001ca0 100644 --- a/src/ServiceControl.Infrastructure/HttpsSettings.cs +++ b/src/ServiceControl.Infrastructure/HttpsSettings.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Infrastructure; using System; using System.IO; +using System.Linq; using System.Security.Cryptography.X509Certificates; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; @@ -148,9 +149,43 @@ X509Certificate2 LoadCertificate() throw new InvalidOperationException(message); } + // Kestrel applies this rule when the HTTPS endpoint is bound; checking it here reports it + // before any hosted service has started. A certificate without an EKU extension is accepted. + if (!IsAllowedForServerAuthentication(certificate)) + { + var message = $"The HTTPS certificate cannot be used for server authentication, so this instance cannot start. " + + $"Https.CertificatePath: '{CertificatePath}' (subject '{certificate.Subject}', thumbprint {certificate.Thumbprint}). " + + $"Its Extended Key Usage extension does not include Server Authentication (OID {ServerAuthenticationOid}). " + + $"To start without HTTPS while investigating, set Https.Enabled to false."; + logger.LogCritical(message); + throw new InvalidOperationException(message); + } + return certificate; } + const string ServerAuthenticationOid = "1.3.6.1.5.5.7.3.1"; + + static bool IsAllowedForServerAuthentication(X509Certificate2 certificate) + { + var hasEkuExtension = false; + + foreach (var extension in certificate.Extensions.OfType()) + { + hasEkuExtension = true; + + foreach (var oid in extension.EnhancedKeyUsages) + { + if (string.Equals(oid.Value, ServerAuthenticationOid, StringComparison.Ordinal)) + { + return true; + } + } + } + + return !hasEkuExtension; + } + void LogConfiguration() { var httpsPortDisplay = HttpsPort.HasValue ? HttpsPort.Value.ToString() : "(null)"; diff --git a/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs b/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs index 0caba4ba81..ce9b94ed01 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs @@ -31,10 +31,16 @@ public void SetUp() WritePfx(tempCertPath); } - static void WritePfx(string path, string password = null) + static void WritePfx(string path, string password = null, string enhancedKeyUsageOid = null) { using var key = RSA.Create(2048); var request = new CertificateRequest("CN=ServiceControl.Tests", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + if (enhancedKeyUsageOid != null) + { + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension([new Oid(enhancedKeyUsageOid)], critical: false)); + } + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); File.WriteAllBytes(path, certificate.Export(X509ContentType.Pkcs12, password)); @@ -167,6 +173,24 @@ public void Should_throw_when_certificate_cannot_be_loaded() } } + [Test] + public void Should_throw_when_certificate_is_not_valid_for_server_authentication() + { + const string clientAuthenticationOid = "1.3.6.1.5.5.7.3.2"; + WritePfx(tempCertPath, enhancedKeyUsageOid: clientAuthenticationOid); + + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true"); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", tempCertPath); + + var ex = Assert.Throws(() => new HttpsSettings(TestNamespace)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.Message, Does.Contain("server authentication")); + Assert.That(ex.Message, Does.Contain(tempCertPath)); + } + } + [Test] public void Should_enable_redirect_when_configured() {