using System; using System.IO; using System.Net; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using Serilog; using ZB.MOM.WW.LmxProxy.Host.Configuration; namespace ZB.MOM.WW.LmxProxy.Host.Security { /// /// Manages TLS certificates for the LmxProxy service, including generation and validation /// public class TlsCertificateManager { private static readonly ILogger Logger = Log.ForContext(); private readonly TlsConfiguration _tlsConfiguration; public TlsCertificateManager(TlsConfiguration tlsConfiguration) { _tlsConfiguration = tlsConfiguration ?? throw new ArgumentNullException(nameof(tlsConfiguration)); } /// /// Checks TLS certificate status and creates new certificates if needed /// /// True if certificates are valid or were successfully created public bool EnsureCertificatesValid() { if (!_tlsConfiguration.Enabled) { Logger.Information("TLS is disabled, skipping certificate check"); return true; } try { // Check if certificate files exist bool certificateExists = File.Exists(_tlsConfiguration.ServerCertificatePath); bool keyExists = File.Exists(_tlsConfiguration.ServerKeyPath); if (!certificateExists || !keyExists) { Logger.Warning("TLS certificate or key not found, generating new certificate"); return GenerateNewCertificate(); } // Check certificate expiration if (IsCertificateExpiringSoon(_tlsConfiguration.ServerCertificatePath)) { Logger.Warning("TLS certificate is expiring within the next year, generating new certificate"); return GenerateNewCertificate(); } Logger.Information("TLS certificate is valid"); return true; } catch (Exception ex) { Logger.Error(ex, "Error checking TLS certificates"); return false; } } /// /// Checks if a certificate is expiring within the next year /// private bool IsCertificateExpiringSoon(string certificatePath) { try { string certPem = File.ReadAllText(certificatePath); byte[] certBytes = GetBytesFromPem(certPem, "CERTIFICATE"); using var cert = new X509Certificate2(certBytes); DateTime expirationDate = cert.NotAfter; double daysUntilExpiration = (expirationDate - DateTime.Now).TotalDays; Logger.Information("Certificate expires on {ExpirationDate} ({DaysUntilExpiration:F0} days from now)", expirationDate, daysUntilExpiration); // Check if expiring within the next year (365 days) return daysUntilExpiration <= 365; } catch (Exception ex) { Logger.Error(ex, "Error checking certificate expiration"); // If we can't check expiration, assume it needs renewal return true; } } /// /// Generates a new self-signed certificate /// private bool GenerateNewCertificate() { try { Logger.Information("Generating new self-signed TLS certificate"); // Ensure directory exists string? certDir = Path.GetDirectoryName(_tlsConfiguration.ServerCertificatePath); if (!string.IsNullOrEmpty(certDir) && !Directory.Exists(certDir)) { Directory.CreateDirectory(certDir); Logger.Information("Created certificate directory: {Directory}", certDir); } // Generate a new self-signed certificate using var rsa = RSA.Create(2048); var request = new CertificateRequest( "CN=LmxProxy, O=SCADA Bridge, C=US", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); // Add certificate extensions request.CertificateExtensions.Add( new X509BasicConstraintsExtension(false, false, 0, false)); request.CertificateExtensions.Add( new X509KeyUsageExtension( X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); request.CertificateExtensions.Add( new X509EnhancedKeyUsageExtension( new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") // Server Authentication }, false)); // Add Subject Alternative Names var sanBuilder = new SubjectAlternativeNameBuilder(); sanBuilder.AddDnsName("localhost"); sanBuilder.AddDnsName(Environment.MachineName); sanBuilder.AddIpAddress(IPAddress.Loopback); sanBuilder.AddIpAddress(IPAddress.IPv6Loopback); request.CertificateExtensions.Add(sanBuilder.Build()); // Create the certificate with 2-year validity DateTimeOffset notBefore = DateTimeOffset.Now.AddDays(-1); DateTimeOffset notAfter = DateTimeOffset.Now.AddYears(2); using X509Certificate2? cert = request.CreateSelfSigned(notBefore, notAfter); // Export certificate to PEM format string certPem = ExportCertificateToPem(cert); File.WriteAllText(_tlsConfiguration.ServerCertificatePath, certPem); Logger.Information("Saved certificate to {Path}", _tlsConfiguration.ServerCertificatePath); // Export private key to PEM format string keyPem = ExportPrivateKeyToPem(rsa); File.WriteAllText(_tlsConfiguration.ServerKeyPath, keyPem); Logger.Information("Saved private key to {Path}", _tlsConfiguration.ServerKeyPath); // If client CA path is specified and doesn't exist, create it if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientCaCertificatePath) && !File.Exists(_tlsConfiguration.ClientCaCertificatePath)) { // For self-signed certificates, the CA cert is the same as the server cert File.WriteAllText(_tlsConfiguration.ClientCaCertificatePath, certPem); Logger.Information("Saved CA certificate to {Path}", _tlsConfiguration.ClientCaCertificatePath); } Logger.Information("Successfully generated new TLS certificate valid until {NotAfter}", notAfter); return true; } catch (Exception ex) { Logger.Error(ex, "Failed to generate new TLS certificate"); return false; } } /// /// Exports a certificate to PEM format /// private static string ExportCertificateToPem(X509Certificate2 cert) { var builder = new StringBuilder(); builder.AppendLine("-----BEGIN CERTIFICATE-----"); builder.AppendLine(Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks)); builder.AppendLine("-----END CERTIFICATE-----"); return builder.ToString(); } /// /// Exports an RSA private key to PEM format /// private static string ExportPrivateKeyToPem(RSA rsa) { var builder = new StringBuilder(); builder.AppendLine("-----BEGIN RSA PRIVATE KEY-----"); // For .NET Framework 4.8, we need to use the older export method RSAParameters parameters = rsa.ExportParameters(true); byte[] keyBytes = EncodeRSAPrivateKey(parameters); builder.AppendLine(Convert.ToBase64String(keyBytes, Base64FormattingOptions.InsertLineBreaks)); builder.AppendLine("-----END RSA PRIVATE KEY-----"); return builder.ToString(); } /// /// Encodes RSA parameters to PKCS#1 format for .NET Framework 4.8 /// private static byte[] EncodeRSAPrivateKey(RSAParameters parameters) { using (var stream = new MemoryStream()) using (var writer = new BinaryWriter(stream)) { // Write version writer.Write((byte)0x02); // INTEGER writer.Write((byte)0x01); // Length writer.Write((byte)0x00); // Version // Write modulus WriteIntegerBytes(writer, parameters.Modulus); // Write public exponent WriteIntegerBytes(writer, parameters.Exponent); // Write private exponent WriteIntegerBytes(writer, parameters.D); // Write prime1 WriteIntegerBytes(writer, parameters.P); // Write prime2 WriteIntegerBytes(writer, parameters.Q); // Write exponent1 WriteIntegerBytes(writer, parameters.DP); // Write exponent2 WriteIntegerBytes(writer, parameters.DQ); // Write coefficient WriteIntegerBytes(writer, parameters.InverseQ); byte[] innerBytes = stream.ToArray(); // Create SEQUENCE wrapper using (var finalStream = new MemoryStream()) using (var finalWriter = new BinaryWriter(finalStream)) { finalWriter.Write((byte)0x30); // SEQUENCE WriteLength(finalWriter, innerBytes.Length); finalWriter.Write(innerBytes); return finalStream.ToArray(); } } } private static void WriteIntegerBytes(BinaryWriter writer, byte[] bytes) { if (bytes == null) { bytes = new byte[] { 0 }; } writer.Write((byte)0x02); // INTEGER if (bytes[0] >= 0x80) { // Add padding byte for positive number WriteLength(writer, bytes.Length + 1); writer.Write((byte)0x00); writer.Write(bytes); } else { WriteLength(writer, bytes.Length); writer.Write(bytes); } } private static void WriteLength(BinaryWriter writer, int length) { if (length < 0x80) { writer.Write((byte)length); } else if (length <= 0xFF) { writer.Write((byte)0x81); writer.Write((byte)length); } else { writer.Write((byte)0x82); writer.Write((byte)(length >> 8)); writer.Write((byte)(length & 0xFF)); } } /// /// Extracts bytes from PEM format /// private static byte[] GetBytesFromPem(string pem, string section) { string header = $"-----BEGIN {section}-----"; string footer = $"-----END {section}-----"; int start = pem.IndexOf(header, StringComparison.Ordinal); if (start < 0) { throw new InvalidOperationException($"PEM {section} header not found"); } start += header.Length; int end = pem.IndexOf(footer, start, StringComparison.Ordinal); if (end < 0) { throw new InvalidOperationException($"PEM {section} footer not found"); } // Use Substring instead of range syntax for .NET Framework 4.8 compatibility string base64 = pem.Substring(start, end - start).Replace("\r", "").Replace("\n", ""); return Convert.FromBase64String(base64); } } }