diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index 214a21f..19994bc 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -274,6 +274,11 @@ public sealed class GatewayOptionsValidator : IValidateOptions $"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}."); } + // The default is non-blank, so this only catches an explicitly-blanked path. + AddIfBlank( + options.SelfSignedCertPath, + "MxGateway:Tls:SelfSignedCertPath must not be blank.", + failures); AddIfInvalidPath( options.SelfSignedCertPath, "MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.", diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index ed43888..7b12193 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Configuration; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Configuration; @@ -87,7 +88,11 @@ public static class GatewayApplication builder.Configuration.GetSection("MxGateway:Tls").Get() ?? new Configuration.TlsOptions(); - using ILoggerFactory loggerFactory = LoggerFactory.Create(logging => logging.AddConsole()); + using ILoggerFactory loggerFactory = LoggerFactory.Create(logging => + { + logging.AddConfiguration(builder.Configuration.GetSection("Logging")); + logging.AddConsole(); + }); Security.Tls.SelfSignedCertificateProvider provider = new( tlsOptions, loggerFactory.CreateLogger(), @@ -95,6 +100,8 @@ public static class GatewayApplication X509Certificate2 certificate = provider.LoadOrCreate(); builder.WebHost.ConfigureKestrel(options => + // The certificate is intentionally owned by Kestrel for the application + // lifetime; it is not disposed here. options.ConfigureHttpsDefaults(https => https.ServerCertificate = certificate)); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs index 123de00..3c80573 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs @@ -18,6 +18,13 @@ public static class KestrelTlsInspector /// public static bool RequiresGeneratedCertificate(IConfiguration configuration) { + // A Kestrel default certificate applies to every endpoint that lacks its own. + // If the operator configured one, the gateway must not override it. + if (HasConfiguredCertificate(configuration.GetSection("Kestrel:Certificates:Default"))) + { + return false; + } + IConfigurationSection endpoints = configuration.GetSection("Kestrel:Endpoints"); foreach (IConfigurationSection endpoint in endpoints.GetChildren()) { @@ -28,13 +35,7 @@ public static class KestrelTlsInspector continue; } - IConfigurationSection certificate = endpoint.GetSection("Certificate"); - bool hasOwnCertificate = - !string.IsNullOrWhiteSpace(certificate["Path"]) || - !string.IsNullOrWhiteSpace(certificate["Subject"]) || - !string.IsNullOrWhiteSpace(certificate["Thumbprint"]); - - if (!hasOwnCertificate) + if (!HasConfiguredCertificate(endpoint.GetSection("Certificate"))) { return true; } @@ -42,4 +43,9 @@ public static class KestrelTlsInspector return false; } + + private static bool HasConfiguredCertificate(IConfigurationSection certificate) + => !string.IsNullOrWhiteSpace(certificate["Path"]) || + !string.IsNullOrWhiteSpace(certificate["Subject"]) || + !string.IsNullOrWhiteSpace(certificate["Thumbprint"]); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs index 271ab55..bbd33ff 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Tls; public sealed class SelfSignedCertificateProvider { private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1"; + private const string SubjectAltNameOid = "2.5.29.17"; private readonly TlsOptions _options; private readonly ILogger _logger; @@ -37,7 +38,8 @@ public sealed class SelfSignedCertificateProvider key, HashAlgorithmName.SHA256); - request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + // End-entity (non-CA) certificate: RFC 5280 marks BasicConstraints critical only for CA certs. + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); request.CertificateExtensions.Add(new X509KeyUsageExtension( X509KeyUsageFlags.DigitalSignature, critical: true)); @@ -110,6 +112,14 @@ public sealed class SelfSignedCertificateProvider _logger.LogWarning(ex, "Persisted gateway certificate at {Path} is unreadable; regenerating.", path); } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + // A permission/IO error must fail fast: do not regenerate (which would + // overwrite the operator's file), surface the cause instead. + throw new InvalidOperationException( + $"Persisted gateway certificate at '{path}' could not be read; the gateway " + + "process lacks read access or the file is otherwise inaccessible.", ex); + } } return GenerateAndPersist(path); @@ -119,21 +129,36 @@ public sealed class SelfSignedCertificateProvider { using X509Certificate2 generated = GenerateCertificate(); byte[] pfx = generated.Export(X509ContentType.Pkcs12); - - string? directory = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(directory)) + try { - Directory.CreateDirectory(directory); + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + // The private-key bytes must never touch a world-readable file. Create the + // temp file empty, harden its permissions, and only then write the PFX into + // the already-protected file. The temp path is in the same directory as the + // target so the Move is atomic and preserves the hardened DACL/mode. + string temp = path + ".tmp"; + using (File.Create(temp)) { } + HardenPermissions(temp); + + // Writing into an existing file truncates content but preserves its ACL/mode. + File.WriteAllBytes(temp, pfx); + File.Move(temp, path, overwrite: true); + HardenPermissions(path); + + X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags()); + Log("Generated", loaded); + return loaded; + } + finally + { + // pfx holds raw private-key material; clear it as soon as it is no longer needed. + Array.Clear(pfx, 0, pfx.Length); } - - string temp = path + ".tmp"; - File.WriteAllBytes(temp, pfx); - File.Move(temp, path, overwrite: true); - HardenPermissions(path); - - X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags()); - Log("Generated", loaded); - return loaded; } private static X509KeyStorageFlags KeyStorageFlags() @@ -179,7 +204,7 @@ public sealed class SelfSignedCertificateProvider private void Log(string action, X509Certificate2 cert) { string sans = cert.Extensions - .FirstOrDefault(e => e.Oid?.Value == "2.5.29.17")? + .FirstOrDefault(e => e.Oid?.Value == SubjectAltNameOid)? .Format(false) ?? "(none)"; _logger.LogInformation( "{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}", diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs index e4ed269..4d29773 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -56,4 +56,13 @@ public sealed class GatewayOptionsValidatorTests Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames")); } + + [Fact] + public void Validate_Fails_WhenSelfSignedCertPathBlank() + { + GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { SelfSignedCertPath = " " }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank.")); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs index d55c498..4851090 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs @@ -51,6 +51,13 @@ public sealed class KestrelTlsInspectorTests => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate( Config(("Kestrel:Endpoints:Https:Url", "HTTPS://0.0.0.0:5120")))); + [Fact] + public void RequiresGeneratedCertificate_False_WhenKestrelDefaultCertificateConfigured() + => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate( + Config( + ("Kestrel:Endpoints:Https:Url", "https://0.0.0.0:5120"), + ("Kestrel:Certificates:Default:Path", @"C:\certs\default.pfx")))); + [Fact] public void RequiresGeneratedCertificate_True_WhenMixedEndpointsAndOneHttpsHasNoCert() => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate( diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs index 986bffb..e75375e 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs @@ -108,8 +108,18 @@ public sealed class SelfSignedCertificateProviderTests finally { Directory.Delete(dir, recursive: true); } } + [Fact] + public void LoadOrCreate_Throws_WhenSelfSignedCertPathBlank() + { + TlsOptions options = new() { SelfSignedCertPath = " " }; + Assert.Throws( + () => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate()); + } + + private const string SubjectAltNameOid = "2.5.29.17"; + private static string ReadSubjectAltNames(X509Certificate2 cert) => cert.Extensions - .First(e => e.Oid?.Value == "2.5.29.17") + .First(e => e.Oid?.Value == SubjectAltNameOid) .Format(false); }