В последнее время использование протокола HTTPS для Web-ресурсов является обязательным требованиям ко всем более-менее большим Web-проектам. Эта технология основана на использовании так называемых сертификатов. Раньше за получение своего сертификата нужно было платить. Но сегодня появление таких сервисов, как Let's Encrypt сделало возможным получение сертификатов бесплатно. Таким образом, цена больше не служит оправданием отказа от использования HTTPS.

В самом простом случае сертификат позволяет установить защищённое соединение между клиентом и сервером. Но это далеко не всё, на что они способны. В частности, недавно я смотрел на Pluralsight курс Microservices Security. И там среди прочих упоминалась такая вещь, как Mutual Transport Layer Security. Она позволяет не только клиенту убедиться в том, что он общается именно с тем сервером, с которым хочет, но и сервер может узнать, что за клиент с ним общается.

Всё это накладывает на разработчиков необходимость знать способы работы с сертификатами. Именно поэтому я и решил написать эту статью. Я задумал её как место, куда можно обратиться за основными сведениями, если что-то забудешь. Не думаю, что специалисты найдут в ней что-то новое, но надеюсь, что она будет полезна новичкам и тем, кто захочет освежить свои знания в этом вопросе.

Статья будет содержать следующие разделы:

  • Что такое сертификаты и зачем они нужны?

  • Как создать самоподписанный сертификат для тестирования на вашей машине?

  • Как использовать сертификаты в ASP.NET Core на стороне сервера и на стороне клиента?

Зачем нам нужны сертификаты

Прежде чем приступать к работе с сертификатами, необходимо понять, зачем они нам нужны. Давайте рассмотрим пару человек. Традиционно их называют Алисой и Бобом. Им необходимо общаться между собой, и единственным способом, которым они могут это сделать, является обмен сообщениями через открытый канал передачи данных:

Алиса и Боб
Алиса и Боб

Все используемые иконки созданы Vitaly Gorbachev на сайте Flaticon

К сожалению, поскольку канал является открытым, любой при желании может прослушивать и даже изменять сообщения, которыми обмениваются Алиса и Боб:

Человек посередине
Человек посередине

Эта ситуация называется атакой "Человек посередине" (Man in the Middle).

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

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

Симметричное шифрование
Симметричное шифрование

Но вернёмся к нашим Алисе и Бобу. Кажется, что проблема решена. Но не тут-то было. Загвоздка в том, как обоим нашим участникам получить одинаковые ключи шифрования так, чтобы этот ключ не узнал никто другой. Ведь общаться они могут только по открытому каналу. Передача по этому каналу симметричного ключа приведёт к тому, что его узнает и хакер. Тогда он сможет расшифровывать и изменять сообщения Алисы и Боба.

Что же делать? На помощь приходит асимметричное шифрование или шифрование с открытым ключом. Суть его состоит в следующем. Пусть Алиса хочет передать сообщение Бобу. Боб теперь генерирует не один, а два ключа - открытый и закрытый. Открытый ключ не представляет секрета. Его Боб свободно раздаёт всем желающим общаться с ним. А вот закрытый ключ Боб хранит в тайне и не показывает никому, даже Алисе. Хитрость заключается в том, что если зашифровать сообщение с помощью открытого ключа, то расшифровать его можно только с помощью закрытого ключа. И наоборот, сообщение, зашифрованное закрытым ключом, расшифровывается открытым ключом.

Теперь ясно, как Алиса и Боб должны действовать. Каждый из них генерирует свои открытый и закрытый ключи. Затем они обмениваются открытыми ключами через их канал связи. Поскольку открытые ключи не представляют собой секрета, их можно передавать через открытые каналы. Закрытые ключи они хранят у себя в тайне. Пусть теперь Боб хочет послать сообщение Алисе. Он шифрует его открытым ключом Алисы и посылает сообщение по каналу. Расшифровать сообщение может только обладатель закрытого ключа, т. е. только Алиса. Хакер этого сделать не может.

Шифрование с открытым ключом
Шифрование с открытым ключом

На самом деле всё чуть сложнее. Дело в том, что шифрование с открытым ключом работает намного медленнее симметричного шифрования. Шифровать таким способом большие объёмы данных представляется неудобным. Поэтому, когда Боб хочет общаться с Алисой, он поступает следующим образом. Он генерирует новый ключ для системы симметричного шифрования (его обычно называют сеансовым ключом). Потом он шифрует этот сеансовый ключ открытым ключом Алисы и посылает его ей. Теперь у Алисы и Боба есть ключ симметричного шифрования, который неизвестен больше никому. С этого момента они свободно могут пользоваться быстрыми алгоритмами симметричного шифрования.

Казалось бы, проблема решена. Но всё не так просто. Хакеру, контролирующему канал связи, есть что нам сказать. Проблема снова кроется в механизме распространения ключей, но теперь уже открытых ключей. Давайте посмотрим, что может произойти.

Пусть Алиса сгенерировала пару из открытого и закрытого ключа. Теперь она хочет передать свой открытый ключ Бобу. Она посылает этот ключ по каналу передачи данных. В этот момент хакер перехватывает этот ключ, не давая ему достичь Боба. Вместо этого хакер генерирует свою пару из открытого и закрытого ключа. Затем он посылает Бобу свой открытый ключ, говоря ему, что это открытый ключ Алисы. Настоящий же открытый ключ Алисы хакер так же сохраняет у себя:

Атака на распространение открытых ключей
Атака на распространение открытых ключей

Да, теперь у нас фигурирует масса различных ключей. Давайте разберёмся, как всё это работает. Пусть Боб хочет послать сообщение Алисе. Он шифрует его открытым ключом, который, как он думает, принадлежит Алисе. Но на самом деле ключ этот принадлежит хакеру. Хакер перехватывает это сообщение, не давая ему достигнуть Алисы. Поскольку сообщение зашифровано открытым ключом хакера, то он может расшифровать его своим закрытым ключом, прочитать и изменить так, как сочтёт нужным. После этого он зашифровывает сообщение настоящим открытым ключом Алисы (помните, что он сохранил этот ключ у себя) и отправляет его ей. Алиса без проблем расшифровывает его своим закрытым ключом. Таким образом, Алиса получает сообщение от Боба и даже не догадывается, что оно было прочитано и, возможно, изменено хакером.

Что же можно сделать, чтобы избежать подобного развития ситуации? И здесь мы подбираемся вплотную к сертификатам. Представьте себе, что Алиса распространяет по открытому каналу не просто свой открытый ключ, а ключ с прикреплённой к нему биркой, на которой написано, что этот ключ принадлежит Алисе. На бирке так же содержится подпись некоего уважаемого лица, которому доверяют как Алиса, так и Боб:

Подписанный открытый ключ
Подписанный открытый ключ

Считается, что ключ и бирка на нём составляют единое целое. Бирку нельзя отсоединить от одного ключа и присоединить к другому. Тогда, если хакер не может подделать подпись, то он не может подделать и ключ. Боб, получив ключ с биркой, на которой написано, что это ключ Алисы, и стоит подпись, которой он доверяет, может быть уверен, что это именно ключ Алисы, а не кого-то другого.

Можно считать, что ключ с такой биркой и представляют собой сертификат. Но как на самом деле он устроен в цифровом мире?

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

  • Она должна быть короткой. Представьте себе, что вы хотите создать цифровую подпись для файла с фильмом. Такой файл может занимать на диске десятки гигабайт. Если и наша подпись будет такого же размера, передавать её с файлом будет крайне накладно.

  • Её должно быть невозможно (или на практике очень-очень трудно) подделать. Иначе хакер всё же сможет подсунуть Бобу свой ключ вместо ключа Алисы.

Как же создать такую подпись? Можно поступить так. Сначала для нашей последовательности бит считается так называемый хеш. Вы передаёте вашу последовательность бит на вход некоторой функции (называемой функцией хеширования), и она возвращает вам другую последовательность бит, но уже очень короткую. Эта выходная последовательность и называется хешем. Все современные функции хеширования обладают следующими свойствами:

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

  • Зная только хеш, вы не можете сказать, из какой последовательности бит он был получен. Т. е. восстановление этой последовательности из хеша невозможно.

  • Если у вас есть значение хеша некоторой последовательности бит, то вам очень трудно указать другую последовательность бит, дающую такой же хеш. Действительно, различных файлов длиной в 1 ГБайт очень много. Но для каждого из них можно посчитать хеш длиной, скажем, всего в 32 байта. Различных последовательностей бит длиной в 32 байта намного меньше, чем различных файлов длиной в 1 ГБайт. Это значит, что обязательно будут существовать два различных файла длиной в 1 ГБайт, дающие один и тот же хеш. И тем не менее, зная один такой файл и его хеш, очень сложно узнать другой файл, дающий такой же хеш.

С хешами разобрались. Но, к сожалению, сам по себе хеш не подходит на роль подписи. Да, он короткий, но его может посчитать любой. Хакер может сам вычислить хеш для своего открытого ключа, ничто не препятствует ему сделать это. Как же нам сделать хеш устойчивым к подделке? Здесь на помощь нам снова приходит шифрование с открытым ключом.

Помните, я говорил, что и Алиса, и Боб должны доверять подписи, которая стоит на бирке ключа. Пусть Алиса и Боб доверяют подписи Очень Важного Человека. Как же Очень Важный Человек может подписать ключ? Он генерирует свою пару из открытого и закрытого ключа. Открытый ключ он передаёт Алисе и Бобу, а закрытый хранит у себя. Когда ему нужно подписать открытый ключ Алисы, он поступает так. Сначала он считает хеш ключа Алисы, а затем шифрует этот хеш своим закрытым ключом. Именно хеш, зашифрованный закрытым ключом Очень Важного Человека (его обычно называют certificate authority) и является подписью. Поскольку никто не знает закрытого ключа Очень Важного Человека, то никто и не может подделать его подпись.

С созданием подписи мы разобрались. Осталось понять, как проверить её подлинность, как проверить то, что подпись не была подделана. Итак, Боб получил некоторый ключ, на бирке которого написано, что это открытый ключ Алисы. А также там присутствует подпись вроде бы Очень Важного Человека. Как это проверить? Во-первых, Боб вычисляет хеш полученного открытого ключа. Помните, что это может сделать любой. Затем Боб расшифровывает подпись с помощью открытого ключа Очень Важного Человека. Мы помним, что подпись представляет собой тот же зашифрованный хеш. После этого Боб сравнивает два хеша: посчитанный им самостоятельно и тот, который он получил при расшифровке подписи. Если они совпадают, то всё в порядке, можно верить тому, что это ключ Алисы. Если же хеши отличаются, то доверять такому ключу нельзя. Поскольку хакер не может создать правильную подпись он не может и подсунуть Бобу другой ключ.

Итак, как я уже сказал, сертификат, в сущности, представляет собой ключ и подпись к нему. На практике в него добавляется масса иной информации:

  • Кому принадлежит ключ. В нашем случае Алисе.

  • С какой и по какую дату этот ключ действителен.

  • Кто подписывал ключ. В нашем случае Очень Важный Человек. Это требуется, если подписывать ключи могут разные certificate authorities.

  • Какой алгоритм был использован для расчёта хеша и создания подписи.

  • ... и любая другая информация.

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

Однако в нашей строгой схеме всё ещё существует одна брешь. Надеюсь, вы уже поняли к чему я веду. А каким образом Алиса и Боб получают открытый ключ Очень Важного Человека? Ведь если хакер сможет подсунуть им вместо настоящего ключа свой ключ, то всё пропало.

Ну конечно же открытый ключ Очень Важного Человека распространяется так же с помощью сертификата, но теперь уже подписанного Очень-Очень Важным Человеком. Хм... А как же распространяется открытый ключ Очень-Очень Важного Человека? Ну конечно же тоже сертификатом. Ну вы поняли... там сертификаты до самого дна.

Но шутки в сторону. Действительно, сертификат Алисы может быть подписан сертификатом Очень Важного Человека, а тот - сертификатом Очень-Очень Важного Человека. Это называется цепочкой доверия. Но эта цепочка не бесконечна. Обычно она заканчивается корневым сертификатом. Этот сертификат никем не подписан, а точнее, он подписан сам собой (self-signed certificate). Обычно корневые сертификаты принадлежат очень надёжным компаниям, которые, собственно, и занимаются тем, что подписывают другие сертификаты с помощью своих корневых сертификатов.

Раньше эти компании брали деньги за подписывание сертификатов. Теперь появились сервисы типа Let's Encrypt, которые делают это бесплатно. Я думаю, что многие большие компании осознали, что лучше предоставлять сертификаты бесплатно и тем самым сделать Интернет более защищённым пространством, нежели иметь массу слабо защищённых сайтов, которые могут быть взломаны и использованы как площадки для атаки на эти же большие компании. Примерно то же самое произошло и с антивирусами. Лет двадцать назад это были платные продукты. Теперь же обычный пользователь без проблем может найти бесплатный качественный антивирус для установки на свой частный компьютер.

Но вернёмся к сертификатам. Осталось рассмотреть последний вопрос. Почему же мы доверяем корневым сертификатам? Что мешает хакеру подменить их? А всё дело в способе их доставки на компьютеры Боба и Алисы. Дело в том, что основные корневые сертификаты не распространяются по открытому каналу, а устанавливаются вместе с операционной системой. Недавно некоторые браузеры так же стали устанавливаться со своим набором доверенных сертификатов.

Вот и всё, что я сегодня хотел сказать о сертификатах. С ними связана ещё масса интересных вещей, например, механизмы устаревания и отзыва сертификатов, но мы не будем касаться этого здесь. Пора нам перейти к практическим вещам.

Создание сертификатов

Надеюсь, я сумел убедить вас, что сертификаты - важная и необходимая вещь. И вы, как разработчик, решили, что пришло время научиться пользоваться ими. На самом деле при создании проекта ASP.NET Core из Visual Studio вы можете просто установить галочку Configure for HTTPS и вся необходимая инфраструктура будет создана для вас:

Сконфигурировать для HTTPS
Сконфигурировать для HTTPS

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

Давайте начнём. Всё, что нам потребуется, уже есть в .NET Core. Создадим консольное приложение и используем несколько полезных пространств имён:

using System.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;

Теперь нам нужно создать пару из открытого и закрытого ключа. Собственно безопасным распространением открытого ключа и занимается сертификат:

// Generate private-public key pair
var rsaKey = RSA.Create(2048);

Далее нам необходимо создать запрос на сертификат:

// Describe certificate
string subject = "CN=localhost";

// Create certificate request
var certificateRequest = new CertificateRequest(
    subject,
    rsaKey,
    HashAlgorithmName.SHA256,
    RSASignaturePadding.Pkcs1
);

Запрос на сертификат содержит информацию о том, для кого выписывается данный сертификат (переменная subject). Если мы хотим, чтобы сертификат использовался Web-сервером, который доступен нам через www.example.com, то содержимое переменной subject обязано быть равным CN=www.example.com. В данном случае мы хотим тестировать наш Web-сервер через localhost. Поэтому и значение переменной subject выбрано равным CN=localhost.

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

Теперь нужно добавить некоторую дополнительную информацию о том, какой именно сертификат нам нужен. Укажем, что нам не требуется подписывать другие сертификаты с его помощью:

certificateRequest.CertificateExtensions.Add(
    new X509BasicConstraintsExtension(
        certificateAuthority: false,
        hasPathLengthConstraint: false,
        pathLengthConstraint: 0,
        critical: true
    )
);

Далее идёт нечто интересное. Дело в том, что сам по себе сертификат - просто хранилище для ключей шифрования. Эти ключи могут использоваться для разных целей. Мы уже встречались с их использованием для цифровой подписи и шифрования сеансового ключа. Но есть и другие применения. Теперь мы должны указать, для каких целей может быть использован наш сертификат:

certificateRequest.CertificateExtensions.Add(
    new X509KeyUsageExtension(
        keyUsages:
            X509KeyUsageFlags.DigitalSignature
            | X509KeyUsageFlags.KeyEncipherment,
        critical: false
    )
);

Вы можете самостоятельно познакомиться с перечислением X509KeyUsageFlags, где указаны различные области применения сертификатов.

Далее указывается публичный ключ для идентификации:

certificateRequest.CertificateExtensions.Add(
    new X509SubjectKeyIdentifierExtension(
        key: certificateRequest.PublicKey,
        critical: false
    )
);

А теперь немножко чёрной магии. Я уже говорил вам, что чтобы сертификат мог использоваться для защиты сайта www.example.com, его поле subject должно содержать CN=www.example.com. Но для браузеров Chrome этого недостаточно. Кроме этого, поле Subject Alternative Name должно содержать DNS Name=www.example.com. Или в нашем случае оно должно содержать DNS Name=localhost. Если этого не сделать, Chrome не будет доверять такому сертификату. К сожалению, я не нашёл удобоваримого способа выставить поле Subject Alternative Name для нашего сертификата. Но вот этот кусочек кода устанавливает его в DNS Name=localhost:

certificateRequest.CertificateExtensions.Add(
    new X509Extension(
        new AsnEncodedData(
            "Subject Alternative Name",
            new byte[] { 48, 11, 130, 9, 108, 111, 99, 97, 108, 104, 111, 115, 116 }
        ),
        false
    )
);

Всё, наш запрос на создание сертификата полностью готов. Теперь мы можем создавать сам сертификат:

var expireAt = DateTimeOffset.Now.AddYears(5);

var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.Now, expireAt);

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

Вот и всё, у нас есть сертификат. Но пока он существует лишь в оперативной памяти компьютера. Чтобы установить его в нашу систему, нужно сперва записать его на диск в виде файла формата PFX. Но тут есть одна тонкость. Файл, который мы хотим получить, должен содержать как открытый, так и закрытый ключи, чтобы использующий его сервер мог проводить любые операции шифрования и расшифровки. Но по соображениям безопасности созданный нами сертификат не предназначен для экспорта закрытого ключа. Получить готовый для экспорта сертификат можно так:

// Export certificate with private key
var exportableCertificate = new X509Certificate2(
    certificate.Export(X509ContentType.Cert),
    (string)null,
    X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet
).CopyWithPrivateKey(rsaKey);

Для удобства ему можно добавить описание:

exportableCertificate.FriendlyName = "Ivan Yakimov Test-only Certificate For Client Authorization";

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

// Create password for certificate protection
var passwordForCertificateProtection = new SecureString();
foreach (var @char in "p@ssw0rd")
{
    passwordForCertificateProtection.AppendChar(@char);
}

// Export certificate to a file.
File.WriteAllBytes(
    "certificateForServerAuthorization.pfx",
    exportableCertificate.Export(
        X509ContentType.Pfx,
        passwordForCertificateProtection
    )
);

Теперь у нас есть файл сертификата, который можно использовать для защиты Web-сервера. Но вы так же можете создать файл сертификата, который будет использоваться для аутентификации клиентов этого сервера. Его создание практически ничем не отличается от серверного сертификата, только в поле subject можно писать что угодно, и поле Subject Alternative Name не требуется:

// Generate private-public key pair
var rsaKey = RSA.Create(2048);

// Describe certificate
string subject = "CN=Ivan Yakimov";

// Create certificate request
var certificateRequest = new CertificateRequest(
    subject,
    rsaKey,
    HashAlgorithmName.SHA256,
    RSASignaturePadding.Pkcs1
);

certificateRequest.CertificateExtensions.Add(
    new X509BasicConstraintsExtension(
        certificateAuthority: false,
        hasPathLengthConstraint: false,
        pathLengthConstraint: 0,
        critical: true
    )
);

certificateRequest.CertificateExtensions.Add(
    new X509KeyUsageExtension(
        keyUsages:
            X509KeyUsageFlags.DigitalSignature
            | X509KeyUsageFlags.KeyEncipherment,
        critical: false
    )
);

certificateRequest.CertificateExtensions.Add(
    new X509SubjectKeyIdentifierExtension(
        key: certificateRequest.PublicKey,
        critical: false
    )
);

var expireAt = DateTimeOffset.Now.AddYears(5);

var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.Now, expireAt);

// Export certificate with private key
var exportableCertificate = new X509Certificate2(
    certificate.Export(X509ContentType.Cert),
    (string)null,
    X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet
).CopyWithPrivateKey(rsaKey);

exportableCertificate.FriendlyName = "Ivan Yakimov Test-only Certificate For Client Authorization";

// Create password for certificate protection
var passwordForCertificateProtection = new SecureString();
foreach (var @char in "p@ssw0rd")
{
    passwordForCertificateProtection.AppendChar(@char);
}

// Export certificate to a file.
File.WriteAllBytes(
    "certificateForClientAuthorization.pfx",
    exportableCertificate.Export(
        X509ContentType.Pfx,
        passwordForCertificateProtection
    )
);

Теперь можно устанавливать созданный нами сертификат в систему. Для этого в Windows выполните двойной щелчок мышью на PFX-файле сертификата. Откроется окно помощника импорта. Укажите, что сертификат устанавливается только для текущего пользователя, а не для всей машины:

Установить сертификат для текущего пользователя
Установить сертификат для текущего пользователя

На следующем экране вам предлагается указать файл сертификата, который вы будете импортировать. Оставьте его без изменений:

Выбор файла сертификата
Выбор файла сертификата

На следующем экране введите пароль, который вы использовали для защиты файла сертификата:

Ввод пароля
Ввод пароля

Далее укажите, что хотите установить ваш сертификат в Trusted Root Certification Authorities:

Выбор хранилища
Выбор хранилища

Помните, мы обсуждали, где заканчиваются цепочки доверия сертификатов. Так вот, хранилище Trusted Root Certification Authorities и хранит такие конечные (корневые) сертификаты, которым система доверяет без дальнейших проверок.

На этом настройка импорта сертификата закончена. Далее можно нажимать только Next, Finish и Ok.

Теперь ваш сертификат присутствует в хранилище сертификатов Trusted Root Certification Authorities. Вы можете открыть его в оснастке Manage User Certificates из панели управления:

Управление сертификатами пользователя
Управление сертификатами пользователя

Вот как выглядит там наш сертификат:

Наш сертификат
Наш сертификат

Сертификат для аутентификации клиента устанавливается аналогичным образом.

Прежде чем переходить к тому, как использовать эти сертификаты в .NET коде, я хочу показать вам ещё один приём, позволяющий генерировать самоподписанные сертификаты. Если вы не хотите писать код создания сертификатов сами, но у вас есть PowerShell, вы можете сгенерировать сертификаты с его помощью.

Вот код, генерирующий сертификат для защиты сервера:

$certificate = New-SelfSignedCertificate `
    -Subject localhost `
    -DnsName localhost `
    -KeyAlgorithm RSA `
    -KeyLength 2048 `
    -NotBefore (Get-Date) `
    -NotAfter (Get-Date).AddYears(5) `
    -FriendlyName "Ivan Yakimov Test-only Certificate For Server Authorization" `
    -HashAlgorithm SHA256 `
    -KeyUsage DigitalSignature, KeyEncipherment, DataEncipherment `
    -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.1")

$pfxPassword = ConvertTo-SecureString `
    -String "p@ssw0rd" `
    -Force `
    -AsPlainText

Export-PfxCertificate `
    -Cert $certificate `
    -FilePath "certificateForServerAuthorization.pfx" `
    -Password $pfxPassword

Команды New-SelfSignedCertificate и Export-PfxCertificate расположены в модуле pki. Я надеюсь, что параметры, передаваемые этим командам, уже понятны вам.

А вот код, создающий сертификат для аутентификации клиента:

$certificate = New-SelfSignedCertificate `
      -Type Custom `
      -Subject "Ivan Yakimov" `
      -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.2") `
      -FriendlyName "Ivan Yakimov Test-only Certificate For Client Authorization" `
      -KeyUsage DigitalSignature `
      -KeyAlgorithm RSA `
      -KeyLength 2048

$pfxPassword = ConvertTo-SecureString `
    -String "p@ssw0rd" `
    -Force `
    -AsPlainText

Export-PfxCertificate `
    -Cert $certificate `
    -FilePath "certificateForClientAuthorization.pfx" `
    -Password $pfxPassword

Теперь рассмотрим вопрос использования созданных сертификатов.

Как использовать сертификаты в .NET коде

Итак, у нас есть Web-сервер, написанный на ASP.NET Core. И мы хотим защитить его созданным нами сертификатом. Сперва этот сертификат нужно получить в коде нашего сервера. Здесь есть два варианта.

Первый вариант - получение сертификата из PFX-файла. Этот вариант применяется, если у вас есть файл сертификата, который вы устанавливали в хранилище доверенных сертификатов. Тогда получить сертификат можно так:

var certificate = new X509Certificate2(
    "certificateForServerAuthorization.pfx",
    "p@ssw0rd"
);

Здесь certificateForServerAuthorization.pfx - имя файла сертификата, а p@ssw0rd - пароль, который вы использовали для его защиты.

Но не всегда файл сертификата доступен вам. В таком случае сертификат можно взять напрямую из хранилища:

var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly);
var certificate = store.Certificates.OfType()
    .First(c => c.FriendlyName == "Ivan Yakimov Test-only Certificate For Server Authorization");

Значение StoreLocation.CurrentUser говорит, что мы хотим работать с хранилищем сертификатов текущего пользователя, а не всего компьютера. Значение StoreName.Root говорит, что сам сертификат нужно искать в хранилище Trusted Root Certification Authorities. Здесь для простоты выбор сертификата я осуществляю по имени, но вы можете использовать любой удобный вам критерий.

Теперь у нас есть сертификат. Давайте заставим наш сервер использовать его. Для этого нам потребуется немного изменить код в файле Program.cs:

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args)
    {
        var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
        store.Open(OpenFlags.ReadOnly);
        var certificate = store.Certificates.OfType()
            .First(c => c.FriendlyName == "Ivan Yakimov Test-only Certificate For Server Authorization");

        return Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder
                    .UseKestrel(options =>
                    {
                        options.Listen(System.Net.IPAddress.Loopback, 44321, listenOptions =>
                        {
                            var connectionOptions = new HttpsConnectionAdapterOptions();
                            connectionOptions.ServerCertificate = certificate;

                            listenOptions.UseHttps(connectionOptions);
                        });
                    })
                    .UseStartup();
            });
    }
}

Как видите, вся магия происходит внутри метода UseKestrel. Здесь мы явно указываем, какой порт мы хотим использовать, и какой серверный сертификат мы применяем.

Теперь браузер воспринимает наш сайт, как защищённый:

Защищённый сайт
Защищённый сайт

Но не всегда мы работаем с Web-сервером через браузер. Иногда нам нужно обратиться к нему из кода. Тогда на помощь приходит HttpClient:

var client = new HttpClient()
{
    BaseAddress = new Uri("https://localhost:44321")
};

var result = await client.GetAsync("data");

var content = await result.Content.ReadAsStringAsync();

Console.WriteLine(content);

На самом деле стандартный HttpClient осуществляет проверку серверного сертификата и не будет выполнять соединение, если не сможет удостовериться в его подлинности. Но что, если мы хотим выполнить дополнительную проверку? Например, мы хотим проверить, кто именно подписал сертификат сервера. Или проверить какое-нибудь нестандартное поле этого сертификата. Сделать это возможно. Для этого нам необходимо определить метод, который будет выполняться после того, как система выполнит стандартную проверку сертификата:

var handler = new HttpClientHandler()
{
    ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) =>{
        if (errors != SslPolicyErrors.None) return false;

        return true;
    }
};

var client = new HttpClient(handler)
{
    BaseAddress = new Uri("https://localhost:44321")
};

Этот метод присваивается свойству ServerCertificateCustomValidationCallback объекта класса HttpClientHandler. Сам же этот объект передаётся в конструктор HttpClient.

Давайте рассмотрим наш метод проверки подробнее. Как я уже сказал, он выполняется не вместо, а после стандартной проверки. Результаты этой проверки можно получить через последний параметр данного метода (errors). Если это значение не равно SslPolicyErrors.None, то стандартная проверка не удалась и доверять такому сертификату нельзя. Кроме того, в данный метод передаётся информация о:

  • Самом запросе (request).

  • Серверном сертификате (certificate).

  • Цепочке доверия этого сертификата (chain). Здесь вы сможете найти более детально описанную причину того, почему не удалась стандартна проверка сертификата, если это вам интересно.

Итак, мы познакомились с тем, как можно защитить сертификатом ваш сервер. Но сертификат можно использовать и для аутентификации клиента. В этом случае сервер будет обслуживать запросы только тех клиентов, которые предоставят ему "правильный" сертификат. Сертификат является правильным, если он прошёл проверку подлинности и, кроме того, удовлетворяет дополнительным условиям, которые накладывает на него сервер.

Давайте посмотрим, как заставить сервер требовать от клиента сертификат. Для этого придётся лишь немного изменить его код:

return Host.CreateDefaultBuilder(args)
    .UseSerilog()
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder
            .UseKestrel(options =>
            {
                options.Listen(System.Net.IPAddress.Loopback, 44321, listenOptions =>
                {
                    var connectionOptions = new HttpsConnectionAdapterOptions();
                    connectionOptions.ServerCertificate = certificate;

                    connectionOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
                    connectionOptions.ClientCertificateValidation = (certificate, chain, errors) =>
                    {
                        if (errors != SslPolicyErrors.None) return false;

                        // Here is your code...

                        return true;
                    };

                    listenOptions.UseHttps(connectionOptions);
                });
            })
            .UseStartup();
    });

Как видите, мы дополнительно установили всего лишь два свойства объекта HttpsConnectionAdapterOptions. Через свойство ClientCertificateMode мы указали, что клиентский сертификат требуется обязательно, а через свойство ClientCertificateValidation задали нашу функцию для дополнительной проверки сертификата.

Если открыть такой сайт в браузере, он потребует от вас указать, какой клиентский сертификат ему использовать:

Задание клиентского сертификата в браузере
Задание клиентского сертификата в браузере

Осталось только узнать, как нам задать клиентский сертификат для HttpClient. Получение сертификата для клиента ничем не отличается от того, что мы использовали для сервера. Остальные же изменения в коде клиента минимальны:

var handler = new HttpClientHandler()
{
    ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) => {
        if (errors != SslPolicyErrors.None) return false;

        // Here is your code...

        return true;
    }
};

handler.ClientCertificates.Add(certificate);

var client = new HttpClient(handler)
{
    BaseAddress = new Uri("https://localhost:44321")
};

Мы просто добавили его в коллекцию ClientCertificates объекта HttpClientHandler.

Заключение

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

Приложение

В своей работе я использовал следующие материалы:

Исходный код для статьи можно найти на GitHub.

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


  1. Ordos
    05.10.2021 18:55
    +1

    Спасибо, что не поленились собрать весь этот материал в одной статье! Это действительно многим будет полезно.

    Поскольку вы упоминаете ASP.NET, то было бы неплохо для HttpClient также привести пример конфигурирования через DI, т.к. ручное создание HttpClient и HttpClientHandler там не самая лучшая практика.


    1. iakimov Автор
      07.10.2021 11:49

      Благодарю за ваш отзыв.

      Мы редко пользуемся HttpClient напрямую, обычно используем Refit. Но есть статья от Microsoft на интересующую вас тему.


  1. Politura
    05.10.2021 20:52

    Всё это накладывает на разработчиков необходимость знать способы работы с сертификатами.

    Знать то, конечно, желательно, но на практике до них далеко не всегда дело доходит.

    Например, когда наружу идет какой-нибудь nginx и все сертификаты на нем, а от него до API слоя незащищенный http.

    Да и на счет микросервисов, на мой взгляд куда как удобнее, когда вся работа с ними идет через AMQP, а не HTTP/HTTPS, так и горизонтально масштабировать сильно проще: если надо, то запустил новый инстанс сервиса и все.


    1. iakimov Автор
      07.10.2021 11:36

      Вы правы, так и делают в рабочих системах. Но подобный подход не очень хорош для обучения. Он, фактически, говорит разработчикам: "Знаете, вам не нужно беспокоиться обо всех этих сертификатах. Хостинг-провайдеры сделают всё это за вас". Цель у статьи была несколько иная - дать базовую информацию, полезную для новичков. Как я и говорил, профессионалы вряд ли найдут в ней что-то полезное.


  1. ZOXEXIVO
    05.10.2021 21:27
    +2

    Последнее что нужно делать - делегировать SSL/TLS терминирование managed-коду, где будут тонны аллокаций памяти и сопуствующие этому проблемы.

    Автор, вы случайно не хостите ASP.NET Core в IIS?


    1. winsky
      07.10.2021 11:29

      что за проблемы у кора с иис-ом?
      правда интересно, если там какие-то грабли - можно детали плз


    1. iakimov Автор
      07.10.2021 11:33

      Да, на production мы тоже используем поддержку сертификатов от провайдера, насколько мне известно. Но на компьютерах разработчиков хостим ASP.NET Core через IIS.


  1. superkeka
    07.10.2021 11:29
    -1

    А поднять nginx в режиме реверс прокси - не судьба?


    1. iakimov Автор
      07.10.2021 11:31

      Вероятно, в реальных ситуациях так и делают. Но я не специалист по nginx, так что описывал те технологии, с которыми знаком.


  1. TheAndrey
    14.10.2021 05:44

    @iakimov

    Очень хорошие примеры и целостное изложение основ, спасибо!

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

    1. Процесс проверки сертификата клиента сервером.

    Давайте посмотрим, как заставить сервер требовать от клиента сертификат. Для этого придётся лишь немного изменить его код:

    В примере, который следует за этим текстом, фигурирует certificate, как я понял это серверный для https со стороны сервера и к валидации клиента отношения не имеет. То есть его можно даже откинуть. Главное тут это присвоение:

    connectionOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate;

    ? Но как мы проверяем что клиентский сертификат верный если в коде сервера он не фигурирует? Важно ли устанавливать этот клиентский сертификат на стороне сервера?

    2. Процесс проверки сертификата сервера клиентом.

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

    3. Можно ли описать специфику установки сертификатов если клиент и сервер разнесены по разным машинам? Есть ли тут особенности установки сертификатов в случае? Казалось что на клиент пойдет другого формата сертификат (.pfx, .cer, точно не помню что и как).

    4. Как я понял единственная польза создавать самоподписные сертификаты для локальной проверки работы приложения с HTTPS перед тем как отдавать его дальше на деплой (запустилось/упало при любом https запросе). Или продемонстрировать работу HTTPS другим членам команды, для этого наверно и стоит разнести клиент и сервер при установке сертификата. Или есть другие полезные сценарии?

    5. Есть примеры использования клиентских сертификатов? Привык только к проверкам серверных браузером, другое в голову не приходит.

    К статье больше вопросов нет.

    А вообще было бы не плохо и вторую часть написать, так как тут еще есть некоторые пробелы для полного раскрытия темы "Использование сертификатов в ASP.NET Core". Даже после прочтения, я все еще не уверенный пользователь сертификатов. Если у кого хватит сил, то могу набросить пунктов на будущее:

    • Можно ли устанавливать сертификаты на веб сервер типа IIS или nginx, в чем плюсы и минусы такого подхода? Можно ли забить на проверки сертификатов из кода ASP.NET Core или тут есть некий симбиоз между подходами? не будет ли ситуаций когда проверяются разные сертификаты из кода и на уровне веб сервера или что происходит повторная проверка сертификата? Как договариваться Dev и DevOps/админам в этих подходах?

    • Типы сертификатов. Или из кода интересуют только .pfx?

    • Установка сертификатов на разных OS. Зависит ли код от платформы?

    • Сценарии генерации прод сертификатов. Особенности для клиентских сертификатов и их распространение.

    • Если у нашего сервиса много инстансов на разных машинах, то устанавливаем на всех копию одного сертификата?


    1. iakimov Автор
      14.10.2021 14:53

      Спасибо за отзыв!

      Вот что я могу сказать по поводу ваших вопросов.

      Вопрос 1. Вы правы, на стороне сервера важной является строчка

      connectionOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate;\

      Сам код сервера ничего о клиентском сертификате ничего не знает. Чтобы проверка клиентского сертификата на стороне сервера прошла, необходимо сделать следующее. Либо вы добавляете сам клиентский сертификат в хранилище доверенных сертификатов (Trusted Root Certification Authorities) на сервере. Либо, если ваш клиентский сертификат не является самоподписанным, а подписан другим сертификатом, то в это хранилище на сервере можно поместить тот сертификат, которым вы подписывали клиентский сертификат (или вообще любой сертификат вверх по цепочке подписывания). В этом случае сам клиентский сертификат на сервере не нужен.

      Вопрос 2. Собственно, ответ такой же. На стороне клиента должен присутствовать либо сам серверный сертификат, либо один из его цепочки подписывающих сертификатов.

      Вопрос 3. Действительно, некоторая специфика есть.

      1. Если вы устанавливаете серверный сертификат на клиента, то вам не нужно иметь в нём закрытый ключ. Поэтому можно выбрать любой формат, который содержит только публичный ключ. По крайней мере, я так полагаю. Но обычно распространяют не сами клиентские или серверные сертификаты, а подписывающие их сертификаты. Это намного удобнее.

      2. Кроме того, когда вы разнесли клиент и сервер по разным машинам, вы уже не можете обращаться с клиента на сервер по имени localhost. Поэтому в генерируемых для этой задачи сертификатах поля subject и DNS Name должны содежать правильные значения (т. е. сетевое имя машины-сервера).

      3. Ну и всё то, что написано выше про установку сертификатов в доверенное хранилище, тоже имеет место.

      Вопрос 4. Насколько мне известно, самоподписанные сертификаты действительно используются только для тестирования. Но в принципе это нормальные сертификаты. Если вы решите проблему их установки на нужные машины и своевременного обновления, то можно их использовать. Хотя зачем это нужно, если есть более удобные и надёжные средства, не понятно.

      Тут в голову пришло, что такая штука, как Fiddler, использует вроде бы самоподписанный сертификат для перехвата SSL-трафика (т. е. фактически для реализации атаки "человек посередине", описанной в статье). Но это, конечно, экзотический сценарий.

      Вопрос 5. Честно говоря, сам я не сталкивался с системами, использующими клиентские сертификаты. Только познакомился с этим в том курсе Pluralsight, о котором говорится в статье. Скорее всего, это какие-то приложения, обладающие повышенными требованиями к безопасности. Ну вот, например, такой вариант. Вы хотите, чтобы ваш клиент работал исключительно на подконтрольных вам устройствах. Вы боитесь, что если клиент станут использовать все, кто хочет, с любой машины, безопасность вашей системы пострадает. Поэтому вы ставите клиентские сертификаты на все компьютеры ваших сотрудников и заставляете систему проверять их. В результате клиент не сможет соединиться с сервером ни с одной машины, на которой нет правильного клиентского сертификата.

      Спасибо вам за предложения по расширению статьи. Могу сказать по ним следующее:

      Да, с сертификатами можно работать на уровне Web-сервера (IIS, nginx), а не приложения. Обычно так и делают в промышленных системах. В результате проверкой сертификата занимается Web-сервер, а не ваше приложения. Это позволяет сделать код вашего приложения проще, а обработку запросов - быстрее. Кроме того, ответственность за правильную проверку сертификатов переносится на разработчиков Web-сервера, которые, хочется надеяться, обладают соответствующей квалификацией.

      Возможным недостатком такого подхода может являться необходимость проверять в приложении некоторые поля сертификата, а затем использовать значения этих полей в коде приложения. Хотя, я уверен, вполне можно настроить Web-сервер так, чтобы он передавал сертификат вашему приложению, а не терминировал SSL на своём уровне. С другой стороны, если вашему приложению всё равно придётся проверять сертификат, зачем это делать на стороне Web-сервера...

      Если у вашего сервера много экземпляров, то да, на всех них нужно установить серверный сертификат. Я думаю, что он должен быть один и тот же для всех экземпляров. Не знаю, как поведут себя клиенты, если в ответ на разные запросы к одному и тому же серверу будут приходить разные, хотя и валидные, сертификаты. С другой стороны, происходит же что-то подобное в момент, когда мы заменяем сертификат сервера из-за окончания его срока службы.