Введение
Данная статья будет являться моим опытом, как использовать сертификат и успешно его отправить вместе с запросом. По тому, как 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 |
Результаты показались мне очень удивительными, особенно как человеку который на словах только знает что такое криптография и сертификаты. Но при этом это интересный кейс и возможно кому‑то он тоже поможет, когда новый сертификат станет настолько «тяжелым».
Буду рад любым фидбекам, так как в этой теме я новичок и это был мой первый опыт с проблемами сертификатов
В любом случае всем спасибо кто дочитал до конца!