using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using ZA.CoreService.ESBCertificateManager.Models; namespace ZA.CoreService.ESBCertificateManager.Services; public sealed class CertificateProbeService { public static readonly string[] SupportedExtensions = [ ".cer", ".crt", ".pem", ".pfx", ".p12" ]; public CertificateProbeResult Probe( string certificatePath, Func? passwordPrompt = null) { string? normalized = NormalizePath(certificatePath); if (normalized is null) { return CertificateProbeResult.Failed( "Kein gültiger Zertifikatspfad angegeben."); } if (!IsSupportedExtension(normalized)) { return CertificateProbeResult.Failed( "Nicht unterstütztes Format. Erlaubt: " + string.Join(", ", SupportedExtensions)); } if (!File.Exists(normalized)) { return CertificateProbeResult.Missing( normalized, "Am Zielpfad liegt noch keine Zertifikatsdatei."); } try { CertificateInfo info = ReadCertificate(normalized, password: null); return CertificateProbeResult.Found(normalized, info); } catch (CryptographicException) when (IsPasswordProtected(normalized)) { if (passwordPrompt is null) { return CertificateProbeResult.PasswordRequired(normalized); } string? password = passwordPrompt.Invoke(); if (password is null) { return CertificateProbeResult.Failed( "Zum Lesen der PFX/P12-Datei wird ein Kennwort benötigt."); } try { CertificateInfo info = ReadCertificate(normalized, password); return CertificateProbeResult.Found(normalized, info); } catch (CryptographicException) { return CertificateProbeResult.Failed( "Das Kennwort ist falsch oder die Datei ist beschädigt."); } } catch (Exception ex) { return CertificateProbeResult.Failed( $"Zertifikat konnte nicht gelesen werden: {ex.Message}"); } } public static bool TrySplitTargetPath( string fullPath, out string directory, out string fileName, out string? error) { directory = string.Empty; fileName = string.Empty; error = null; string? normalized = NormalizePath(fullPath); if (normalized is null) { error = "Bitte einen vollständigen Pfad zur Zertifikatsdatei angeben."; return false; } if (!IsSupportedExtension(normalized)) { error = "Dateiendung nicht unterstützt. Erlaubt: " + string.Join(", ", SupportedExtensions); return false; } string? dir = Path.GetDirectoryName(normalized); string file = Path.GetFileName(normalized); if (string.IsNullOrWhiteSpace(dir) || string.IsNullOrWhiteSpace(file)) { error = "Pfad muss Verzeichnis und Dateiname enthalten, z. B.\n" + @"\\server\e$\...\certs\server.p12"; return false; } if (Path.GetFileName(file) != file) { error = "Dateiname darf keinen weiteren Verzeichnisanteil enthalten."; return false; } directory = dir; fileName = file; return true; } public static string CombineTargetPath( string directory, string fileName) { return Path.Combine( directory.Trim().TrimEnd('\\', '/'), fileName.Trim()); } public static bool IsSupportedExtension(string path) { string extension = Path.GetExtension(path); return SupportedExtensions.Contains( extension, StringComparer.OrdinalIgnoreCase); } private static bool IsPasswordProtected(string path) { string extension = Path.GetExtension(path); return extension.Equals(".pfx", StringComparison.OrdinalIgnoreCase) || extension.Equals(".p12", StringComparison.OrdinalIgnoreCase); } private static string? NormalizePath(string? path) { if (string.IsNullOrWhiteSpace(path)) { return null; } string trimmed = path.Trim().Trim('"'); if (trimmed.Length == 0) { return null; } // UNC und absolute lokale Pfade akzeptieren if (!Path.IsPathRooted(trimmed) && !trimmed.StartsWith(@"\\", StringComparison.Ordinal)) { return null; } return trimmed; } private static CertificateInfo ReadCertificate( string certificatePath, string? password) { string extension = Path.GetExtension(certificatePath); using X509Certificate2 certificate = IsPasswordProtected(certificatePath) ? new X509Certificate2( certificatePath, password, X509KeyStorageFlags.EphemeralKeySet) : new X509Certificate2(certificatePath); string subject = certificate.GetNameInfo( X509NameType.SimpleName, forIssuer: false); string issuer = certificate.GetNameInfo( X509NameType.SimpleName, forIssuer: true); if (string.IsNullOrWhiteSpace(subject)) { subject = certificate.Subject; } if (string.IsNullOrWhiteSpace(issuer)) { issuer = certificate.Issuer; } string fingerprint = certificate.GetCertHashString( HashAlgorithmName.SHA256); fingerprint = string.Join( ":", Enumerable.Range(0, fingerprint.Length / 2) .Select(index => fingerprint.Substring(index * 2, 2))); DateTimeOffset validFrom = new(certificate.NotBefore); DateTimeOffset validUntil = new(certificate.NotAfter); DateTimeOffset now = DateTimeOffset.Now; return new CertificateInfo { Subject = subject, Issuer = issuer, FingerprintSha256 = fingerprint, ValidFrom = validFrom, ValidUntil = validUntil, IsCurrentlyValid = now >= validFrom && now <= validUntil }; } } public sealed class CertificateProbeResult { public bool Success { get; init; } public bool FileExists { get; init; } public string Path { get; init; } = string.Empty; public string Message { get; init; } = string.Empty; public CertificateInfo? Certificate { get; init; } public static CertificateProbeResult Found( string path, CertificateInfo certificate) { string expiry = certificate.ValidUntil.ToLocalTime() .ToString("dd.MM.yyyy HH:mm"); string validity = certificate.IsCurrentlyValid ? "gültig" : "abgelaufen / ungültig"; return new CertificateProbeResult { Success = true, FileExists = true, Path = path, Certificate = certificate, Message = $"Zertifikat gefunden · Subject: {certificate.Subject} · " + $"Ablauf: {expiry} ({validity})" }; } public static CertificateProbeResult Missing( string path, string message) { return new CertificateProbeResult { Success = true, FileExists = false, Path = path, Message = message }; } public static CertificateProbeResult Failed(string message) { return new CertificateProbeResult { Success = false, FileExists = false, Message = message }; } public static CertificateProbeResult PasswordRequired(string path) { return new CertificateProbeResult { Success = false, FileExists = true, Path = path, Message = "PFX/P12 vorhanden – Kennwort nötig zum Lesen des Ablaufs" }; } }