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)); } PathReachability reachability = AssessPathReachability(normalized); if (reachability == PathReachability.AccessDenied) { return CertificateProbeResult.ForAccessDenied(normalized); } if (reachability == PathReachability.Unreachable) { return CertificateProbeResult.Failed( "Pfad nicht erreichbar."); } if (reachability == PathReachability.FileMissing) { 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 (UnauthorizedAccessException) { return CertificateProbeResult.ForAccessDenied(normalized); } catch (IOException ex) when (IsAccessOrLogonFailure(ex)) { return CertificateProbeResult.ForAccessDenied(normalized); } 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 enum PathReachability { FileExists, FileMissing, AccessDenied, Unreachable } /// /// Unterscheidet „Datei fehlt“ von „Share/Pfad ohne Admin-Konto nicht erreichbar“. /// File.Exists liefert bei fehlenden Credentials oft nur false. /// private static PathReachability AssessPathReachability(string filePath) { try { if (File.Exists(filePath)) { return PathReachability.FileExists; } } catch (UnauthorizedAccessException) { return PathReachability.AccessDenied; } catch (IOException ex) when (IsAccessOrLogonFailure(ex)) { return PathReachability.AccessDenied; } catch (IOException) { return PathReachability.Unreachable; } string? directory = Path.GetDirectoryName(filePath); if (string.IsNullOrWhiteSpace(directory)) { return PathReachability.Unreachable; } return AssessDirectoryReachability(directory); } private static PathReachability AssessDirectoryReachability(string directory) { string current = directory.TrimEnd('\\', '/'); while (!string.IsNullOrWhiteSpace(current)) { try { if (Directory.Exists(current)) { // Exists kann bei UNC ohne Rechte „lügen“ – Auflisten erzwingen. _ = Directory.EnumerateFileSystemEntries(current) .Any(); return PathReachability.FileMissing; } if (current.StartsWith(@"\\", StringComparison.Ordinal)) { // Exists=false: echter Zugriffsfehler oder Ordner fehlt. _ = Directory.GetFileSystemEntries(current); return PathReachability.FileMissing; } // Lokaler Ordner fehlt → für Erst-Deployment als „Datei fehlt“ werten. return PathReachability.FileMissing; } catch (UnauthorizedAccessException) { return PathReachability.AccessDenied; } catch (IOException ex) when (IsAccessOrLogonFailure(ex)) { return PathReachability.AccessDenied; } catch (DirectoryNotFoundException) { string? parent = GetParentPath(current); if (parent is null || parent == current) { return current.StartsWith(@"\\", StringComparison.Ordinal) ? PathReachability.Unreachable : PathReachability.FileMissing; } current = parent; continue; } catch (IOException) { return PathReachability.Unreachable; } } return PathReachability.Unreachable; } private static string? GetParentPath(string path) { string trimmed = path.TrimEnd('\\', '/'); if (trimmed.StartsWith(@"\\", StringComparison.Ordinal)) { // \\server\share → Stop; darunter weiter nach oben. string withoutPrefix = trimmed[2..]; int slash = withoutPrefix.IndexOfAny(['\\', '/']); if (slash < 0) { return null; } int second = withoutPrefix.IndexOfAny(['\\', '/'], slash + 1); if (second < 0) { // Bereits Share-Root \\server\share return null; } } string? parent = Path.GetDirectoryName(trimmed); return string.IsNullOrWhiteSpace(parent) ? null : parent; } private static bool IsAccessOrLogonFailure(IOException ex) { // HRESULT-Lower-Word = Win32-Fehlercode int win32 = ex.HResult & 0xFFFF; return win32 is 5 or // ERROR_ACCESS_DENIED 53 or // ERROR_BAD_NETPATH 67 or // ERROR_BAD_NET_NAME 86 or // ERROR_INVALID_PASSWORD 1326 or // ERROR_LOGON_FAILURE 59 or // ERROR_UNEXP_NET_ERR 64 or // ERROR_NETNAME_DELETED 1219 or // ERROR_SESSION_CREDENTIAL_CONFLICT 1240 or // ERROR_LOGIN_WKSTA_RESTRICTION 1245 or // ERROR_ACCOUNT_RESTRICTION 1396; // ERROR_WRONG_TARGET_NAME } 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 const string AccessDeniedShortText = "Admin nötig"; public bool Success { get; init; } public bool FileExists { get; init; } public bool AccessDenied { 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 ForAccessDenied(string path) { return new CertificateProbeResult { Success = false, FileExists = false, AccessDenied = true, Path = path, Message = "Kein Zugriff auf den Pfad – Admin-Konto nötig." }; } 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" }; } }