Как создать самоподписанный TLS сертификат
Инструкция по созданию TLS сертификата с SAN для Ubuntu 24.04. Настройка nginx и Apache без использования curl -k для корректной работы в Chrome.
Что вы создаете
Самоподписанный TLS-сертификат, который принимают современные браузеры и клиенты — с корректным subjectAltName, правильными правами доступа к ключам и интеграцией в nginx или Apache. Также вы решите проблему, которую пропускают почти все руководства: вы настроите доверие клиентов к сертификату, чтобы им не приходилось игнорировать предупреждения и прописывать curl -k в скриптах. В итоге вы получите приватный CA из пяти команд, который пригодится, когда количество внутренних сервисов вырастет с одного до шести.
Сначала нужно принять решение, так как самоподписанный сертификат подходит для задач гораздо реже, чем принято считать. Если сервис доступен из публичного интернета по реальному DNS-имени, прекратите чтение и используйте бесплатный сертификат Let's Encrypt с помощью certbot на nginx или аналог для Apache. Это бесплатно, сертификат обновляется автоматически, и все браузеры в мире уже доверяют ему. Самоподписанный сертификат на публичном сайте приучает пользователей игнорировать предупреждения безопасности, что хуже, чем использование обычного HTTP.
Самоподписанный сертификат подходит, когда нет доступа к публичному интернету: панель администратора, привязанная к адресу WireGuard tunnel на вашем VPS, staging-сервер в частной сети, трафик между бэкендами (service-to-service), домашняя лаборатория или замена стандартного сертификата, который генерирует Webmin для порта 10000. Let's Encrypt в любом случае не может выпустить сертификат для 10.8.0.1 или git.internal.lan — ни один публичный CA не включит приватный IP или вымышленный TLD в сертификат. В этих случаях вы сами являетесь CA.
Все последующие действия выполняются на чистой системе Ubuntu 24.04 с OpenSSL 3.0.x (используйте openssl version для проверки). Для работы не требуется доступ к интернету; все работает в изолированной среде.
Почему старая команда в одну строку создает сертификаты, которые отклоняет Chrome
Команда, которую приводят во всех руководствах до 2017 года, выглядит так:
# Do not run this — shown so you recognise it in old guides
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout selfsigned.key -out selfsigned.crtОна запрашивает серию интерактивных вопросов, записывает ваш hostname в поле Common Name и создает сертификат без расширения subjectAltName. Такой сертификат не будет работать. Chrome перестал использовать Common Name в версии 58 в апреле 2017 года — согласно RFC 2818, использование CN для сопоставления было устаревшим еще в 2000 году. Firefox, Safari, curl и Python работают аналогично. Сертификат идентифицирует сервер только через расширение SAN; в противном случае он не считается валидным. Браузер выводит следующее сообщение:
NET::ERR_CERT_COMMON_NAME_INVALID
This server could not prove that it is git.internal.lan; its security
certificate does not specify Subject Alternative Names.Изменение хранилища доверенных сертификатов (trust-store) не исправит эту ошибку, так как сертификат не содержит имени сервера. Если вы видите ошибку NET::ERR_CERT_COMMON_NAME_INVALID, ваш сертификат не содержит SAN (или содержит неверный SAN), и вам необходимо создать новый. Решение состоит из одной команды.
Создание сертификата, принимаемого браузерами: одна команда
В версии OpenSSL 1.1.1 был добавлен флаг -addext. Теперь не требуется использовать сложные конфигурационные файлы для добавления SAN, как в старых руководствах. В Ubuntu 24.04:
sudo openssl req -x509 -newkey rsa:4096 -sha256 -days 730 -noenc \
-keyout /etc/ssl/private/git.internal.key \
-out /etc/ssl/certs/git.internal.crt \
-subj "/CN=git.internal.lan" \
-addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"Назначение каждого флага:
-x509создает самоподписанный сертификат напрямую вместо запроса на подпись (CSR).-newkey rsa:4096генерирует новый ключ на том же этапе. RSA 4096 совместим со всеми устаревшими клиентами; если все подключаемые устройства современные,-newkey ec -pkeyopt ec_paramgen_curve:P-256будет работать быстрее и занимать меньше места.-noenc— это синтаксис OpenSSL 3.x для старого флага-nodes: ключ без пароля. Работают оба варианта. Ключ с паролем заставит nginx ожидать ввода при каждой загрузке, поэтому для серверного ключа следует использовать этот флаг.-days 730— срок действия в два года; подробности см. в разделе об истечении срока действия.-subj— позволяет отвечать на интерактивные вопросы в командной строке. Поле CN сейчас носит косметический характер, но все равно укажите там основное имя; некоторые инструменты используют его для отображения.-addext "subjectAltName=..."— основной флаг. Перечислите каждое имя и каждый IP-адрес, которые будут вводить клиенты: записиDNS:для хостов (допустимы wildcard-записи, напримерDNS:*.internal.lan) и записиIP:для IP-адресов. Если кто-то будет заходить наhttps://10.8.0.1, записьIP:10.8.0.1должна быть в списке — SAN только с DNS-именами снова вызовет ошибкуNET::ERR_CERT_COMMON_NAME_INVALID.
Перед настройкой сервисов проверьте наличие SAN в сертификате:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -ext subjectAltNameВерный вывод:
X509v3 Subject Alternative Name:
DNS:git.internal.lan, IP Address:10.8.0.1Если вместо этого выводится No extensions in certificate, значит, в сертификате отсутствует SAN и браузеры будут его отклонять. В этом случае не продолжайте настройку, а перевыпустите сертификат.
Ограничьте доступ к ключу
Приватный ключ, доступный для чтения любому пользователю в системе, не является приватным. В Ubuntu /etc/ssl/private уже имеет права 710 root:ssl-cert, что защищает его от случайного просмотра, но рекомендуется установить права на файл явно:
sudo chown root:root /etc/ssl/private/git.internal.key
sudo chmod 600 /etc/ssl/private/git.internal.keyNginx и Apache считывают сертификаты от имени root перед снижением привилегий, поэтому для них подходит режим root:root 600. Если ключ используется сервисом, который работает под собственной учетной записью и самостоятельно загружает ключ — например, Node app, Gitea или Python daemon — установите владельцем chown пользователя этого сервиса, сохранив режим 600. Категорически запрещено: использовать режим 644, хранить копию в git repository или в /tmp.
Настройка интеграции с nginx
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name git.internal.lan;
ssl_certificate /etc/ssl/certs/git.internal.crt;
ssl_certificate_key /etc/ssl/private/git.internal.key;
location / {
proxy_pass http://127.0.0.1:3000;
}
}sudo nginx -t && sudo systemctl reload nginxnginx -t должен вывести syntax is ok и test is successful до того, как команда reload вступит в силу. Если вместо этого выводится SSL_CTX_use_PrivateKey_file() failed ... key values mismatch, это означает, что сертификат и ключ были созданы в разных сессиях — см. раздел с описанием ошибок.
Настройка интеграции с Apache
sudo a2enmod ssl proxy proxy_httpОдного ssl в данном случае недостаточно: в приведенном ниже vhost используется ProxyPass. Без mod_proxy и mod_proxy_http проверка конфигурации завершится ошибкой Invalid command 'ProxyPass', perhaps misspelled or defined by a module not included in the server configuration. Сохраните vhost в файл /etc/apache2/sites-available/git-internal.conf:
<VirtualHost *:443>
ServerName git.internal.lan
SSLEngine on
SSLCertificateFile /etc/ssl/certs/git.internal.crt
SSLCertificateKeyFile /etc/ssl/private/git.internal.key
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>sudo a2ensite git-internal
sudo apache2ctl configtest && sudo systemctl reload apache2configtest должен отвечать на запросы Syntax OK. Теперь выполните проверку с клиентской машины:
curl -v https://git.internal.lan/вы получите ошибку:
curl: (60) SSL certificate problem: self-signed certificateЭто не ошибка. Это корректная работа TLS: curl не знает о вашем сертификате и отказывается взаимодействовать с сервером, подлинность которого невозможно подтвердить. В следующем разделе описано правильное решение — оно отличается от методов, которые использует половина пользователей в интернете.
Как обеспечить доверие клиентов — и антипаттерны, которых следует избегать
Сначала рассмотрим неверные решения. curl -k (или --insecure), прописанные в скрипте, verify=False в Python requests или NODE_TLS_REJECT_UNAUTHORIZED=0 в Node, не делают ваш сертификат доверенным. Эти методы отключают проверку сертификата. Это означает, что клиент будет взаимодействовать с любым сервером, предъявившим любой сертификат, включая сертификат злоумышленника. Вы сохраняете накладные расходы TLS, но теряете аутентификацию, которая была целью использования протокола. Хуже того, эти флаги распространяются по системе: их копируют в cron job, затем в скрипт развертывания, а затем в рабочий код, пока никто не помнит, какие соединения должны были быть временными. Если verify=False сохраняется после завершения сессии отладки, архитектура спроектирована неверно.
Правильное решение — добавить этот сертификат в список доверенных корневых сертификатов в каждой клиентской ОС. Для клиентов на Ubuntu и Debian:
sudo cp git.internal.crt /usr/local/share/ca-certificates/git.internal.crt
sudo update-ca-certificatesВажная строка в выводе (за ней следует блок Running hooks in /etc/ca-certificates/update.d...):
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.В этих строках есть две ловушки. Файл должен иметь расширение .crt — расширение .pem будет проигнорировано без ошибок, и вы получите 0 added. Содержимое должно быть в формате PEM (файл начинается с -----BEGIN CERTIFICATE-----); сначала конвертируйте бинарный DER с помощью openssl x509 -inform der -in file.der -out file.crt. Добавление самоподписанного сертификата в качестве корневого работает, так как самоподписанный сертификат сам является своим корнем.
После этого curl, wget, git, apt и любые другие инструменты, использующие OpenSSL для работы с системным хранилищем, будут доверять серверу без каких-либо дополнительных флагов. Некоторые клиенты используют собственные хранилища доверия и требуют индивидуальной настройки:
- Chrome/Chromium на Linux читает базу данных NSS, а не системное хранилище:
sudo apt install libnss3-tools, затемcertutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crtдля каждого пользователя. - Firefox имеет собственное хранилище: Settings → Privacy & Security → Certificates → Import, либо установите
security.enterprise_roots.enabledв значениеtrueвabout:config, чтобы браузер использовал системное хранилище. - Python requests использует собственный пакет CA (certifi) и игнорирует системное хранилище: передайте
verify="/usr/local/share/ca-certificates/git.internal.crt"или экспортируйтеREQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt. - Node.js: экспортируйте
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/git.internal.crt.
На клиентах Windows дважды щелкните по .crt и установите его в хранилище Trusted Root Certification Authorities; на macOS добавьте его в связку ключей System в приложении Keychain Access и установите параметр Always Trust.
Один корневой сертификат для множества сервисов: небольшой частный CA
Доверительные отношения для каждого сертификата отдельно не масштабируются: шесть сервисов и четыре клиентские машины требуют 24 установки сертификатов, и каждый новый сервис увеличивает это число. Решением является частный CA — клиенты доверяют одному корневому сертификату, а вы подписываете сертификат каждого сервиса с его помощью.
Удобный вариант — mkcert. Он доступен в репозиториях Ubuntu 24.04 и работает с хранилищами NSS (Chrome, Firefox), которые update-ca-certificates не поддерживает:
sudo apt install -y mkcert libnss3-tools
mkcert -install
mkcert git.internal.lan "*.internal.lan" 10.8.0.1mkcert -install создает корневой сертификат и регистрирует его во всех хранилищах доверия на данной машине; третья команда выводит git.internal.lan+2.pem и git.internal.lan+2-key.pem, которые можно использовать в конфигурациях nginx или Apache, приведенных выше. Инструмент рассчитан на рабочую станцию разработчика — закрытый ключ хранится на той машине, где была запущена команда -install. Это идеально подходит для ноутбука разработчика, но не подходит для парка серверов.
Для серверов обычный OpenSSL позволяет настроить CA с помощью пяти команд:
openssl genrsa -out lab-ca.key 4096
openssl req -x509 -new -key lab-ca.key -sha256 -days 3650 \
-out lab-ca.crt -subj "/CN=Lab Internal CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genrsa -out git.key 2048
openssl req -new -key git.key -out git.csr -subj "/CN=git.internal.lan" \
-addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"
openssl x509 -req -in git.csr -CA lab-ca.crt -CAkey lab-ca.key \
-CAcreateserial -days 730 -sha256 -copy_extensions copy -out git.crtОшибка заключается в последней команде: openssl x509 -req по умолчанию удаляет все расширения из CSR, включая добавленный вами SAN. Опция -copy_extensions copy (доступна в OpenSSL 3.x, поэтому работает в 24.04) сохраняет их; если ее не использовать, подписанный сертификат не будет содержать SAN, и Chrome снова выдаст ошибку NET::ERR_CERT_COMMON_NAME_INVALID. Проверьте результат с помощью той же команды openssl x509 -noout -ext subjectAltName, что и ранее.
Распределите lab-ca.crt на клиентах, используя шаги по настройке хранилища доверия, описанные выше — достаточно выполнить это один раз для каждой машины. Берегите lab-ca.key как самое ценное: установите права доступа mode 600 и храните его на машине, отличной от серверов, для которых он подписывает сертификаты. Любой, кто получит к нему доступ, сможет выпустить сертификат для любого имени, которому будут доверять ваши клиенты.
Срок действия и ротация
Срок действия сертификатов публичных CA сокращается. В марте 2026 года CA/Browser Forum ограничил срок действия новых публичных сертификатов 200 днями (вместо 398), в 2027 году этот срок составит 100 дней, а к марту 2029 года — 47 дней. Однако эти правила распространяются только на публичные CA. Ваша частная CA не подчиняется этим правилам, и браузеры не применяют их к сертификатам с вручную установленными корневыми сертификатами. Существует одно реальное ограничение: платформы Apple отклоняют любые TLS-сертификаты серверов, срок действия которых превышает 825 дней, независимо от издателя. Если вы используете iPhone или Mac, срок действия конечных сертификатов должен составлять не более двух лет. -days 730 соответствует этому требованию во всех случаях; схема с корневым сертификатом на 10 лет и конечными сертификатами на 2 года является оптимальной для внутренней инфраструктуры.
Проблемы с долгоживущими сертификатами возникают только одним способом: они внезапно и одновременно перестают работать в дату, которую никто не планировал. Проверьте текущее состояние:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -enddateДобавьте дату обновления в календарь или настройте cron для уведомлений за 30 дней — openssl x509 -checkend 2592000 -in cert.crt завершается с ненулевым кодом, если до истечения срока действия осталось столько секунд. Если вы уже используете Uptime Kuma для мониторинга состояния, ее HTTPS-мониторы бесплатно сигнализируют о приближающемся истечении срока действия сертификата.
Ротация с использованием частной CA проходит без осложнений: повторно выполните команды для создания CSR и подписи, замените файлы и перезагрузите веб-сервер. Корневой сертификат не меняется, поэтому клиенты не заметят изменений.
Режимы сбоев и соответствующие сообщения
NET::ERR_CERT_AUTHORITY_INVALID — это ожидаемое состояние до установки доверия, а не дефект сертификата. Если ошибка сохраняется после установки корневого сертификата: в Linux Chrome использует NSS вместо системного хранилища (см. шаг certutil); или скопированный файл не заканчивался на .crt, и update-ca-certificates сообщил 0 added; или сервер предъявляет сертификат, отличный от того, которому вы доверяете — сравните отпечатки с помощью openssl s_client -connect git.internal.lan:443 </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256.
NET::ERR_CERT_COMMON_NAME_INVALID — в сертификате отсутствует поле SAN, или поле SAN не содержит имя из адресной строки. Типичный случай: в SAN указан DNS:git.internal.lan, но пользователь перешел по адресу https://10.8.0.1. Изменения в хранилище доверенных сертификатов не помогут; необходимо перевыпустить сертификат с недостающим полем.
curl: (60) SSL certificate problem: self-signed certificate — curl не доверяет сертификату. Вариант self-signed certificate in certificate chain означает то же самое для сертификата, подписанного вашим частным CA. Временное решение: curl --cacert lab-ca.crt https://...; постоянное решение: добавление в хранилище доверия. Не используйте -k.
unable to load certificate ... Expecting: TRUSTED CERTIFICATE (или Expecting: CERTIFICATE REQUEST, или no start line) — ошибка формата PEM. Вы передали OpenSSL файл неверного типа: ключ или CSR вместо сертификата, либо бинарный файл DER вместо PEM. Команда head -1 filename покажет фактический тип файла — сертификат начинается с -----BEGIN CERTIFICATE-----. Для DER используйте конвертацию через openssl x509 -inform der -in file.der -out file.crt.
nginx: [emerg] SSL_CTX_use_PrivateKey_file(...) failed (SSL: error ... key values mismatch) — сертификат и ключ не являются парой. Обычно это происходит из-за повторного запуска команды генерации и смешивания файлов. Проверьте соответствие с помощью openssl x509 -in git.internal.crt -noout -pubkey | sha256sum и openssl pkey -in git.internal.key -pubout | sha256sum; совпадающие хеш-суммы означают, что файлы образуют пару. Если они различаются, перегенерируйте оба файла одновременно.
FAQ
Почему Chrome по-прежнему пишет «Not secure» после создания самоподписанного сертификата?
Если возникает ошибка NET::ERR_CERT_AUTHORITY_INVALID, сертификат исправен — у Chrome просто нет причин доверять ему. Установите его (или ваш корневой сертификат частного CA) в хранилище доверенных сертификатов клиента. Помните, что в Linux Chrome использует базу данных NSS через certutil, а не системное хранилище. Если возникает ошибка NET::ERR_CERT_COMMON_NAME_INVALID, сертификату не хватает поля Subject Alternative Name, соответствующего URL; его необходимо перевыпустить с использованием -addext "subjectAltName=...".
Как заставить curl доверять самоподписанному сертификату без флага -k?
Скопируйте сертификат (формат PEM, расширение .crt) в /usr/local/share/ca-certificates/ и выполните sudo update-ca-certificates — в выводе должно быть указано 1 added. После этого curl будет проверять его как любой публичный сертификат. Для разового запроса без изменения системы используйте curl --cacert /path/to/cert.crt для проверки по конкретному файлу; флаг -k полностью отключает проверку, и его не следует использовать в скриптах.
Каким может быть срок действия самоподписанного сертификата?
Технически — любым. Ограничения CA/Browser Forum (сейчас 200 дней, к 2029 году — 47 дней) распространяются только на публичные доверенные CA, а не на частные. На практике ограничивайте срок действия серверных сертификатов 825 днями, так как устройства Apple отклоняют сертификаты с бóльшим сроком независимо от издателя. Разумным стандартом является десятилетний частный корневой сертификат и двухлетние (-days 730) конечные сертификаты; обязательно запланируйте обновление, так как истечение срока действия внутреннего сертификата незаметно нарушит работу всех сервисов.
Что лучше использовать: самоподписанный сертификат или Let's Encrypt?
Если у сервиса есть публичное DNS-имя и он доступен из интернета, всегда используйте Let's Encrypt — это бесплатно, автоматизировано и уже доверено всеми клиентами. Самоподписанные сертификаты (или частный CA) нужны там, где Let's Encrypt не может выпустить сертификат: для частных IP-адресов, внутренних имен хостов (например, .lan), изолированных сетей и сервисов, намеренно скрытых за VPN. Выбор зависит от доступности и именования, а не от криптографической стойкости — она идентична.
Почему мой сертификат отклоняется, даже если я добавил его в /usr/local/share/ca-certificates?
Проверьте три параметра. Файл должен иметь расширение .crt — файлы с расширением .pem игнорируются, а update-ca-certificates сообщает об ошибке 0 added. Содержимое должно быть в текстовом формате PEM, начинаться с -----BEGIN CERTIFICATE-----, а не в бинарном формате DER. Также приложение должно использовать системное хранилище — Chrome в Linux, Firefox, Python requests, Node.js и Java имеют собственные хранилища доверенных сертификатов, и сертификат нужно добавлять в каждое из них отдельно.