Введение

Данная статья будет являться моим опытом, как использовать сертификат и успешно его отправить вместе с запросом. По тому, как BouncyCastle достаточно старая библиотека и на нее мало свежих туториалов и появилась идея написать эту статью.

Данная работа написана исключительно в рамках моих рабочих будней и не является профессиональным гайдом.

Как я к этому пришел

Мой проект занимается медициной и отправкой документов на подпись онлайн (для выдачи рецептов). Недавно наш старый сертификат почти закончил свой срок жизни и нам прислали новый. Однако при замене сертификата и приватного ключа оказалось, что на Azure энвах мы ловим тайм ауты, в то время как с локал хоста все запросы отправляются и приходят 200 в ответе.

Начало ресерча

Первое что приходит в голову — может неправильные конфиги на Azure? Как оказалось нет, так как если отправить с контейнера курл запрос — всегда приходило 200 и никаких проблем не наблюдалось (причем быстро очень). Тогда закрались опасения насчет того, насколько быстро работает наш HttpClient. Ниже приведет код регистрации его в сервис провайдере:

builder.Services.AddHttpClient<SigningClient>((serviceProvider, client) =>
  {
      var options = serviceProvider.GetService<IOptions<SigningSettings>>()?.Value;
      if (options?.Url != null)
      {
          client.BaseAddress = new Uri(options.Url);
          client.Timeout = TimeSpan.FromSeconds(options.ConnectionTimeout);
      }
  
      client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(
        MediaTypeNames.Application.Json));
  })
  .ConfigurePrimaryHttpMessageHandler(serviceProvider =>
  {
      var options = serviceProvider.GetService<IOptions<SigningSettings>>()?.Value;
  
      var httpClientHandler = new HttpClientHandler
      {
          ClientCertificates =
          {
              GetClientCertificate(options)
          }
      };
  
      return httpClientHandler;
  });

В целом не увидев никаких проблем в регистрации — я написал свой MyHttpClientHandler и добавил парочку логов, чтобы потенциально увидеть проблему

public class MyHttpClientHandler : HttpClientHandler
{
    public MyHttpClientHandler()
    {
        ServerCertificateCustomValidationCallback = ServerCertificateCustomValidation;
    }

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, 
CancellationToken cancellationToken)
    {
        Log.Information("-- Information about call -- " +
                               "\nClientCertificateOptions: " +
                               "{ClientCertificateOptions},\n" +
                               "ClientCertificates:\n{ClientCertificates}",
                               ClientCertificateOptions.ToString(),
                               ClientCertificates[0].ToString());

        var response = base.SendAsync(request, cancellationToken);

        var content = response.GetAwaiter().GetResult()
          .Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult();

        Log.Information("Response: {Content}\nStatusCode: {StatusCode}", 
          content, response.Result.StatusCode);

        return response;
    }

    private static bool ServerCertificateCustomValidation(HttpRequestMessage 
      requestMessage, X509Certificate2 certificate, X509Chain chain,
      SslPolicyErrors sslErrors)
    {
        // Based on the custom logic it is possible to 
        // decide whether the client considers certificate valid or not
        Log.Information($"Errors: {sslErrors}");
        return sslErrors == SslPolicyErrors.None;
    }
}

В целом, после деплоя как вы думаете что я увидел? Абсолютно ничего. Метод .SendAsync стал ботлнек без всяких объяснений! Использовав старый сертификат было обнаружено, что все работаем в штатном режиме и очень быстро.

Продолжение ресерча

Просмотрев что я имею на данный момент, я решил посмотреть как мы работаем с сертификатом. На входе у нас есть ClientCert (сертификат) и ClientCertKey (приватный ключ). Ниже код, который обрабатываем данные значения.

var provider = new RSACryptoServiceProvider(2048);
provider.ImportFromPem(Encoding.UTF8.GetString(
  Convert.FromBase64String(_settings.ClientCertKey)));

var certificate = new X509Certificate2(
  Convert.FromBase64String(_settings.ClientCert));
var certificateWithPrivateKey = certificate.CopyWithPrivateKey(provider);

var cert = new X509Certificate2(
  certificateWithPrivateKey.Export(X509ContentType.Pfx));

В целом никакой проблеме в коде я не увидел. Возможно, знатоки криптографии и смогут упростить этот код, но на тот момент мне ничего явного и РАБОЧЕГО в голову не пришло (так как пару раз я попробовал переписать этот код и ничего не вышло)

После этого я решил написать тест, который выполняет данный код и потом шлет его на наш сервис. В целом, код выглядел вот так:

[Fact]
public async Task OldApproach()
{
    var provider = new RSACryptoServiceProvider(2048);
    provider.ImportFromPem(Encoding.UTF8.GetString(
      Convert.FromBase64String(_settings.ClientCertKey)));

    var certificate = new X509Certificate2(
      Convert.FromBase64String(_settings.ClientCert));
    var certificateWithPrivateKey = certificate.CopyWithPrivateKey(provider);

    var cert = new X509Certificate2(
      certificateWithPrivateKey.Export(X509ContentType.Pfx));

    var result = await GetResponse(cert);

    result.StatusCode.Should().Be(HttpStatusCode.OK);
}

Много останавливаться на методе GetResponse(X509Certificate2 cert) останавливаться не будем, просто скажу что он имеет такой вид:

    private async Task<HttpResponseMessage> GetResponse(X509Certificate2 cert)
    {
        _handler.ClientCertificates.Add(cert);
        _client = new HttpClient(_handler);
        return await _client.SendAsync(GetRequest());
    }

Как вы думаете, какой результат показал мне код? Среднее время выполнения было от 10.9 до 12.4 секунд (где-то 10-15 колов было сделано). Это показалось мне долгим и я попробовал старый сертификат - среднее время выполнения от 1.8 до 2.5 секунд.

Откуда такая разница во времени?

Я не знаю. Может у кого есть свои догадки? Буду раз обсудить!

Ну пора решать проблему, а то бизнес ждет!

Вообщем, первое что приходит в голову когда что‑то долго работает — использовать другой нугет. В этот раз так и вышло, мой взгляд сразу упал на BouncyCastle, как на главный исходник всех остальных либ.

Первая проблема с которой я столкнулся — очень мало актуальной информации. Я прям часами сидел, чтобы найти какую‑то полезную инфу, но мой максимум — какие‑то вопросы 3–4 летней давности.

Методом тыка и сбора из каждого вопроса по строчке у меня получился следующий код:

byte[] certData = Convert.FromBase64String(options.ClientCert);
string encodedPrivateKey = Encoding.UTF8.GetString
  Convert.FromBase64String(options.ClientCertKey));

var certParser = new X509CertificateParser();
var certStructure = certParser.ReadCertificate(certData);

var certGenerator = new X509V3CertificateGenerator();

// Set the certificate's serial number, issuer, and subject
certGenerator.SetSerialNumber(certStructure.SerialNumber);
certGenerator.SetIssuerDN(certStructure.IssuerDN);
certGenerator.SetSubjectDN(certStructure.SubjectDN);

// Set the certificate's validity period
certGenerator.SetNotBefore(certStructure.NotBefore);
certGenerator.SetNotAfter(certStructure.NotAfter);

// Set Public Key
var publicKey = certStructure.GetPublicKey();
certGenerator.SetPublicKey(publicKey);

// Private key reading
AsymmetricKeyParameter privateKey;
using (var stringReader = new StringReader(encodedPrivateKey))
{
    var pemReader = new PemReader(stringReader);
    var pemObject = pemReader.ReadObject();
    privateKey = ((AsymmetricCipherKeyPair)pemObject).Private;
}

// Generate the certificate
ISignatureFactory signatureFactory = new Asn1SignatureFactory(
  "SHA256WITHRSA", privateKey, new SecureRandom());
Org.BouncyCastle.X509.X509Certificate certificate = certGenerator
  .Generate(signatureFactory);

// Convert the BouncyCastle X509Certificate to .NET X509Certificate2
var dotNetCertificate = new X509Certificate2(certificate.GetEncoded());

В целом что делает этот код в каждый момент времени и так расписано, но если кратко

  • Декодим и читаем наш сертификат

  • Копируем из него все полезные данные

  • Читаем приватный ключ (эту часть я вообще еле откопал)

  • Создаем сертификат

  • Переводим его в X509Certificate2

В целом, под данный код был также написан тест точно такого же плана, как и под старый код:

[Fact]
public async Task NewApproach()
{
    // Код с геренарацией опущен, он написан выше

    // Convert the BouncyCastle X509Certificate to .NET X509Certificate2
    var dotNetCertificate = new X509Certificate2(certificate.GetEncoded());

    var result = await GetResponse(dotNetCertificate);

    result.StatusCode.Should().Be(HttpStatusCode.OK);
}

Как вы думаете, какие результаты показал данный код?

Результаты

Старый сертификат

Новый сертификат

Старый код

1.8-2.5 s

10.9-12.4 s

Новый код

340-400 ms

430-480 ms

Результаты показались мне очень удивительными, особенно как человеку который на словах только знает что такое криптография и сертификаты. Но при этом это интересный кейс и возможно кому‑то он тоже поможет, когда новый сертификат станет настолько «тяжелым».

Буду рад любым фидбекам, так как в этой теме я новичок и это был мой первый опыт с проблемами сертификатов

В любом случае всем спасибо кто дочитал до конца!

Комментарии (13)


  1. RouR
    28.06.2023 12:19

    У сертификата есть алгоритм подписи. Он может быть разным, с разной вычислительной сложностью. Может в новом алгоритм какой-то неподдерживающийся?


    1. vliashko Автор
      28.06.2023 12:19

      Я так понимаю за это отвечает SignatureAlgorithm? Если да, то у обоих он sha256RSA


      1. AntoineLarine
        28.06.2023 12:19

        Надо различать ключ, к которому выпущен сертификат, и подпись издателя под самим сертификатом. То что в сертификате обозначено как sha256RSA - это второе.


  1. Vanirn
    28.06.2023 12:19

    Необычная статься по .NET для хабра – чаще разбирают очень странные кейсы, которые могут быть сильно упрощены стандартным функционалом .NET.

    Почему в методе HttpClientHandler.SendAsync использовали .GetAwaiter().GetResult() вместо нормального await, метод же возвращает  Task?


    1. vliashko Автор
      28.06.2023 12:19

      В целом не имеет особой роли что в данном кейсе использовать. Можно было написать и такой код:

          protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
          {
              Log.Information("-- Information about call -- " +
                                     "\nClientCertificateOptions: " +
                                     "{ClientCertificateOptions},\n" +
                                     "ClientCertificates:\n{ClientCertificates}",
                                     ClientCertificateOptions.ToString(),
                                     ClientCertificates[0].ToString());
      
              var response = await base.SendAsync(request, cancellationToken);
      
              var content = await response.Content.ReadAsStringAsync(cancellationToken);
      
              Log.Information("Response: {Content}\nStatusCode: {StatusCode}", content, response.StatusCode);
      
              return response;
          }

      Данный хендлер писался как быстрый и не претендует быть красивым кодом, скорее просто информативная часть.


  1. Scratch
    28.06.2023 12:19
    +1

    var cs = new RSACryptoServiceProvider(2048)

    генерирует новую ключевую пару RSA каждый раз. Зачем вам это? Вы же импортируете свою


    1. vliashko Автор
      28.06.2023 12:19

      Вопрос хороший на самом деле. Этот код был новый для меня и как мне подсказывает интуиция взялся он откуда из интернета. А как в таком случае приписать приватный ключ к сертификату? Попытка использовать вот такой метод

      X509Certificate2.CreateFromPem

      сложилась не очень удачно..


      1. AntoineLarine
        28.06.2023 12:19

        По поводу скорости. Возможно, в новом сертификате размер ключа больше. Например, было 2048, стало 4096.

        И какая конкретно ошибка с X509Certificate2.CreateFromPem()? Обратите внимание, что в документации на метод описаны допустимые заголовки PEM.

        Также можно предварительно экспортировать сертификат в PFX, и в приложении загружать уже оттуда, а не собирать из двух PEM-ов.


      1. AntoineLarine
        28.06.2023 12:19

        Ваш пример кода по генерации сертификата с помощью BC тоже неверный. Вы подписываете сертификат его же закрытым ключом, а нужно подписывать закрытым ключом издателя, которого у вас нет (если, конечно, сертификат не самоподписанный). Т.е. на выходе вы получаете другой сертификат. Если на стороне-приёмнике будет проверка такого клиентского сертификата, то он её не пройдёт.


  1. Mingun
    28.06.2023 12:19

    Вообще, ситуация с BouncyCastle напоминает этот комикс. Все используют это плохо документированное нечто, но никто не найдет в себе силы написать нормальную библиотеку. Эй, фанаты криптографии, ау!


    1. vliashko Автор
      28.06.2023 12:19

      Я видел и другие варианты пакетов, но у всех в референсах лежит касл)

      Поэтому думаю не скоро будет такое еще :D


    1. AntoineLarine
      28.06.2023 12:19

      Ещё одна её особенность - полностью своя managed-реализация всей криптографии. С соответствующими проблемами: низкая скорость в отличие от оптимизированных системных криптопровайдеров, ключи хранятся в памяти приложения, и далеко не всегда хотя бы зануляются. Но иногда да, в определённых случаях BC выручает, хотя отсутствие нормальной документации сильно затрудняет освоение.

      Нормальную библиотеку потому и не пишут, что нет смысла, системные всё равно не переплюнуть в плане скорости и надёжности. Да и в свежих версиях .NET улучшения в плане поддержки криптографии прямо радуют, хотя и тут не без изъянов.


  1. onyxmaster
    28.06.2023 12:19

    А что с настройками валидации сертификата, доступностью CRL и OCSP?