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;
}
}