Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
}
30 changes: 30 additions & 0 deletions src/ServiceControl.Infrastructure/HttpsSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,6 +58,12 @@ public HttpsSettings(SettingsRootNamespace rootNamespace)
[JsonIgnore]
public string CertificatePassword { get; }

/// <summary>
/// The certificate loaded from <see cref="CertificatePath"/>, or null when HTTPS is disabled.
/// </summary>
[JsonIgnore]
public X509Certificate2 Certificate { get; private set; }

/// <summary>
/// When true, HTTP requests will be redirected to HTTPS.
/// Requires HTTPS to be properly configured. Default is false.
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Another scenario to consider is that the certificate file has no private key. This currently wont throw but just not allow any conections. Could we also check for !Certificate.HasPrivateKey and throw if false?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That makes sense indeed as we will be using the private key.

? 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} " +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would this hide the actual issue? Should we use ex.GetBaseException()?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@warwickschroeder In the logs it showed:

2026-09-14 12:27:48.2887|00:00:12.500|14|Error|Microsoft.Extensions.Hosting.Internal.Host|Hosting failed to start|System.Security.Cryptography.CryptographicException: The certificate data cannot be read with the provided password, the password may be incorrect.

We should be good but I'll test just to be sure.

$"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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<InvalidOperationException>(() => 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()
{
Expand Down