330 lines
12 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|