using System.Net; using System.Net.Sockets; 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 const string SubjectAltNameOid = "2.5.29.17"; private readonly TlsOptions _options; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; /// Initializes a new instance of the class. /// TLS configuration options controlling certificate subject, validity, and storage. /// Logger for certificate lifecycle events. /// Time provider used to compute certificate validity windows. 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. /// A new self-signed with the configured SANs and validity period. public X509Certificate2 GenerateCertificate() { using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); CertificateRequest request = new( new X500DistinguishedName("CN=MxAccessGateway Self-Signed"), key, HashAlgorithmName.SHA256); // 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)); 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); } // Best-effort: add the machine FQDN when it differs from the short name and "localhost". // GetHostEntry may fail if DNS is unavailable; skip silently in that case. try { string fqdn = Dns.GetHostEntry(machine).HostName; if (!string.IsNullOrWhiteSpace(fqdn) && !fqdn.Equals("localhost", StringComparison.OrdinalIgnoreCase) && !fqdn.Equals(machine, StringComparison.OrdinalIgnoreCase)) { san.AddDnsName(fqdn); } } catch (SocketException) { /* DNS not resolvable — FQDN SAN is optional */ } catch (ArgumentException) { /* invalid host name — skip */ } 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)); } /// Loads the persisted certificate, regenerating when missing, /// expired (and allowed), or unreadable. /// The loaded or newly generated . 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); } 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); } private X509Certificate2 GenerateAndPersist(string path) { using X509Certificate2 generated = GenerateCertificate(); byte[] pfx = generated.Export(X509ContentType.Pkcs12); try { 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. // If the write or move fails the hardened temp file (which may contain private-key // material) must not be left on disk; delete it best-effort before rethrowing. try { File.WriteAllBytes(temp, pfx); File.Move(temp, path, overwrite: true); HardenPermissions(path); } catch (Exception) { try { File.Delete(temp); } catch { /* best effort */ } throw; } 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); } } 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 == SubjectAltNameOid)? .Format(false) ?? "(none)"; _logger.LogInformation( "{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}", action, cert.Thumbprint, cert.NotAfter.ToUniversalTime(), sans); } }