fix(gateway): harden self-signed cert persistence and config validation

This commit is contained in:
Joseph Doherty
2026-06-01 07:37:27 -04:00
parent 3775f6bf3b
commit ddd5721082
7 changed files with 93 additions and 24 deletions
@@ -274,6 +274,11 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
$"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}."); $"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( AddIfInvalidPath(
options.SelfSignedCertPath, options.SelfSignedCertPath,
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.", "MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
@@ -1,6 +1,7 @@
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.AspNetCore.Hosting.StaticWebAssets;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Configuration;
using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -87,7 +88,11 @@ public static class GatewayApplication
builder.Configuration.GetSection("MxGateway:Tls").Get<Configuration.TlsOptions>() builder.Configuration.GetSection("MxGateway:Tls").Get<Configuration.TlsOptions>()
?? new Configuration.TlsOptions(); ?? 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( Security.Tls.SelfSignedCertificateProvider provider = new(
tlsOptions, tlsOptions,
loggerFactory.CreateLogger<Security.Tls.SelfSignedCertificateProvider>(), loggerFactory.CreateLogger<Security.Tls.SelfSignedCertificateProvider>(),
@@ -95,6 +100,8 @@ public static class GatewayApplication
X509Certificate2 certificate = provider.LoadOrCreate(); X509Certificate2 certificate = provider.LoadOrCreate();
builder.WebHost.ConfigureKestrel(options => 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)); options.ConfigureHttpsDefaults(https => https.ServerCertificate = certificate));
} }
@@ -18,6 +18,13 @@ public static class KestrelTlsInspector
/// </summary> /// </summary>
public static bool RequiresGeneratedCertificate(IConfiguration configuration) 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"); IConfigurationSection endpoints = configuration.GetSection("Kestrel:Endpoints");
foreach (IConfigurationSection endpoint in endpoints.GetChildren()) foreach (IConfigurationSection endpoint in endpoints.GetChildren())
{ {
@@ -28,13 +35,7 @@ public static class KestrelTlsInspector
continue; continue;
} }
IConfigurationSection certificate = endpoint.GetSection("Certificate"); if (!HasConfiguredCertificate(endpoint.GetSection("Certificate")))
bool hasOwnCertificate =
!string.IsNullOrWhiteSpace(certificate["Path"]) ||
!string.IsNullOrWhiteSpace(certificate["Subject"]) ||
!string.IsNullOrWhiteSpace(certificate["Thumbprint"]);
if (!hasOwnCertificate)
{ {
return true; return true;
} }
@@ -42,4 +43,9 @@ public static class KestrelTlsInspector
return false; return false;
} }
private static bool HasConfiguredCertificate(IConfigurationSection certificate)
=> !string.IsNullOrWhiteSpace(certificate["Path"]) ||
!string.IsNullOrWhiteSpace(certificate["Subject"]) ||
!string.IsNullOrWhiteSpace(certificate["Thumbprint"]);
} }
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Tls;
public sealed class SelfSignedCertificateProvider public sealed class SelfSignedCertificateProvider
{ {
private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1"; 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 TlsOptions _options;
private readonly ILogger<SelfSignedCertificateProvider> _logger; private readonly ILogger<SelfSignedCertificateProvider> _logger;
@@ -37,7 +38,8 @@ public sealed class SelfSignedCertificateProvider
key, key,
HashAlgorithmName.SHA256); 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( request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature, X509KeyUsageFlags.DigitalSignature,
critical: true)); critical: true));
@@ -110,6 +112,14 @@ public sealed class SelfSignedCertificateProvider
_logger.LogWarning(ex, _logger.LogWarning(ex,
"Persisted gateway certificate at {Path} is unreadable; regenerating.", path); "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); return GenerateAndPersist(path);
@@ -119,21 +129,36 @@ public sealed class SelfSignedCertificateProvider
{ {
using X509Certificate2 generated = GenerateCertificate(); using X509Certificate2 generated = GenerateCertificate();
byte[] pfx = generated.Export(X509ContentType.Pkcs12); byte[] pfx = generated.Export(X509ContentType.Pkcs12);
try
string? directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{ {
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() private static X509KeyStorageFlags KeyStorageFlags()
@@ -179,7 +204,7 @@ public sealed class SelfSignedCertificateProvider
private void Log(string action, X509Certificate2 cert) private void Log(string action, X509Certificate2 cert)
{ {
string sans = cert.Extensions string sans = cert.Extensions
.FirstOrDefault(e => e.Oid?.Value == "2.5.29.17")? .FirstOrDefault(e => e.Oid?.Value == SubjectAltNameOid)?
.Format(false) ?? "(none)"; .Format(false) ?? "(none)";
_logger.LogInformation( _logger.LogInformation(
"{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}", "{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}",
@@ -56,4 +56,13 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Failed); Assert.True(result.Failed);
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames")); 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."));
}
} }
@@ -51,6 +51,13 @@ public sealed class KestrelTlsInspectorTests
=> Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate( => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate(
Config(("Kestrel:Endpoints:Https:Url", "HTTPS://0.0.0.0:5120")))); 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] [Fact]
public void RequiresGeneratedCertificate_True_WhenMixedEndpointsAndOneHttpsHasNoCert() public void RequiresGeneratedCertificate_True_WhenMixedEndpointsAndOneHttpsHasNoCert()
=> Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate( => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate(
@@ -108,8 +108,18 @@ public sealed class SelfSignedCertificateProviderTests
finally { Directory.Delete(dir, recursive: true); } finally { Directory.Delete(dir, recursive: true); }
} }
[Fact]
public void LoadOrCreate_Throws_WhenSelfSignedCertPathBlank()
{
TlsOptions options = new() { SelfSignedCertPath = " " };
Assert.Throws<InvalidOperationException>(
() => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate());
}
private const string SubjectAltNameOid = "2.5.29.17";
private static string ReadSubjectAltNames(X509Certificate2 cert) private static string ReadSubjectAltNames(X509Certificate2 cert)
=> cert.Extensions => cert.Extensions
.First(e => e.Oid?.Value == "2.5.29.17") .First(e => e.Oid?.Value == SubjectAltNameOid)
.Format(false); .Format(false);
} }