diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs new file mode 100644 index 0000000..c3961fd --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Tls; + +/// +/// Generates and persists a long-lived self-signed certificate used as the +/// Kestrel HTTPS default when no operator certificate is configured. +/// +public sealed class SelfSignedCertificateProvider +{ + private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1"; + + private readonly TlsOptions _options; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public SelfSignedCertificateProvider( + TlsOptions options, + ILogger logger, + TimeProvider timeProvider) + { + _options = options; + _logger = logger; + _timeProvider = timeProvider; + } + + /// Creates a fresh in-memory ECDSA P-256 self-signed certificate. + public X509Certificate2 GenerateCertificate() + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CertificateRequest request = new( + new X500DistinguishedName("CN=MxAccessGateway Self-Signed"), + key, + HashAlgorithmName.SHA256); + + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + critical: true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + [new Oid(ServerAuthOid, "Server Authentication")], + critical: false)); + + SubjectAlternativeNameBuilder san = new(); + san.AddDnsName("localhost"); + string machine = Environment.MachineName; + if (!string.IsNullOrWhiteSpace(machine)) + { + san.AddDnsName(machine); + } + + foreach (string extra in _options.AdditionalDnsNames) + { + if (!string.IsNullOrWhiteSpace(extra)) + { + san.AddDnsName(extra); + } + } + + san.AddIpAddress(IPAddress.Loopback); + san.AddIpAddress(IPAddress.IPv6Loopback); + request.CertificateExtensions.Add(san.Build()); + + DateTimeOffset now = _timeProvider.GetUtcNow(); + return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(_options.ValidityYears)); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs new file mode 100644 index 0000000..f73cf36 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Tls; +using Xunit; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls; + +public sealed class SelfSignedCertificateProviderTests +{ + private static SelfSignedCertificateProvider CreateProvider(TlsOptions options, FakeTimeProvider time) + => new(options, NullLogger.Instance, time); + + [Fact] + public void GenerateCertificate_HasExpectedSansEkuAndValidity() + { + FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + TlsOptions options = new() { ValidityYears = 7, AdditionalDnsNames = ["gw.internal"] }; + + using X509Certificate2 cert = CreateProvider(options, time).GenerateCertificate(); + + Assert.Equal(time.GetUtcNow().AddYears(7).UtcDateTime.Date, cert.NotAfter.ToUniversalTime().Date); + Assert.True(cert.NotBefore.ToUniversalTime() < time.GetUtcNow().UtcDateTime); + Assert.True(cert.HasPrivateKey); + + string sans = ReadSubjectAltNames(cert); + Assert.Contains("localhost", sans); + Assert.Contains("gw.internal", sans); + + X509EnhancedKeyUsageExtension eku = cert.Extensions.OfType().Single(); + Assert.Contains(eku.EnhancedKeyUsages.Cast(), + o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth + } + + private static string ReadSubjectAltNames(X509Certificate2 cert) + => cert.Extensions + .First(e => e.Oid?.Value == "2.5.29.17") + .Format(false); +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj b/src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj index 714c3cf..2026e35 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj +++ b/src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj @@ -7,6 +7,7 @@ +