using System.Net.Security; using System.Security.Cryptography.X509Certificates; namespace ServiceLib.Manager; /// /// Manager for certificate operations with CA pinning to prevent MITM attacks /// public class CertPemManager { private static readonly string _tag = "CertPemManager"; private static readonly Lazy _instance = new(() => new()); private Config _config; public async Task Init(Config config) { if (_config != null) { return; } _config = config; await Task.CompletedTask; } public static CertPemManager Instance => _instance.Value; /// /// Get certificate in PEM format from a server with CA pinning validation /// public async Task<(string?, string?)> GetCertPemAsync(string target, string serverName, List? verifyPeerCertByName = null, int timeout = 4) { try { var (domain, _, port, _) = Utils.ParseUrl(target); using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(timeout)); using var client = new TcpClient(); await client.ConnectAsync(domain, port > 0 ? port : 443, cts.Token); var callback = new RemoteCertificateValidationCallback((sender, certificate, chain, sslPolicyErrors) => ValidateServerCertificate(sender, certificate, chain, sslPolicyErrors, verifyPeerCertByName ?? [])); await using var ssl = new SslStream(client.GetStream(), false, callback); var sslOptions = new SslClientAuthenticationOptions { TargetHost = serverName, RemoteCertificateValidationCallback = callback, }; await ssl.AuthenticateAsClientAsync(sslOptions, cts.Token); var remote = ssl.RemoteCertificate; if (remote == null) { return (null, null); } var leaf = new X509Certificate2(remote); return (ExportCertToPem(leaf), null); } catch (OperationCanceledException) { Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {timeout} seconds")); return (null, $"Connection timeout after {timeout} seconds"); } catch (Exception ex) { Logging.SaveLog(_tag, ex); return (null, ex.Message); } } /// /// Get certificate chain in PEM format from a server with CA pinning validation /// public async Task<(List, string?)> GetCertChainPemAsync(string target, string serverName, List? verifyPeerCertByName = null, int timeout = 4) { var pemList = new List(); try { var (domain, _, port, _) = Utils.ParseUrl(target); using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(timeout)); using var client = new TcpClient(); await client.ConnectAsync(domain, port > 0 ? port : 443, cts.Token); var callback = new RemoteCertificateValidationCallback((sender, certificate, chain, sslPolicyErrors) => ValidateServerCertificate(sender, certificate, chain, sslPolicyErrors, verifyPeerCertByName ?? [])); await using var ssl = new SslStream(client.GetStream(), false, callback); var sslOptions = new SslClientAuthenticationOptions { TargetHost = serverName, RemoteCertificateValidationCallback = callback, }; await ssl.AuthenticateAsClientAsync(sslOptions, cts.Token); if (ssl.RemoteCertificate is not X509Certificate2 certChain) { return (pemList, null); } var chain = new X509Chain(); chain.Build(certChain); pemList.AddRange(chain.ChainElements.Select(element => ExportCertToPem(element.Certificate))); return (pemList, null); } catch (OperationCanceledException) { Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {timeout} seconds")); return (pemList, $"Connection timeout after {timeout} seconds"); } catch (Exception ex) { Logging.SaveLog(_tag, ex); return (pemList, ex.Message); } } /// /// Validate server certificate with CA pinning /// private bool ValidateServerCertificate( object _, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors, List verifyPeerCertByName) { if (certificate == null) { return false; } // Build certificate chain var cert2 = certificate as X509Certificate2 ?? new X509Certificate2(certificate); var certChain = chain ?? new X509Chain(); if (chain == null) { certChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; certChain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; certChain.ChainPolicy.VerificationTime = DateTime.UtcNow; if (!certChain.Build(cert2)) { return false; } } // Find root CA if (certChain.ChainElements.Count == 0) { return false; } var rootCert = certChain.ChainElements[^1].Certificate; var trustedCerts = BuildTrustedCertificateCollection(); if (!trustedCerts.Contains(rootCert)) { return false; } if (!sslPolicyErrors.HasFlag( SslPolicyErrors.RemoteCertificateNameMismatch)) { return true; } if (verifyPeerCertByName.Count == 0) { return false; } foreach (var ext in cert2.Extensions) { if (ext is not X509SubjectAlternativeNameExtension san) { continue; } return san.EnumerateDnsNames().Any(dnsName => verifyPeerCertByName.Contains(dnsName, StringComparer.OrdinalIgnoreCase)); } return false; } public static string ExportCertToPem(X509Certificate2 cert) { var der = cert.Export(X509ContentType.Cert); var b64 = Convert.ToBase64String(der); return $"-----BEGIN CERTIFICATE-----\n{b64}\n-----END CERTIFICATE-----\n"; } /// /// Parse concatenated PEM certificates string into a list of individual certificates /// Normalizes format: removes line breaks from base64 content for better compatibility /// /// Concatenated PEM certificates string (supports both \r\n and \n line endings) /// List of individual PEM certificate strings with normalized format public static List ParsePemChain(string pemChain) { var certs = new List(); if (string.IsNullOrWhiteSpace(pemChain)) { return certs; } // Normalize line endings (CRLF -> LF) at the beginning pemChain = pemChain.Replace("\r\n", "\n").Replace("\r", "\n"); const string beginMarker = "-----BEGIN CERTIFICATE-----"; const string endMarker = "-----END CERTIFICATE-----"; var index = 0; while (index < pemChain.Length) { var beginIndex = pemChain.IndexOf(beginMarker, index, StringComparison.Ordinal); if (beginIndex == -1) { break; } var endIndex = pemChain.IndexOf(endMarker, beginIndex, StringComparison.Ordinal); if (endIndex == -1) { break; } // Extract certificate content var base64Start = beginIndex + beginMarker.Length; var base64Content = pemChain.Substring(base64Start, endIndex - base64Start); // Remove all whitespace from base64 content base64Content = new string(base64Content.Where(c => !char.IsWhiteSpace(c)).ToArray()); // Reconstruct with clean format: BEGIN marker + base64 (no line breaks) + END marker var normalizedCert = $"{beginMarker}\n{base64Content}\n{endMarker}\n"; certs.Add(normalizedCert); // Move to next certificate index = endIndex + endMarker.Length; } return certs; } /// /// Concatenate a list of PEM certificates into a single string /// /// List of individual PEM certificate strings /// Concatenated PEM certificates string public static string ConcatenatePemChain(IEnumerable? pemList) { return pemList == null ? string.Empty : string.Concat(pemList); } public static string GetCertSha256Thumbprint(string pemCert, bool includeColon = false) { try { var cert = X509Certificate2.CreateFromPem(pemCert); var thumbprint = cert.GetCertHashString(HashAlgorithmName.SHA256); return includeColon ? string.Join(":", thumbprint.Chunk(2).Select(c => new string(c))) : thumbprint; } catch { return string.Empty; } } private static readonly Lazy _chromeRootCerts = new(() => { var pemText = EmbedUtils.GetEmbedText(Global.ChromeRootCertFileName); var collection = new X509Certificate2Collection(); collection.ImportFromPem(pemText); return collection; }); private static readonly Lazy _mozillaRootCerts = new(() => { var pemText = EmbedUtils.GetEmbedText(Global.MozillaRootCertFileName); var collection = new X509Certificate2Collection(); collection.ImportFromPem(pemText); return collection; }); private X509Certificate2Collection BuildTrustedCertificateCollection() { if (_config.GuiItem.RootCertProvider == Global.ChromeRootProvider) { return _chromeRootCerts.Value; } return _mozillaRootCerts.Value; } private bool IsSystemRootCertProvider() { return _config.GuiItem.RootCertProvider != Global.ChromeRootProvider && _config.GuiItem.RootCertProvider != Global.MozillaRootProvider; } public X509ChainPolicy? BuildCertificateChainPolicy() { if (IsSystemRootCertProvider()) { return null; } var trustedCerts = BuildTrustedCertificateCollection(); var chainPolicy = new X509ChainPolicy { TrustMode = X509ChainTrustMode.CustomRootTrust, RevocationMode = X509RevocationMode.NoCheck, }; chainPolicy.CustomTrustStore.AddRange(trustedCerts); return chainPolicy; } }