Больше двух параметров — каждый на отдельной строке (закрывающая скобка в конце последнего); два и меньше — в одну строку. Правило добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам в 253 файлах.
249 lines
13 KiB
C#
249 lines
13 KiB
C#
using System.Net.Security;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
namespace Deal.Grpc.Hosting;
|
|
|
|
/// <summary>
|
|
/// Загруженный набор сертификатов mTLS внутреннего gRPC (Ruling 6, план Task 13; общий шаблон — C31).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Создаётся один раз на старте процесса, когда <see cref="MtlsOptions.Enabled"/>=true, из файлов
|
|
/// deploy/certs (генерация — scripts/mtls-certs.sh); при выключенном флаге <see cref="Load"/> возвращает
|
|
/// null — процесс остаётся на plaintext + service-token (Ruling 2 этапа 6). Экземпляр живёт до конца
|
|
/// процесса: сертификаты держат Kestrel (серверный) и исходящие каналы процессов с клиентской ролью
|
|
/// (клиентский), поэтому IDisposable сознательно нет — преждевременный Dispose сломал бы живые
|
|
/// соединения. Fail-fast: при включённом флаге любой пустой/битый путь или пароль —
|
|
/// <see cref="InvalidOperationException"/> на старте.
|
|
///
|
|
/// Проверка второй стороны — цепочка на нашу CA (CustomRootTrust, без revocation): dev-CA не в системном
|
|
/// хранилище, поэтому стандартная проверка доверия дала бы RemoteCertificateChainErrors и без кастомного
|
|
/// билда цепочки каждое соединение отвергалось бы.
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// CA-сертификат из CaPem: корень доверия для проверки второй стороны.
|
|
/// </summary>
|
|
public X509Certificate2 CaCertificate { get; }
|
|
|
|
/// <summary>
|
|
/// Серверный сертификат процесса из PFX (подпись своего Kestrel-gRPC-эндпоинта).
|
|
/// </summary>
|
|
public X509Certificate2 ServerCertificate { get; }
|
|
|
|
/// <summary>
|
|
/// Клиентский сертификат из PFX (подпись исходящих каналов, общий deal-client).
|
|
/// </summary>
|
|
public X509Certificate2 ClientCertificate { get; }
|
|
|
|
/// <summary>
|
|
/// Загружает сертификаты из <paramref name="options"/>: null при выключенном флаге (режим plaintext),
|
|
/// иначе — CA + серверный + клиентский с fail-fast на битые пути/пароли.
|
|
/// </summary>
|
|
/// <param name="options">Опции mTLS (env DEAL_MTLS_*).</param>
|
|
/// <returns>Набор сертификатов либо null (флаг выключен).</returns>
|
|
/// <exception cref="InvalidOperationException">Флаг включён, а путь не задан/файл не найден/не читается.</exception>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Серверная проверка клиентского сертификата для Kestrel (ClientCertificateValidation): сертификат
|
|
/// обязан быть подписан нашей CA (цепочка до CaPem). Стандартные ошибки цепочки (наша CA вне системного
|
|
/// хранилища) пересобираются кастомным билдом; иные ошибки (нет сертификата/недоступен) — отказ.
|
|
/// </summary>
|
|
/// <param name="certificate">Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен).</param>
|
|
/// <param name="chain">Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA).</param>
|
|
/// <param name="sslPolicyErrors">Ошибки стандартной проверки TLS.</param>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Создаёт HTTP/2-хендлер исходящего канала: клиентский сертификат + проверка CA сервера
|
|
/// (используют процессы с исходящими gRPC-каналами — общий шаблон).
|
|
/// </summary>
|
|
/// <returns>Новый SocketsHttpHandler (владелец — создатель; канал GrpcChannel закроет его вместе с собой).</returns>
|
|
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;
|
|
}
|
|
}
|