X.509 своми силами в .Net Core

image
Некоторое время назад я задался вопросом, можно ли наладить фабрику сертификатов, не прибегая к утилите openssl. Как подвести «под кнопку» весь процесс от генерации ключей до проверки подлинности. Забегая вперед, скажу, что пространство System.Security.Cryptography в этом плане является вполне самодостаточным. В статье я рассмотрю этапы создания сертификатов, экспорт в форматы pem и pkcs12, хранение сертификатов в файловой системе, а также проверку подлинности, используя только классы из System.Security.Cryptography.


Создание

В статье я буду рассматривать простую схему взаимодействия клиент-сервер, без удостоверяющего центра. Для взаимной проверки подлинности нам понадобится корневой самоподписанный сертификат caCert и два конечных clientCert и serverCert. Клиенты обмениваются своими сертификатами, проверяют подлинность корневым, успех. Создание сертификатов состоит из тех же этапов, как и любое руководство по openssl:


  1. Генерируем ассиметричный ключ:

    var rsaKey = RSA.Create(2048);

  2. Описываем субъект сертификации:

    string subject = "CN=myauthority.ru";

    cn — »common name», общепринятое имя организации, определяющее, какие имена хостов будет защищать сертификат. В общем случае строка может содержать дополнительные поля, разделенные косой чертой. Пример из MSDN »C = US/O = Microsoft/OU = WGA/CN = test».


  3. Создаем запрос на сертификат:

    var certReq = new CertificateRequest(subject, rsaKey,HashAlgorithmName.SHA256,RSASignaturePadding.Pkcs1);

    Режим Pkcs1 используется по умолчанию в openssl.


  4. Дополнительно настраиваем запрос:

    certReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); 
    certReq.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(certReq.PublicKey, false));

    Этими дополнениями мы указываем, что данный сертификат принадлежит центру сертификации, а также используем публичный ключ для создания SKI (идентификатора ключа субъекта).


  5. Создаем сертификат на 5 лет:

    var expirate = DateTimeOffset.Now.AddYears(5);
    var caCert  = certReq.CreateSelfSigned(DateTimeOffset.Now, expirate);

Теперь создаем конечные сертификаты.


  1. Запрос:

    var clientKey = RSA.Create(2048);
    string subject = "CN=10.10.10.*";
    var clientReq = new CertificateRequest(subject, clientKey,HashAlgorithmName.SHA256,RSASignaturePadding.Pkcs1);

    Здесь в качестве защищаемого объекта можно указать подсеть или конечный ip адрес, если доменное имя не используется.


  2. В дополнение к предыдущим настройкам мы указываем назначение ключа:

    clientReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
    clientReq.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.NonRepudiation, false));
    clientReq.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(clientReq.PublicKey, false));

  3. Назначаем сертификату серийный номер:

    byte[] serialNumber = BitConverter.GetBytes(DateTime.Now.ToBinary());

    Serial Number — уникальный идентификатор, назначенный каждому сертификату, выданному издателем. Рекомендуется использовать общедоступный счетчик, инициализированный на основе текущего времени.


  4. Создаем сертификат на 5 лет:

    var clientCert = clientReq.Create(caCert, DateTimeOffset.Now, expirate, serialNumber):

    Время экспирации не должно выходить за границы корневого сертификата, иначе мы получим соответствующую ошибку. Теперь, если сравнить поля caCert.Subject и clientCert.Issuer, то они совпадают, как мы и ожидали.



Хранение

Теперь, когда у нас есть все сертификаты, необходимо их сохранить. В руководстве openssl обычно предлагается хранить сертификаты в формате pem, разбив их на открытую часть public.crt и private.key. Полученные на предыдущих этапах сертификаты являются экземплярами класса X509Certificate2, который имеет все необходимые для нас свойства и методы. Итак, чтобы получить открытую часть, необходимо закодировать содержимое сертификата в base64 и записать в файл:

StringBuilder builder = new StringBuilder();
builder.AppendLine("-----BEGIN CERTIFICATE-----");
builder.AppendLine(Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END CERTIFICATE-----");
File.WriteAllText("public.crt", builder.ToString());

Также сохраним закрытый ключ:

RSA key = (RSA)cert.PrivateKey;
string name = key.SignatureAlgorithm.ToUpper();
StringBuilder builder = new StringBuilder();
builder.AppendLine($"-----BEGIN {name} PRIVATE KEY-----");
builder.AppendLine(Convert.ToBase64String(key.ExportRSAPrivateKey(), Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine($"-----END {name} PRIVATE KEY-----");
File.WriteAllText("private.key", builder.ToString());

Отмечу, что метод ExportRSAPrivateKey появился только в netcore 3.0, поэтому в предыдущих версиях могут возникнуть проблемы.

Еще один способ хранения сертификатов — формат pkcs12 или pfx, позволяющий уместить в одном файле открытую и закрытую часть. Класс X509Certificate2 содержит метод Export, принимающий в качестве аргумента необходимый нам ключ X509ContentType.Pkcs12 или X509ContentType.Pfx. Однако на этом этапе возникают трудности, поскольку загруженный обратно из файла сертификат неожиданно не содержит приватного ключа, о чем свидетельствует флаг cert.HasPrivateKey == false. Дело в том, что класс X509Certificate2 содержит внутреннюю метку, описывающую, куда и как экспортируется закрытый ключ. Эта метка инициализируется только с помощью конструктора и не может быть изменена после. Поэтому для создания файла p12 или pfx, необходимы дополнительные усилия:

var exportCert = new X509Certificate2(cert.Export(X509ContentType.Cert), (string)null, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet).CopyWithPrivateKey((RSA)cert.PrivateKey);
File.WriteAllBytes("client.pfx", exportCert.Export(X509ContentType.Pfx));
File.WriteAllBytes("client.p12", exportCert.Export(X509ContentType.Pkcs12));

При желании можно использовать перегрузки метода Export с паролем.

Для работы с коллекциями сертификатов в .net core удобно использовать специализированное хранилище, реализуемого классом X509Store. Хранилище в простом случае инициализируется строковым именем:

X509Store store = new X509Store("test");
store.Open(OpenFlags.ReadWrite);
store.Add(cert);

В результате для OC Linux будет создана папка »~/.dotnet/corefx/cryptography/x509stores/ test/» и файл »827…E3E.pfx», соответсвующий цифровому отпечатку (поле Thumbprint). Попытка что-то сделать с этим сертификатом с помощью openssl не увенчается успехом, так как он ожидаемо запаролен. Однако если положить в папку свой сертификат *.pfx без пароля, то он успешно подхватится хранилищем. К слову X509Store реализует методы поиска сертификатов по различным параметрам, включая IssueName и другие.


Проверка

Теперь о проверке подлинности сертификатов. Обмен конечными сертификатами осуществляется при установлении защищенного соединения, например при использовании протокола https. Основная проверка заключается в сличении цифровой подписи, добавленной в сертификат. В качестве ключа используется открытый ключ выдавшего сертификат центра. Проверку подлинности сертификата называют »построением цепочки», поскольку чаще всего конечный сертификат подписывается промежуточным, тот в свою очередь — предыдущим, и так до корневого.
Для простоты настроим nginx на использование созданных ранее сертификатов:

server {
        listen 443;
        ssl on;
        ssl_certificate /etc/nginx/ssl/public.crt;
        ssl_certificate_key /etc/nginx/ssl/private.key;
        ssl_client_certificate /etc/nginx/ssl/caCert.crt;
        ssl_verify_client on;
}

Настроим HttpClient:

SocketsHttpHandler handler = new SocketsHttpHandler();
handler.SslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
 {
    X509Chain chain = new X509Chain(false);
    chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
    chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
    chain.ChainPolicy.ExtraStore.Add(caCert);
    var serverСert = new X509Certificate2(certificate);
    return chain.Build(serverСert);
};
handler.SslOptions.ClientCertificates = new X509CertificateCollection() { clientCert };
HttpClient client = new HttpClient(handler);
var resp = await client.GetAsync…

Настройка построения цепочки заключается в отключении поиска сертификата в списке »отозванных» и допустимости »недоверенных» сертификатов.


Итог

В реализациях .net core для ОС Linux криптографические функции выполняются через взаимодействие с библиотеками OpenSSL. Поэтому принципиальной разницы между создаваемыми сертификатами нет. Есть библиотеки, такие как BouncyCastle, которые умеют гораздо больше и, возможно, удобнее, но лично мне не хочется использовать дополнительные зависимости ради 10 процентов функционала. Надеюсь, статья хоть немного упростит поиски информации для тех, кому предстоит разбираться в сложностях работы с X.509.

© Habrahabr.ru