using System.Net.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; namespace Deal.Grpc.Hosting; /// /// Загруженный набор сертификатов mTLS внутреннего gRPC (Ruling 6, план Task 13; общий шаблон — C31). /// /// /// Создаётся один раз на старте процесса, когда =true, из файлов /// deploy/certs (генерация — scripts/mtls-certs.sh); при выключенном флаге возвращает /// null — процесс остаётся на plaintext + service-token (Ruling 2 этапа 6). Экземпляр живёт до конца /// процесса: сертификаты держат Kestrel (серверный) и исходящие каналы процессов с клиентской ролью /// (клиентский), поэтому IDisposable сознательно нет — преждевременный Dispose сломал бы живые /// соединения. Fail-fast: при включённом флаге любой пустой/битый путь или пароль — /// на старте. /// /// Проверка второй стороны — цепочка на нашу CA (CustomRootTrust, без revocation): dev-CA не в системном /// хранилище, поэтому стандартная проверка доверия дала бы RemoteCertificateChainErrors и без кастомного /// билда цепочки каждое соединение отвергалось бы. /// public sealed class MtlsCertificates { // Роль в сообщениях об ошибках: CA-сертификат (проверка второй стороны). private const string CaRoleName = "CA-сертификат (проверка второй стороны)"; // Роль в сообщениях об ошибках: серверный сертификат Kestrel-gRPC. private const string ServerRoleName = "серверный сертификат Kestrel-gRPC процесса"; // Роль в сообщениях об ошибках: клиентский сертификат исходящих каналов. private const string ClientRoleName = "клиентский сертификат исходящих каналов (deal-client)"; private MtlsCertificates( X509Certificate2 caCertificate, X509Certificate2 serverCertificate, X509Certificate2 clientCertificate) { CaCertificate = caCertificate; ServerCertificate = serverCertificate; ClientCertificate = clientCertificate; } /// /// CA-сертификат из CaPem: корень доверия для проверки второй стороны. /// public X509Certificate2 CaCertificate { get; } /// /// Серверный сертификат процесса из PFX (подпись своего Kestrel-gRPC-эндпоинта). /// public X509Certificate2 ServerCertificate { get; } /// /// Клиентский сертификат из PFX (подпись исходящих каналов, общий deal-client). /// public X509Certificate2 ClientCertificate { get; } /// /// Загружает сертификаты из : null при выключенном флаге (режим plaintext), /// иначе — CA + серверный + клиентский с fail-fast на битые пути/пароли. /// /// Опции mTLS (env DEAL_MTLS_*). /// Набор сертификатов либо null (флаг выключен). /// Флаг включён, а путь не задан/файл не найден/не читается. public static MtlsCertificates? Load(MtlsOptions options) { ArgumentNullException.ThrowIfNull(options); if (!options.Enabled) { return null; } X509Certificate2 ca = LoadCaFromPem(options); X509Certificate2 server = LoadPfx(options.ServerCertPfx, options.ServerCertPassword, MtlsOptions.ServerCertPfxEnvKey, MtlsOptions.ServerCertPasswordEnvKey, ServerRoleName); X509Certificate2 client = LoadPfx(options.ClientCertPfx, options.ClientCertPassword, MtlsOptions.ClientCertPfxEnvKey, MtlsOptions.ClientCertPasswordEnvKey, ClientRoleName); return new MtlsCertificates(ca, server, client); } /// /// Серверная проверка клиентского сертификата для Kestrel (ClientCertificateValidation): сертификат /// обязан быть подписан нашей CA (цепочка до CaPem). Стандартные ошибки цепочки (наша CA вне системного /// хранилища) пересобираются кастомным билдом; иные ошибки (нет сертификата/недоступен) — отказ. /// /// Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен). /// Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA). /// Ошибки стандартной проверки TLS. public bool ValidateClientCertificate( X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) { if (certificate is null) { return false; } if (sslPolicyErrors == SslPolicyErrors.None) { return true; } if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors) { return IsTrustedByCa(certificate); } return false; } /// /// Создаёт HTTP/2-хендлер исходящего канала: клиентский сертификат + проверка CA сервера /// (используют процессы с исходящими gRPC-каналами — общий шаблон). /// /// Новый SocketsHttpHandler (владелец — создатель; канал GrpcChannel закроет его вместе с собой). public SocketsHttpHandler CreateClientHttpHandler() { var handler = new SocketsHttpHandler { SslOptions = new SslClientAuthenticationOptions { ClientCertificates = new X509CertificateCollection { ClientCertificate }, RemoteCertificateValidationCallback = ValidateServerCertificate, }, }; return handler; } // Клиентская проверка сертификата сервера (RemoteCertificateValidationCallback): имя из SAN + // цепочка до нашей CA; сертификаты не нашей CA/чужое имя — отказ. // sender: Отправитель (не используется). // certificate: Сертификат сервера из рукопожатия. // chain: Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA). // sslPolicyErrors: Ошибки стандартной проверки TLS. private bool ValidateServerCertificate( object? sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) { if (certificate is null) { return false; } if (sslPolicyErrors == SslPolicyErrors.None) { return true; } // Имя хоста проверяется отдельно от доверия: несовпадение SAN (подключились не к тому сервису) — // безусловный отказ, даже если цепочка сошлась бы на нашу CA. if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0 || (sslPolicyErrors & SslPolicyErrors.RemoteCertificateNotAvailable) != 0) { return false; } if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateChainErrors) != 0) { using var leaf = new X509Certificate2(certificate); return IsTrustedByCa(leaf); } return false; } // Строит цепочку candidate → наша CA (CustomRootTrust, без revocation) — признак «свой» сертификат. // candidate: Проверяемый сертификат второй стороны. private bool IsTrustedByCa(X509Certificate2 candidate) { using var chain = new X509Chain(); chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; chain.ChainPolicy.CustomTrustStore.Add(CaCertificate); chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; return chain.Build(candidate); } // Читает CA из PEM/DER (только публичный сертификат — ключ CA нужен лишь скрипту генерации). // options: Опции mTLS. private static X509Certificate2 LoadCaFromPem(MtlsOptions options) { string path = RequireExistingFile(options.CaPem, MtlsOptions.CaPemEnvKey, CaRoleName); try { return X509CertificateLoader.LoadCertificateFromFile(path); } catch (CryptographicException exception) { throw new InvalidOperationException( $"{CaRoleName} ({MtlsOptions.CaPemEnvKey}): не удалось прочитать \"{path}\" — ожидается PEM/DER X.509.", exception); } } // Читает PFX (серверный/клиентский) с паролем; EphemeralKeySet — ключ не оседает в хранилище ОС. // configuredPath: Путь из env. // password: Пароль PFX. // pathEnvKey: Env-ключ пути (для сообщения об ошибке). // passwordEnvKey: Env-ключ пароля (для сообщения об ошибке). // role: Роль сертификата (для сообщения об ошибке). private static X509Certificate2 LoadPfx( string configuredPath, string password, string pathEnvKey, string passwordEnvKey, string role) { string path = RequireExistingFile(configuredPath, pathEnvKey, role); try { return X509CertificateLoader.LoadPkcs12FromFile( path, password, X509KeyStorageFlags.EphemeralKeySet); } catch (CryptographicException exception) { throw new InvalidOperationException( $"{role} ({pathEnvKey}): не удалось открыть \"{path}\" — проверьте путь и пароль ({passwordEnvKey}).", exception); } } // Fail-fast: путь обязан быть задан и указывать на существующий файл. // configuredPath: Путь из env. // envKey: Env-ключ пути (для сообщения об ошибке). // role: Роль сертификата (для сообщения об ошибке). private static string RequireExistingFile( string configuredPath, string envKey, string role) { if (string.IsNullOrWhiteSpace(configuredPath)) { throw new InvalidOperationException( $"mTLS включён (DEAL_MTLS_ENABLED=1), но не задан путь {role}: env {envKey}."); } string path = configuredPath.Trim(); if (!File.Exists(path)) { throw new InvalidOperationException($"{role} ({envKey}): файл не найден \"{path}\"."); } return path; } }