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")