Initial commit: ESB Certificate Manager.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 08:10:13 +02:00
co-authored by Cursor
commit ed8e2822d1
36 changed files with 5111 additions and 0 deletions
@@ -0,0 +1,135 @@
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class TlsCertificateProbe
{
private readonly int _timeoutSeconds;
private readonly int _retryCount;
public TlsCertificateProbe(int timeoutSeconds = 8, int retryCount = 2)
{
_timeoutSeconds = Math.Clamp(timeoutSeconds, 1, 60);
_retryCount = Math.Clamp(retryCount, 0, 5);
}
public async Task<(bool Success, string Status, string? Detail, string? ObservedFingerprint)> ProbeAsync(
DeploymentTarget target,
string expectedFingerprintSha256,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(target.TlsHost))
{
return (true, "TLS übersprungen", "Kein TlsHost konfiguriert.", null);
}
int port = target.TlsPort is > 0 and <= 65535 ? target.TlsPort.Value : 443;
// TlsServerName überschreibt den SNI-Hostnamen wenn gesetzt (wichtig bei IP-Adressen)
string serverName = string.IsNullOrWhiteSpace(target.TlsServerName)
? target.TlsHost
: target.TlsServerName;
string expected = NormalizeFingerprint(expectedFingerprintSha256);
Exception? lastError = null;
for (int attempt = 0; attempt <= _retryCount; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
return await ProbeOnceAsync(
target.TlsHost,
port,
serverName,
expected,
cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
lastError = ex;
if (attempt < _retryCount)
{
await Task.Delay(400, cancellationToken);
}
}
}
return (
false,
"TLS nicht erreichbar",
lastError?.Message ?? $"Keine Verbindung zu {target.TlsHost}:{port}",
null);
}
private async Task<(bool Success, string Status, string? Detail, string? ObservedFingerprint)> ProbeOnceAsync(
string host,
int port,
string serverName,
string expectedFingerprint,
CancellationToken cancellationToken)
{
using TcpClient client = new();
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(_timeoutSeconds));
await client.ConnectAsync(host, port, timeoutCts.Token);
await using SslStream sslStream = new(
client.GetStream(),
leaveInnerStreamOpen: false,
userCertificateValidationCallback: static (_, _, _, _) => true);
await sslStream.AuthenticateAsClientAsync(
new SslClientAuthenticationOptions
{
TargetHost = serverName, // SNI: muss zum CN/SAN im Zertifikat passen
EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12
| System.Security.Authentication.SslProtocols.Tls13
},
timeoutCts.Token);
if (sslStream.RemoteCertificate is null)
{
return (false, "TLS Fail", "Kein Remote-Zertifikat erhalten.", null);
}
using X509Certificate2 remote = new(sslStream.RemoteCertificate);
string actual = Convert.ToHexString(SHA256.HashData(remote.RawData));
if (string.IsNullOrEmpty(expectedFingerprint))
{
// Kein erwarteter Fingerprint konfiguriert nur Konnektivität prüfen
return (true, "TLS Pass (kein Fingerprint-Vergleich)", $"{host}:{port} erreichbar. Fingerprint={actual}", actual);
}
if (string.Equals(actual, expectedFingerprint, StringComparison.OrdinalIgnoreCase))
{
return (true, "TLS Pass", $"{host}:{port} Fingerprint stimmt überein.", actual);
}
return (
false,
"TLS Fail",
$"{host}:{port} Fingerprint weicht ab. Erwartet={expectedFingerprint}, Ist={actual}",
actual);
}
private static string NormalizeFingerprint(string fingerprint)
{
return fingerprint
.Replace(":", string.Empty, StringComparison.Ordinal)
.Replace(" ", string.Empty, StringComparison.Ordinal)
.ToUpperInvariant();
}
}