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 c3961fd..1816d5b 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs
@@ -68,4 +68,121 @@ public sealed class SelfSignedCertificateProvider
DateTimeOffset now = _timeProvider.GetUtcNow();
return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(_options.ValidityYears));
}
+
+ /// Loads the persisted certificate, regenerating when missing,
+ /// expired (and allowed), or unreadable.
+ public X509Certificate2 LoadOrCreate()
+ {
+ string path = _options.SelfSignedCertPath;
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new InvalidOperationException(
+ "MxGateway:Tls:SelfSignedCertPath must be set when an HTTPS endpoint has no certificate.");
+ }
+
+ if (File.Exists(path))
+ {
+ try
+ {
+ X509Certificate2 existing = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags());
+ if (existing.NotAfter.ToUniversalTime() > _timeProvider.GetUtcNow().UtcDateTime)
+ {
+ Log("Loaded", existing);
+ return existing;
+ }
+
+ if (!_options.RegenerateIfExpired)
+ {
+ string notAfter = existing.NotAfter.ToUniversalTime().ToString("u");
+ existing.Dispose();
+ throw new InvalidOperationException(
+ $"Persisted gateway certificate at '{path}' expired on {notAfter} " +
+ "and MxGateway:Tls:RegenerateIfExpired is false.");
+ }
+
+ _logger.LogWarning(
+ "Persisted gateway certificate at {Path} expired on {NotAfter:u}; regenerating.",
+ path, existing.NotAfter.ToUniversalTime());
+ existing.Dispose();
+ }
+ catch (CryptographicException ex)
+ {
+ _logger.LogWarning(ex,
+ "Persisted gateway certificate at {Path} is unreadable; regenerating.", path);
+ }
+ }
+
+ return GenerateAndPersist(path);
+ }
+
+ private X509Certificate2 GenerateAndPersist(string path)
+ {
+ using X509Certificate2 generated = GenerateCertificate();
+ byte[] pfx = generated.Export(X509ContentType.Pkcs12);
+
+ string? directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ 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()
+ => OperatingSystem.IsWindows()
+ ? X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable
+ : X509KeyStorageFlags.Exportable;
+
+ private static void HardenPermissions(string path)
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ HardenWindowsAcl(path);
+ }
+ else
+ {
+ File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ }
+
+ [System.Runtime.Versioning.SupportedOSPlatform("windows")]
+ private static void HardenWindowsAcl(string path)
+ {
+ FileInfo file = new(path);
+ System.Security.AccessControl.FileSecurity security = new();
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+
+ foreach (System.Security.Principal.WellKnownSidType sid in new[]
+ {
+ System.Security.Principal.WellKnownSidType.LocalSystemSid,
+ System.Security.Principal.WellKnownSidType.BuiltinAdministratorsSid,
+ })
+ {
+ System.Security.Principal.SecurityIdentifier identifier = new(sid, null);
+ security.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
+ identifier,
+ System.Security.AccessControl.FileSystemRights.FullControl,
+ System.Security.AccessControl.AccessControlType.Allow));
+ }
+
+ file.SetAccessControl(security);
+ }
+
+ private void Log(string action, X509Certificate2 cert)
+ {
+ string sans = cert.Extensions
+ .FirstOrDefault(e => e.Oid?.Value == "2.5.29.17")?
+ .Format(false) ?? "(none)";
+ _logger.LogInformation(
+ "{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}",
+ action, cert.Thumbprint, cert.NotAfter.ToUniversalTime(), sans);
+ }
}
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 f73cf36..e376118 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs
@@ -33,6 +33,75 @@ public sealed class SelfSignedCertificateProviderTests
o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth
}
+ [Fact]
+ public void LoadOrCreate_GeneratesPersistsAndReuses_SameThumbprint()
+ {
+ string dir = Directory.CreateTempSubdirectory().FullName;
+ try
+ {
+ string path = Path.Combine(dir, "gw.pfx");
+ FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
+ TlsOptions options = new() { SelfSignedCertPath = path };
+
+ using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
+ Assert.True(File.Exists(path));
+ using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();
+
+ Assert.Equal(first.Thumbprint, second.Thumbprint); // reused, not regenerated
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void LoadOrCreate_Regenerates_WhenPersistedCertExpired()
+ {
+ string dir = Directory.CreateTempSubdirectory().FullName;
+ try
+ {
+ string path = Path.Combine(dir, "gw.pfx");
+ FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
+ TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1 };
+
+ using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
+ time.Advance(TimeSpan.FromDays(800)); // past 1-year validity
+ using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();
+
+ Assert.NotEqual(first.Thumbprint, second.Thumbprint);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void LoadOrCreate_Regenerates_WhenPersistedFileCorrupt()
+ {
+ string dir = Directory.CreateTempSubdirectory().FullName;
+ try
+ {
+ string path = Path.Combine(dir, "gw.pfx");
+ File.WriteAllText(path, "not a pfx");
+ TlsOptions options = new() { SelfSignedCertPath = path };
+ using X509Certificate2 cert = CreateProvider(options, new FakeTimeProvider()).LoadOrCreate();
+ Assert.True(cert.HasPrivateKey);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void LoadOrCreate_Throws_WhenExpiredAndRegenerateDisabled()
+ {
+ string dir = Directory.CreateTempSubdirectory().FullName;
+ try
+ {
+ string path = Path.Combine(dir, "gw.pfx");
+ FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
+ TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1, RegenerateIfExpired = false };
+ using (CreateProvider(options, time).LoadOrCreate()) { }
+ time.Advance(TimeSpan.FromDays(800));
+ Assert.Throws(() => CreateProvider(options, time).LoadOrCreate());
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
private static string ReadSubjectAltNames(X509Certificate2 cert)
=> cert.Extensions
.First(e => e.Oid?.Value == "2.5.29.17")