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

К тому же, в собственной сети не так важен правильный сертификат, когда на всех клиентских устройствах можно предустановить свой доверенный корневой сертификат и подписывать им сертификаты внутренних ресурсов. Но у автора статьи просто был спортивный интерес и на досуге хотелось вникнуть в тему.

Я изучаю на досуге Java и мне потребовалось дома в локальной сети настроить тестовый полигон для mTLS аутентификации по сертификатам, это когда клиент аутентифицирует сервер, а сервер клиента.

Общение клиента с сервером mTLS

На данной схеме не рассмотрены все тонкости взаимных рукопожатий, но базовые моменты выглядят примерно так.

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

В браузере, когда у вас есть клиентские сертификаты и сервер их запрашивает это выглядит так:

Цепочки подписания сертификатов

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

цепочка подписания сертификатов, стрелочки и галочки это подпись
цепочка подписания сертификатов, стрелочки и галочки это подпись

HomeLabCA - это самый корневой сертификат, но конечные сертификаты (клиентские и серверные) им не подписываются, во-первых в данном эксперименте хотелось поиграться с цепочками сертификатов, во-вторых это достаточно распространенная практика - корневой центр сертификации обычно подписывает сертификаты других удостоверяющих центров, а они уже подписывают конечные сертификаты.

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

Допустим, что случилось непоправимое, один из промежуточных сертификатов HomeLabCA2 был украден, злоумышленниками был похищен приватный ключ и началось бесконтрольное подписание сертификатов для фишинговых сайтов, в таком случае корневой центр может отозвать этот промежуточный сертификат и все подписанные им сертификаты сразу потеряют доверие, но но сертификат srv1.loc при этом продолжит действовать, а если бы все сертификаты подписывались только корневым HomeLabCA, то масштаб компрометации был бы намного больше.

Для наглядного примера можно посмотреть в браузере путь сертификации у сайта habr.com

Генерация сертификатов

Предупреждение: Если такое будете повторять на проде - нужно внимательно подойти к тонким настройкам, моей целью было просто настроить несколько виртуальных машин на домашнем компе, сделать взаимодействие между сервисами с mTLS, в общем, всё только в учебных целях, организация тестового полигона для опытов, в проде, вероятно, лучше задействовать LetsEncrypt. К тому же нужно предусмотреть отзыв сертификатов.

Вернемся к первой картинке, будем выполнять всё по схеме

у нас два ЦС, один серверный и два клиентских сертификата
у нас два ЦС, один серверный и два клиентских сертификата

Структура каталогов нашего игрушечного центра сертификации:

├──./certs
│   ├── /ca_root - корневой, главный УЦ
│   │   ├── openssl.conf - конфиг корневого УЦ
│   │   ├── index.txt - база данных сертификатов
│   │   ├── serial - счетчик серийных номеров
│   │   ├── /pub - каталог с открытыми ключами
│   │   ├── /priv - каталог с закрытыми ключами
│   │   ├── /newcerts - каталог с подписанными сертификатами
│   │ 
│   ├── /ca1 - промежуточный УЦ
│   │   ├── openssl.conf - конфиг промежуточного УЦ
│   │   ├── index.txt - база данных сертификатов
│   │   ├── serial - счетчик серийных номеров
│   │   ├── /pub - каталог с открытыми ключами
│   │   ├── /priv - каталог с закрытыми ключами
│   │   ├── /newcerts - каталог с подписанными сертификатами
│   │   ├── /csr - каталог с запросами на подпись
│   │   ├── /psk12 - каталог с ключевыми парами

Для всех манипуляций нам потребуется только одна утилита openssl, можно воспользоваться git bash под Windows, либо WSL, либо на виртуалке с линуксом

Я буду использовать в WSL с Debian, поэтому, корневая папка будет /mnt/d/cert

mkdir /mnt/d/cert

Далее зайдем в созданную папку и создам всю структуру каталогов и файлов из схемы выше

cd /mnt/d/cert
mkdir -p {ca_root,ca1}/{pub,priv,newcerts}
echo 1000 > ca_root/serial
echo 1000 > ca1/serial
touch  {ca_root,ca1}/{index.txt,openssl.conf}
mkdir ca1/{csr,psk12}

Корневой центр сертификации

Откроем файл ca_root/openssl.conf и впишем в него следующее

ca_root/openssl.conf
[ ca ]
default_ca = CA_default

[ CA_default ]
dir               = /mnt/d/cert/ca_root #папка с нашим УЦ
certs             = $dir/priv
#crl_dir           = $dir/crl
new_certs_dir     = $dir/newcerts
database          = $dir/index.txt
serial            = $dir/serial
RANDFILE          = $dir/priv/.rand
#подписывающие серты
private_key       = $dir/priv/ca.key
certificate       = $dir/pub/ca.crt
default_md        = sha256
name_opt          = ca_default
cert_opt          = ca_default
default_days      = 375
preserve          = no
policy            = policy_strict

[ policy_strict ]
countryName             = match
stateOrProvinceName     = match
organizationName        = optional
organizationalUnitName  = optional
commonName              = optional
emailAddress            = optional


[ req ]
default_bits        = 2048
distinguished_name  = req_distinguished_name
string_mask         = utf8only
default_md          = sha256
x509_extensions     = v3_ca

[ req_distinguished_name ]
countryName                     = Country Name (2 letter code)
stateOrProvinceName             = State or Province Name
localityName                    = Locality Name
organizationName                = Organization Name
organizationalUnitName          = Organizational Unit Name
commonName                      = Common Name
emailAddress                    = Email Address

# дефолтные значения
countryName_default             = RU
stateOrProvinceName_default     = Russia
localityName_default            = Russia
organizationName_default       = MyHomeLab

[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true
keyUsage = critical, digitalSignature, cRLSign, keyCertSign

[ v3_intermediate_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true, pathlen:0
keyUsage = critical, digitalSignature, cRLSign, keyCertSign

Дальше перейдем в папку с корневым ЦС и сгенерируем приватный ключ, у нас спросит пароль, не забывайте его, он будет требоваться для дальнейших манипуляций

cd ./ca_root
openssl genrsa -aes256 -out priv/ca.key 4096

Создадим самоподписанный корневой сертификат

openssl req -config openssl.conf \
-key priv/ca.key \
-new -x509 -days 7300 -sha256 -extensions v3_ca \
-out pub/ca.crt

Основываясь на нашей договоренности из первой схемы у корневого сертификата будет CommonName=HomeLabCA

Корневой сертификат готов, теперь переходим к промежуточному ЦС - HomeLabCA1

Промежуточный ЦС

Подготовим конфигурационный файл в ca1 - ca1/openssl.conf

ca1/openssl.conf
[ ca ]
default_ca = CA_default

[ CA_default ]
dir               = /mnt/d/cert/ca1 # папка промежуточного цс
certs             = $dir/pub
new_certs_dir     = $dir/newcerts
database          = $dir/index.txt
serial            = $dir/serial
RANDFILE          = $dir/priv/.rand

private_key       = $dir/priv/ca1.key
certificate       = $dir/pub/ca1.crt

default_md        = sha256
name_opt          = ca_default
cert_opt          = ca_default
default_days      = 375
preserve          = no
policy            = policy_loose
unique_subject = no


[ policy_loose ]
countryName             = optional
stateOrProvinceName     = optional
localityName            = optional
organizationName        = optional
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional

[ req ]
default_bits        = 2048
distinguished_name  = req_distinguished_name
string_mask         = utf8only
default_md          = sha256
x509_extensions     = v3_ca

[ req_distinguished_name ]
countryName                     = Country Name (2 letter code)
stateOrProvinceName             = State or Province Name
localityName                    = Locality Name
organizationName                = Organization Name
organizationalUnitName          = Organizational Unit Name
commonName                      = Common Name
emailAddress                    = Email Address

# значения по-умолчанию
countryName_default             = RU
stateOrProvinceName_default     = Russia
localityName_default            = Russia
organizationName_default        = MyHomeLab1

[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true
keyUsage = critical, digitalSignature, cRLSign, keyCertSign

[ v3_intermediate_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true, pathlen:0
keyUsage = critical, digitalSignature, cRLSign, keyCertSign

[ usr_cert ]
basicConstraints = CA:FALSE
nsCertType = client, email
nsComment = "OpenSSL Generated Client Certificate"
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth, emailProtection

[ server_cert ]
basicConstraints = CA:FALSE
nsCertType = server
nsComment = "OpenSSL Generated Server Certificate"
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer:always
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName=${ENV::SAN}

И снова генерируем приватный ключ, только уже для промежуточного ЦС, запоминаем пароль для ключа

cd ../ca1
openssl genrsa -aes256 -out priv/ca1.key 4096

Дальше мы уже будем создавать подписанный сертификат, поэтому, создаем запрос на подписание сертификата, тут нам понадобится пароль от ключа priv/ca1.key

openssl req -config openssl.conf -new -sha256 \
-key priv/ca1.key \
-out csr/ca1.csr

Не забываем указать cname=HomeLabCA1 по изначальной схеме на картинке выше.

Далее подписываем промежуточный сертификат корневым

openssl ca -config ../ca_root/openssl.conf -extensions v3_intermediate_ca \
-days 3650 -notext -md sha256 \
-in csr/ca1.csr \
-out pub/ca1.crt

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

Генерация серверного сертификата

Итак, мы находимся в папке промежуточного центра сертификации, нам нужно выпустить сертификат для сервера с адресом srv1.loc и ip-адресом 192.168.200.10.

Основной момент при генерации сертификатов для сервера - это вписать SAN, это альтернативные имена для нашего сертификата, на практике выяснилось, что браузеры не особо смотрят на cname, а ориентируются на SAN. Если вы внимательно почитали конфиг то увидели в разделе [ server_cert ] следующую строку:

subjectAltName=${ENV::SAN}

Эта строчка подставляет из переменной окружения SAN значение в атрибут subjectAltName.

Первым делом, заходим в папку ca1 нашего промежуточного центра сертификации и генерируем приватный ключ, только тут упущена опция -aes256, поэтому ключ будет без пароля, это всё чтобы не вводить пароль при перезагрузке nginx

cd ../ca1
openssl genrsa -out priv/192.168.200.10.key 2048

Дальше всё по аналогии, создаем запрос на подпись, указываем cname=srv1.loc

openssl req -config openssl.conf \
-key priv/192.168.200.10.key \
-new -sha256 -out csr/192.168.200.10.csr

Теперь подписываем промежуточным сертификатом наш серверный srv1.loc, но предварительно заполним переменную окружения SAN

export SAN=DNS:site1.loc,IP:192.168.200.10

openssl ca -config openssl.conf \
-extensions server_cert -days 375 -notext -md sha256 \
-in csr/192.168.200.10.csr \
-out pub/192.168.200.10.crt
созданный сертификат в картинках
тип сертификата правильный, для проверки подлинности сервера
тип сертификата правильный, для проверки подлинности сервера
дополнительное имя субъекта это и есть указанный SAN
дополнительное имя субъекта это и есть указанный SAN

Цепочки сертификатов

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

Каждый сертификат начинается с -----BEGIN CERTIFICATE----- и заканчивается -----END CERTIFICATE-----

В данном случае полная цепочка для серверного сертификата будет выглядеть так:

-----BEGIN CERTIFICATE-----
сам серверный сертификат
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
сертификат промежуточного ЦС
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
сертификат корневого ЦС
-----END CERTIFICATE-----

Создадим сперва цепочку для промежуточного, добавив туда корневой (открытый) сертификат:

cat pub/ca1.crt ../ca_root/pub/ca.crt > pub/ca1_chain.crt

Далее создадим цепочку для серверного сертификата

cat pub/192.168.200.10.crt pub/ca1_chain.crt > pub/192.168.200.10_chain.crt

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

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

Генерация клиентских сертификатов

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

openssl genrsa -out priv/client1.key 2048
openssl genrsa -out priv/client2.key 2048

Теперь создаем запросы на подпись, не забываем указывать cname = client1 и client2 соответственно

openssl req -config openssl.conf \
-key priv/client1.key -new -sha256 \
-out csr/client1.csr


openssl req -config openssl.conf \
-key priv/client2.key -new -sha256 \
-out csr/client2.csr

Подписываем оба сертификата по очереди

openssl ca -config openssl.conf -extensions usr_cert \
-days 375 -notext -md sha256 \
-in csr/client1.csr \
-out pub/client1.crt


openssl ca -config openssl.conf -extensions usr_cert \
-days 375 -notext -md sha256 \
-in csr/client2.csr \
-out pub/client2.crt
клиентский сертификат отличается по свойствам от серверного
клиентский сертификат отличается по свойствам от серверного

Создание ключевой пары

В систему просто так не инсталлировать отдельно ключ и отдельно открытый сертификат, их нужно объединить в ключевую пару p12, создадим две ключевые пары для двух сертификатов - client1 и client2. Помните, выше мы создавали цепочку ca1_chain.crt нам она сейчас пригодится, это цепочка подписантов.

openssl pkcs12 -export \
-in pub/client1.crt \
-inkey priv/client1.key \
-certfile pub/ca1_chain.crt \
-out psk12/client1.p12 \
-passout pass:123321

Тоже самое повторим для второго клиентского ключа

openssl pkcs12 -export \
-in pub/client2.crt \
-inkey priv/client2.key \
-certfile pub/ca1_chain.crt \
-out psk12/client2.p12 \
-passout pass:123321

На выходе у нас будет два файла, которые можно установить в систему

указываем пароль из команды генерации ключевой пары
указываем пароль из команды генерации ключевой пары

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

Настройка тестового сервера Nginx с TLS

Данный этап будет проходить уже на Linux-машине, я скопирую всю папку туда, соответствие файлов будет по аналогии, положу всю папку cert в /opt/ssl/

Помимо nginx я установил php-fpm, мне потребуется простейший скрипт для отладки.

Конфиг nginx

server {
        listen 80 default_server;
        listen [::]:80 default_server;

        # SSL configuration
        #
        listen 443 ssl default_server;
        ssl_certificate /opt/ssl/cert/ca1/pub/192.168.200.10_chain.crt;
        ssl_certificate_key /opt/ssl/cert/ca1/priv/192.168.200.10.key;
        #mtls
        ssl_client_certificate /opt/ssl/cert/ca1/pub/ca1_chain.crt;
        ssl_verify_client optional;

        root /www;

        index index.php;

        server_name _;

        location / {
                try_files $uri $uri/ /index.php?$args;
        }


        location  ~ \.php$ {
                include snippets/fastcgi-php.conf;

                fastcgi_pass unix:/run/php/php7.4-fpm.sock;
                fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

                fastcgi_param           X-SSL-CERT        $ssl_client_cert;
                fastcgi_param           X-SSL-VERIFIED    $ssl_client_verify;
                fastcgi_param           X-SSL-CLIENT-DN   $ssl_client_s_dn;
                fastcgi_param           X-SSL-ISSUER-DN   $ssl_client_i_dn;
                fastcgi_param           HTTP_PROXY      "";
                fastcgi_param           SSL_CLIENT_SERIAL $ssl_client_serial;
        }
}

Тестовый скрипт /www/index.php

<pre>
<?php print_r($_SERVER); ?>
POST=
<?php print_r($_POST)?>
GET=
<?php print_r($_GET)?>
RAW=
<?=file_get_contents('php://input')?>
</pre>

Дальше проверяем конфиг и перезагружаем nginx

nginx -t
service nginx restart

Открываем приватное окно в хроме или яндекс браузере, для firefox отдельная история с собственными хранилищами.

При входе на сайт у нас идет запрос сертификата, как мы помним, сервер сообщает о поддержки аутентификации и поэтому браузер запрашивает.

в скрипте видно с каким сертификатом мы пришли
в скрипте видно с каким сертификатом мы пришли

С серверным сертификатом тоже всё отлично, браузеры не ругаются

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

Чтобы curl не ругался на сертификат, ему нужно указать цепочку доверенных

curl -s \
--cacert cert/ca1/pub/ca1_chain.crt \
https://192.168.200.10

Далее можно указать пару ключей для авторизации по ключу и проверить с каким ключом мы пришли

curl -s \
--cacert cert/ca1/pub/ca1_chain.crt \
--key cert/ca1/priv/client1.key \
--cert cert/ca1/pub/client1.crt \
https://192.168.200.10 | grep X-SSL-CLIENT-DN
сервер показывает CN сертификата с которым к нему пришли
сервер показывает CN сертификата с которым к нему пришли
признак аутентификации клиента
признак аутентификации клиента

Но если указать сертификат, который подписан отличным от нашего ЦС, то приложению не будет передана информация о сертификате и X-SSL-VERIFIED будет NONE.

Можно указать не опциональную, а строгую проверку сертификатов в nginx, так, чтобы нельзя было без сертификата ничего прислать

ssl_verify_client on;

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

А еще утилита openssl умеет отлаживать настройку серверных сертификатов, но это уже тема для отдельной статьи

openssl s_client -connect 192.168.200.10:443
если на сервере не включена клиентская аутентификация по сертификатам, то такого блока не будет
если на сервере не включена клиентская аутентификация по сертификатам, то такого блока не будет

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

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


  1. md_backend_binance
    04.09.2022 19:03

    если вы в этом разбираетесь , подскажите как так вышло что все домены .app по умолчанию имеют ssl сертификаты?


    1. lohabr
      04.09.2022 21:29

      No, this doesn’t include an SSL certificate - domains.google/tld/app/


    1. ColdPhoenix
      05.09.2022 03:01

      По ссылке человека выше, видим, что домены .app включены в HSTS список, из-за этого SSL требуется.

      Но, в комплект не входит.


  1. sirmax123
    04.09.2022 19:28
    +1

    В аналогичном эксперименте для домашней сети я использовал (что бы упростить жизнь) хашикорп vault

    на самом деле тут не помешает рассказать о том какие опции бывают у сертификатов кроме клиентских и серверных.

    Как вводная статья очень хорошо


  1. ivlad
    05.09.2022 04:31
    +1

    openssl genrsa -aes256 -out priv/ca.key 4096

    Не надо в 2022 году генерировать RSA ключи для новых инсталляций. Вообще, стоит переставать пользоваться RSA везде, где возможно. Вот тут есть подробности: ToB:fRSA


    1. event1
      05.09.2022 17:29

      Вот тут есть подробности: ToB:fRSA

      Строго говоря, тут пишут что не надо реализовывать RSA руками и использовать левые параметры. Если использовать TLS и OpenSSL последних версий, то всё должно быть ок.


      1. ivlad
        07.09.2022 06:39

        А ещё не надо пользоваться паддингом PKCS1, потому, что он проблемный, 4096 битными ключами, потому, что они бессмысленно медленные и несовместимые, и нужно нигде никогда не лажать с side channel.

        Зачем это всё в green field сетапе в 2022 году?


        1. event1
          07.09.2022 12:06

          TLS всё это сделает за нас. Просто надо поставить ограничение на минимальную версию.

          4096-и битные ключи действительно медленнее чем 2048-и битные и не нужны. Но настолько ли это большая проблема при современном развитии компьютерной техники? По-моему, не очень большая.


  1. event1
    05.09.2022 17:16

    Ещё один важнейший аспект работы с сертификатами — это их отзыв. Для этого используется команда openssl ca -config openssl.conf -revoke <certpath>

    Собственно, простота отзыва это одно из самых важных преимуществ PKI над паролями


    1. PavelBelyaev Автор
      05.09.2022 17:35

      Спасибо, я планировал эту тему рассмотреть дальше.