Files
Joseph Doherty 9dccf8e72f deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/
LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL
adapter files, and related docs to deprecated/. Removed LmxProxy registration
from DataConnectionFactory, project reference from DCL, protocol option from
UI, and cleaned up all requirement docs.
2026-04-08 15:56:23 -04:00

330 lines
12 KiB
C#

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
{
/// <summary>
/// Manages TLS certificates for the LmxProxy service, including generation and validation
/// </summary>
public class TlsCertificateManager
{
private static readonly ILogger Logger = Log.ForContext<TlsCertificateManager>();
private readonly TlsConfiguration _tlsConfiguration;
public TlsCertificateManager(TlsConfiguration tlsConfiguration)
{
_tlsConfiguration = tlsConfiguration ?? throw new ArgumentNullException(nameof(tlsConfiguration));
}
/// <summary>
/// Checks TLS certificate status and creates new certificates if needed
/// </summary>
/// <returns>True if certificates are valid or were successfully created</returns>
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;
}
}
/// <summary>
/// Checks if a certificate is expiring within the next year
/// </summary>
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;
}
}
/// <summary>
/// Generates a new self-signed certificate
/// </summary>
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;
}
}
/// <summary>
/// Exports a certificate to PEM format
/// </summary>
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();
}
/// <summary>
/// Exports an RSA private key to PEM format
/// </summary>
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();
}
/// <summary>
/// Encodes RSA parameters to PKCS#1 format for .NET Framework 4.8
/// </summary>
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));
}
}
/// <summary>
/// Extracts bytes from PEM format
/// </summary>
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);
}
}
}