Создание подписанного TLS сертификата с помощью OpenSSL и PowerShell

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

Привет, Хабр и читатели! В своей прошлой статье про написание скрипта на PowerShell для отслеживания сроков действия сертификатов. В этом туториале я хочу рассказать (и поделиться) о скрипте, который позволит создавать сертификаты субъекта, подписанные вышестоящим сертификатам типа root (это позволит не устанавливать каждый раз выпускаемые сертификаты субъекта в хранилище доверенных издателей). Статья будет в виде подробного туториала, чтобы охватить как можно больше аудитории (а она разная) и в основном для тех, кто будет это делать впервые, ну или почти впервые.

Что мы конкретно будем делать? В этом туториале мы будем делать следующее:

  1. Установим OpenSSL на ОС Windows (потому что будем в итоге писать небольшой скрипт для PowerShell);

  2. Выпустим ключи уровня CA, на которых будут выпускаться остальные ключи (уровня субъекта) для наших сервисов;

  3. Напишем, собственно, скрипт на PowerShell, который будет:

    3.1. Создавать файл конфигурации для генерации файла запроса;

    3.2. Создавать ключи с нужными для нас полями, нужными признаками и, самое важное, с полем SAN и единым вышестоящим root сертификатом;

    3.3. Конвертировать ключи в форматы .crt, .key, .pem, .pfx (самые частые форматы ключей, которые требуют сервисы);

  4. Протестируем скрипт и подведём итоги.

Если после прочитанного вы вообще не поняли, о чём речь, зачем это нужно и что получится — переходите к тестированию и итогам — там будут все результаты и демонстрационная гифка. А мы начнём с начала…

1) Установка OpenSSL

Тут совсем коротко, потому что скачивать и устанавливать пакеты в Windows, я думаю, на Хабре все давно умеют, тем более этих гайдов по установке OpenSSL в инторнетах ну целая куча. Основной замес туториала не в этом. И всё же… первым делом нам нужно установить OpenSSL для Windows (мы ведь собираемся через PowerShell с ним работать потом).

Перейдём на https://slproweb.com/products/Win32OpenSSL.html

Пролистаем немного вниз страницы и скачаем актуальный установочный файл — у меня это Win64 OpenSSL v3.4.0

Рисунок 1 - скачиваем установочный файл OpenSSL
Рисунок 1 — скачиваем установочный файл OpenSSL

Скачиваем установочный файл и запускаем его. Установка проходит в режиме «Далее, далее, готово» за исключением вот этой галки, тут советую сложить все библиотеки в /bin директорию:

Рисунок 2 - установка OpenSSL
Рисунок 2 — установка OpenSSL

Теперь проверяем, что сам OpenSSL корректно установился и работает:

Пуск --→ OpenSSL --→ Win64 OpenSSL Command Prompt

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

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

$currentPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
[System.Environment]::SetEnvironmentVariable('Path', "$currentPath;C:\Program Files\OpenSSL-Win64\bin\", 'User')

Коротко, что делает скрипт — записывает в переменную $currentPath текущее значение Path пользователя. Это критически важно, потому что если мы сразу передадим туда новое значение (новый путь), то мы просто затрём все пути, которые там созданы на данный момент системой.

Так вот, после того, как мы получили значение, добавим туда C:\Program Files\OpenSSL-Win64\bin\ и уже после этого передаём новое значение. Всё это можно сделать вручную через графику в настройках переменных сред, если вы не доверяете командам в PowerShell.
Вот тут это делается ручками через графику:

Рисунок 3 - изменение переменных сред в графике
Рисунок 3 — изменение переменных сред в графике

Чтобы командная строка начала воспринимать новые пути после изменения, нужно закрыть все текущие её сессии и открыть заново (чтобы она перечитала переменную пути). Закрываем/открываем, пишем «openssl version», чтобы проверить, что скрипт отработал штатно — если всё хорошо (а я надеюсь, что у вас всё хорошо), то результатом вы получите версию установленной OpenSSL. Отлично, перейдём к более интересному.

2) Выпускаем ключи и корневой сертификат уровня CA

Поехали выпускать ключики. Тут по классике, всё в PowerShell (от администратора) — создаём директорию для ключей в нужном месте. У меня это так:

mkdir E:\Certs\CA

Создадим и поместим в неё закрытый ключ:

openssl genrsa -out E:\Certs\CA\ca.key

Теперь создадим для него сертификат, но…здесь немного поподробнее: есть такая опция »-days» и туда мы должны передать значение, которое будет определять, сколько дней этот сертификат будет валиден — напомню, речь идёт о корневом сертификате уровня CA. Тут смотрите, какие у вас цели и потребности. Если вы собираетесь основательно и продолжительно выпускать на этом сертификате другие сертификаты, а также не будете публиковать его в открытых источниках, советую выпустить его лет на 10, то есть 3650 дней. Учитывайте, что после истечения этого срока, все нижестоящие сертификаты, которые были выпущены на руте — тоже протухнут, а вместе с ними, возможно, остановятся и сервисы.

Что ещё важно: раз этот сертификат у нас будет уровня CA, то в него обязательно нужно включить нужные признаки, а не просто подписать им другой сертификат. Эти признаки должны содержаться в конфигурационном файле по пути: "C:\Program Files\OpenSSL-Win64\bin\openssl.cfg" чем мы и воспользуемся. Итак, в итоге команда будет следующей:

openssl req -x509 -new -key E:\Certs\CA\ca.key -days 3650 -out E:\Certs\CA\root.crt -extensions v3_ca -config "C:\Program Files\OpenSSL-Win64\bin\openssl.cfg" -subj "/CN=Root Certification authority”

Внимательно следите за путями в командах (не забывайте подставлять свои). На этом этапе, если вы всё ввели без ошибок, то в вашей директории (у меня это "E:\Certs\CA\") должен был создаться root сертификат. Надеюсь, что это так и тогда это отлично! Предлагаю открыть и посмотреть на него. При первом открытии он выглядит так:

Рисунок 4 - сертификат уровня CA
Рисунок 4 — сертификат уровня CA

Сейчас мы видим, что наша система не доверяет этому сертификату — всё правильно.

Предлагаю сразу проверить поля, которые для нас важны, а потом установить сертификат в хранилище системы. Перейдём на вкладку «Состав». Какие поля нас интересуют? Всего 3 поля:   CN, Validity (NotAfter) и Base Constrains. Поле CN должно содержать «Root Certification authority», поле «Validity» должно указывать на срок окончания действия сертификата, у меня это через 10 лет (я смело могу выпускать на нём подчинённые сертификаты и не опасаться того, что корневой в скором времени истечёт, а за ним и подчинённые сертификаты) и поле «Base Constrains» должно содержать признак того, что сертификат уровня CA (Тип субъекта=ЦС), а также на этом поле должен быть восклицательный знак, который говорит о том, что это расширение критическое (то есть обязано учитываться информационной системой, которая будет работать с сертификатом). Это всё присутствует. Если нет,   то удаляем сертификат и перепроверяем команду для создания сертификата — значит вы где-то допустили ошибку. У меня всё без ошибок, сертификат меня устраивает, а значит, самое время установить его в систему.

Нажимаем на кнопку «Установить сертификат…» → Далее → Поместить все сертификаты в следующее хранилище → Обзор → Доверенные корневые центры сертификации → ОК → далее → Готово. Если система уведомит вас о том, что готовится установка сертификата с таким-то отпечатком — соглашаемся, после этого сертификат должен быть установлен. Закрываем сертификат и открываем снова — теперь система доверяет сертификату и мы должны увидеть следующее:

Рисунок 5 - установленный в системное хранилище корневой сертификат. Тут дата отличается от Рисунка 4 из-за того, что этот скрин я сделал на другой машине и в другое время, но в качестве иллюстрации подойдёт и он - не пугайтесь\не удивляйтесь
Рисунок 5 — установленный в системное хранилище корневой сертификат. Тут дата отличается от Рисунка 4 из-за того, что этот скрин я сделал на другой машине и в другое время, но в качестве иллюстрации подойдёт и он — не пугайтесь\не удивляйтесь

Ну, а теперь, переходим к заключительной и самой сложной части этой статьи — будем выпускать подчинённые сертификаты уровня субъекта (для всевозможных веб-интерфейсов, агентов, etc.)

3) Создание скрипта на PowerShell для генерации закрытых ключей и выпуска подчинённых сертификатов, которые будут подписаны вышестоящим root сертификатом, создание скрипта для упаковки ключей в формат .pfx, сшивания цепочки сертификатов в файле .pem

Для написания скриптов на PowerShell я использую PowerShell ISE с повышенными правами, но можно писать всё и в обычный текстовик, а в конце поменять у него расширение на .ps1 — всё это на ваш вкус.

Перейдём в директорию с нашими ключиками:

Set-Location -Path 'E:\Certs\CA'
Скрытый текст

Тут я указывал путь через одинарные кавычки, как того требует документация к PowerShell, но синтаксис самого OpenSSL одинарные кавычки не поддерживает и уведёт вас в ошибку, поэтому там я уже буду использовать двойные

Далее, нам понадобится файл конфигурации, на основе которого будут генерироваться все наши дальнейшие сертификаты. Мы создадим его сами и включим в него несколько «шаблонов», которые не обязательно нужны прямо сейчас, но могут понадобиться в будущем. Раньше я хранил его отдельным файлом в директории, но потом решил, что лучше передам его содержимое в переменную, чтобы этот файлик не валялся, пусть скрипт сам создаёт файлик, передаёт в него содержимое переменной, а затем, в конце своей работы — удаляет файлик. Я не буду утверждать, что это супер метод, но я остановился именно на этом варианте. В случае переноса скрипта на другую машину мне придётся переносить на 1 файлик меньше, код будет более самодостаточен, хоть и менее опрятен.

$content = @"
[req_distinguished_name]
# Может быть пустым, так как мы сами передадим значения полей
# Расширения для пользовательского сертификата по умолчанию
[usr_cert]
basicConstraints = CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
subjectAltName = email:move
# Расширения для пользовательского сертификата по умолчанию, но в запросе указаны SAN
[usr_cert_has_san]
basicConstraints = CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
# Расширение для выпуска сертификата уровня центра сертификации
[v3_ca]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer:always
basicConstraints = CA:true
subjectAltName=email:move
# Расширение для выпуска сертификата уровня центра сертификации, но в запросе указаны SAN
[v3_ca_has_san]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer:always
basicConstraints = CA:true
# Передаём длину ключа и запрос расширений
[req]
prompt             = no
default_bits       = 4096
distinguished_name = req_distinguished_name
req_extensions = req_ext
# Это наш основной шаблон выпуска сертификатов уровня субъекта
[req_ext]
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth, clientAuth
basicConstraints = CA:false
subjectKeyIdentifier = hash
subjectAltName = @san
# Сюда будем передавать адреса дополнительных имён субъекта
[san]
"@

Для создания файлика используем следующую команду:

Set-Content -Path 'E:\Certs\CA\template.cfg' -Value $content 

В основном, для выпуска сертификата будет использоваться шаблон [ req_ext ]. О чём он нам говорит? Ни о чём, потому что он не умеет разговаривать. Он говорит нам о том, что в сертификате субъекта будут обязательно присутствовать такие поля, как «Использование ключа», который согласно RFC должен быть критическим, и который будет содержать два параметра — Цифровая подпись и Шифрование ключей.

Далее идёт поле «Расширенное использование ключа», которое будет содержать такие параметры, как: аутентификация сервера, аутентификация клиента.

Поле «Основные ограничения» будет говорить о том, что этот сертификат не может подписывать другие сертификаты — это конечный сертификат субъекта, а не сертификат уровня CA.

Самое важное поле всей нашей затеи — SAN — Дополнительное имя субъекта. В этом поле будут перечислены доменные адреса или IP-адреса информационных систем, для которых предназначается сертификат. То есть, если вы разворачиваете сервер, адреса которого будут звучать как 192.168.100.100 и/или domaincontroller.mgmt.it то эти адреса как раз пойдут в это поле, и именно эти адреса мы сможем использовать для подключения к веб-интерфейсу в браузере через https.

Ладно, с этим разобрались, идём дальше.

Предполагается, что поле SAN у нас всегда будет разное, потому как для каждой новой системы мы будем создавать свои ключи и у каждой системы будет свой адрес (внезапно), поэтому это поле мы будем запрашивать у пользователя через интерактивный ввод. Как мы это сделаем? Вот так:

$name = Read-Host 'Введите необходимое доменное имя или IP-адрес сайта' add-content -Path 'E:\Certs\CA\template.cfg' -Value ($name)
$name2 = Read-Host 'Введите необходимое доменное имя или IP-адрес сайта' add-content -Path 'E:\Certs\CA\template.cfg' -Value ($name2)

Почему два раза? Лично я чаще всего использую два адреса, это IP-адрес, который обычно не меняется и по нему можно будет подключиться используя https в случае, если что-то случилось с DNS и он не резолвит доменное имя… ну и само доменное имя, которое создано для лёгкости запоминания человеком. Поэтому я передам именно две переменные. Если вам нужно больше, добавляйте больше, их не обязательно заполнять все — если третий адрес вам окажется не нужен, просто передайте пустое значение в переменную (при запросе ничего не вводите и нажмите Enter) — сертификат выпустится корректно. Но для себя я пришёл к выводу, что двух адресов мне, обычно, хватает, хотя бывают и исключения. Теперь самое важное — в каком формате вводить данные в эти переменные? Данные должны выглядеть следующим образом:

Для доменных имён пишем так:

Для IP-адресов пишем так:

DNS.1 = my.domain.ru

IP.1 = 192.168.0.1

DNS.2 = your.domain.io

IP.2 = 10.114.162.9

DNS.n = ours.domain.it

IP.n = 172.11.16.5

Я обычно заполняю вот так:

DNS.1 = usboverip.mgmt.organization.local

IP.1 = 172.16.100.100

Но для некоторых систем нужно и 5 адресов (привет, UserGate NGFW), так что добавляйте нужное именно вам количество.

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

New-Item -Path . -Name "Keys" -ItemType "directory"

Переходим к генерации непосредственно нового закрытого ключа уже для нашей системы, в которую будут устанавливаться эти ключики (уровня субъекта). Буду генерировать 2048 битный ключ — не слишком большой (для старых систем), не слишком маленький (для новых). Вы можете указать другую длину ключа.

openssl genrsa -out Keys/private.key 2048

Теперь генерируем файл запроса на сертификат с явным указанием объектных идентификаторов поля «субъект».

openssl req -new -key Keys/private.key -out Keys/req.csr -config template.cfg -subj "/CN=Subject Certificate/C=RU/ST=Siberia/L=Best City/O=My organization/OU=IT/"

Тут снова небольшой нюанс. Согласно RFC в случае, если в сертификате используется поле SAN (а оно у нас используется, чёрт подери), то в поле CN не должен передаваться адрес, НО не все информационные системы этому следуют, встречаются такие, которые ждут адрес и в этом поле тоже, и без него не «съедят» сертификат, поэтому имейте ввиду, что для конкретно ваших целей, возможно, это поле нужно будет поменять и вписать туда доменное имя, но по умолчанию мы его туда стараемся не писать. Остальные поля, такие как:  Country, State, Locality, Organization, Organizational-Unit — технически не обязательны, но я заполняю их для придания некого порядка и солидности сертификатам. Это опять же, на ваш выбор.

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

openssl x509 -req -in Keys/req.csr -CA root.crt -CAkey ca.key -extensions req_ext -extfile template.cfg -out Keys/certWITHca.crt -days 3330 

В параметре –days передаём количество дней валидности сертификата конечного субъекта. Тут, думаю, без лишних комментариев — пишите столько дней, сколько требуется вашим сертификатам, но не больше срока действия сертификата уровня CA, которым он будет подписан.

На этом этапе у нас уже есть файл запроса, закрытый ключ и файл сертификата, но некоторые системы также требуют загрузки файла цепочки сертификатов. Соберём цепочку:

Get-Content 'Keys\certWITHca.crt', 'root.crt' | Set-Content 'Keys\fullchain.pem' 

Мы создадим цепочку без закрытого ключа. В случае, если вам нужна полная цепочка с закрытым ключом (но это редкость), тогда воспользуйтесь командой:

Get-Content ‘Keys\private.key’, ‘Keys\certWITHca.crt’, ‘root.crt’ | Set-Content ‘Keys\fullchain.pem’

Тут сначала идёт закрытый ключ, потом сертификат субъекта, а в самом конце — коревой. Но будьте внимательны при передаче файла полной цепочки, так как в нём содержатся все ключи (в том числе и закрытый) и он не защищён паролем, как, например, файл .pfx, о котором, кстати, сейчас тоже поговорим.

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

Write-Host "Введите пароль для pfx файла" -ForegroundColor Cyan
openssl pkcs12 -export -out Keys/ReadyKeys.pfx -inkey Keys/private.key -in Keys/certWITHca.crt 

Далее я хочу переименовать папку с ключами (тут опционально, лично моя хотелка):

Rename-Item -Path ‘E:\Certs\CA\Keys’ -NewName ‘Новые Ключи’

И в конце, проверяем существование созданных файликов и выводим текст об их успешном создании:

$directoryPath = 'E:\Certs\CA\Новые Ключи'
$extensions = @(".pfx", ".crt", ".pem", ".key")
$filesFound = Get-ChildItem -Path $directoryPath -File | Where-Object { $extensions -contains $_.Extension }
$missingExtensions = $extensions | Where-Object {
    -not ($filesFound.Extension -contains $_)
}
if ($missingExtensions.Count -eq 0) {
    Write-Host "Все файлы (.pfx, .crt, .pem, .key) были успешно созданы!" -ForegroundColor Green
} else {
    Write-Host "Следующие типы файлов отсутствуют в директории:" -ForegroundColor Yellow
    $missingExtensions | ForEach-Object { Write-Host "- $_" }
}

Добавим в конце запрос нажатия Enter, чтобы была возможность прочитать информационные сообщения или сообщения об ошибках:

Read-Host -Prompt "Нажмите Enter для выхода и откиньтесь на спинку кресла"

Вот и всё. Осталось только удалить файл конфигурации, который создавался скриптом:

Remove-Item 'E:\Certs\CA\template.cfg'

Ну что, пришло время собрать все команды воедино:

Set-Location -Path 'E:\Certs\CA'
$content = @"
[req_distinguished_name]
# Этот раздел может быть пустым, так как мы сами передадим значения полей командой

# Расширения для пользовательского сертификата по умолчанию
[usr_cert]
basicConstraints = CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
subjectAltName = email:move
# Расширения для пользовательского сертификата по умолчанию, но в запросе указаны SAN
[usr_cert_has_san]
basicConstraints = CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
# Расширение для выпуска сертификата уровня центра сертификации
[v3_ca]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer:always
basicConstraints = CA:true
subjectAltName=email:move
# Расширение для выпуска сертификата уровня центра сертификации, но в запросе указаны SAN
[v3_ca_has_san]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer:always
basicConstraints = CA:true
# Передаём длину ключа и запрос расширений
[req]
prompt             = no
default_bits       = 4096
distinguished_name = req_distinguished_name
req_extensions = req_ext
# Это наш основной шаблон выпуска сертификатов уровня субъекта
[req_ext]
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth, clientAuth
basicConstraints = CA:false
subjectKeyIdentifier = hash
subjectAltName = @san
# Сюда будем передавать адреса дополнительных имён субъекта
[san]
"@
Set-Content -Path 'E:\Certs\CA\template.cfg' -Value $content
$name = Read-Host 'Введите необходимое доменное имя или IP-адрес сайта'
add-content -Path 'E:\Certs\CA\template.cfg' -Value ($name)
$name2 = Read-Host 'Введите необходимое доменное имя или IP-адрес сайта'
add-content -Path 'E:\Certs\CA\template.cfg' -Value ($name2) 
New-Item -Path . -Name 'Keys' -ItemType 'directory'
openssl genrsa -out Keys/private.key 2048
openssl req -new -key Keys/private.key -out Keys/req.csr -config template.cfg -subj "/CN=Subject Certificate/C=RU/ST=Siberia/L=Best City/O=My organization/OU=IT/"
openssl x509 -req -in Keys/req.csr -CA root.crt -CAkey ca.key -extensions req_ext -extfile template.cfg -out Keys/certWITHca.crt -days 3330
Get-Content 'Keys\certWITHca.crt', 'root.crt' | Set-Content 'Keys\fullchain.pem'
Write-Host "Введите пароль для pfx файла" -ForegroundColor Cyan
openssl pkcs12 -export -out Keys/ReadyKeys.pfx -inkey Keys/private.key -in Keys/certWITHca.crt
Rename-Item -Path 'E:\Certs\CA\Keys' -NewName 'Новые Ключи'

$directoryPath = 'E:\Certs\CA\Новые Ключи'
$extensions = @(".pfx", ".crt", ".pem", ".key")
$filesFound = Get-ChildItem -Path $directoryPath -File | Where-Object { $extensions -contains $_.Extension }
$missingExtensions = $extensions | Where-Object {
    -not ($filesFound.Extension -contains $_)
}
if ($missingExtensions.Count -eq 0) {
    Write-Host "Все файлы (.pfx, .crt, .pem, .key) были успешно созданы!" -ForegroundColor Green
} else {
    Write-Host "Следующие типы файлов отсутствуют в директории:" -ForegroundColor Yellow
    $missingExtensions | ForEach-Object { Write-Host "- $_" }
}
Read-Host -Prompt "Нажмите Enter для выхода и откиньтесь на спинку кресла"
Remove-Item 'E:\Certs\CA\template.cfg'

4) Тестируем скрипт и подводим итоги

Сохраняем файл как скрипт с расширением .ps1, клацаем на нём правой кнопкой мыши, выбираем «Запустить с помощью PowerShell» — должно запуститься выполнение скрипта и всё должно быть без ошибок. Скрипт запросит у вас на ввод две переменные — это адреса альтернативного имени субъекта. Вводим их, затем вводим (с подтверждением) пароль для .pfx файла. В идеальном варианте это всё — ключи готовы, файлики созданы, песенка спета, птичка в клетке.

Рисунок 6 - финальный результат работы скрипта
Рисунок 6 — финальный результат работы скрипта

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

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

© Habrahabr.ru