Введение
ACME — это стандартный (RFC8555) протокол для автоматической выдачи или отзыва SSL-сертификатов (X.509). Наиболее известным сервисом для бесплатной выдачи SSL-сертификатов является Let’s Encrypt. Но и есть другие сервисы, например Zero SSL.
В статье имеются следующие оговорки и ограничения:
- Рассматривается только вторая (последняя) версия ACME-протокола.
- В процессе используется линуксовый
openssl
. Поэтому для других ОС требуется адаптация. - Механизм описан в виде инструкции.
- Реализацию можно посмотреть в виде Lua-модуля здесь. Другие реализации — можно посмотреть здесь.
Итак, сама инструкция:
Подготавливаем CSR
Предварительно необходимо сформировать запрос на подпись сертификата (Certificate Signing Request) — CSR. Этот файл (назовём его csr.pem
) содержит информацию о вашем домене и организации. А именно:
- Доменное имя (CN) — на которое выпускается сертификат;
- Организация (O) — полное имя организации, которой принадлежит сайт;
- Отдел (OU) — подразделение организации, которое занимается выпуском сертификата;
- Страна (C) — код из двух символов, соответствующий стране организации (список);
- Штат/Область (ST) и город (L) — местонахождение организации;
- email (EMAIL) — почта для связи с организацией.
Сгенерировать такой файл можно с помощью онлайн-генераторов, например вот и вот. Можно с помощью OpenSSL. Для этого потребуются следующие команды:
openssl genrsa -out private.key 4096
openssl req -new -key private.key -out domain_name.csr -sha256
... заполняем поля в режиме вопрос-ответ ...
Подготавливаем JWK
Для получения сертификата требуется пара RSA-ключей. Получаем закрытый ключ командой:
openssl genrsa -out %s 2048
где %s — это имя файла закрытого RSA-ключа.
Открытая экспонента и модуль закрытого ключа — это открытый ключ. Они извлекаются так, как описано здесь. Сначала парсим выводы команд:
openssl rsa -text -noout < %s
openssl rsa -noout -modulus < %s
где %s — это имя того же самого файла закрытого RSA-ключа. Оба параметра конвертируем в двоичный вид.
Далее формируем JSON Web Key (JWK, порядок полей важен):
{
"e": "%1",
"kty": "RSA",
"n": "%2"
}
где %1 — это открытая экспонента, а %2 — модуль закрытого ключа. Оба параметра кодируем из бинарного формата в base64url
. Здесь и далее base64url
— это base64
в режиме safe url.
Получаем переменные от ACME-сервера
Сначала делаем GET-запрос на адрес acmeDirectoryUrl
для получения других API-концов. Пример ответа:
{
"dp75P63JIJg": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417",
"keyChange": "https://acme-v02.api.letsencrypt.org/acme/key-change",
"meta": {
"caaIdentities": [
"letsencrypt.org"
],
"termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf",
"website": "https://letsencrypt.org"
},
"newAccount": "https://acme-v02.api.letsencrypt.org/acme/new-acct",
"newNonce": "https://acme-v02.api.letsencrypt.org/acme/new-nonce",
"newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order",
"revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert"
}
Далее делаем HEAD-запрос на адрес newNonce
. GET запрос тоже работает, но по стандарту надо HEAD. Здесь мы получаем одноразовый номер (nonce
), который идентифицирует следующий ACME-запрос. Нужное значение лежит в заголовке Replay-nonce
. Пример: 0102_Au2AvzhGQT7XZlKBuklVDVIjzweVxjzu1lDE5wBOuY
.
ACME-запрос
Здесь и далее — ACME-запрос, это специальным образом подготовленный POST-запрос. Как и у POST-запроса, у данной операции два входных параметра: url
— адрес куда делаем запрос и payload
— подаваемые данные. Подаваемые данные — это всегда строка JSON.
При вызове ACME-запроса, формируем следующий JSON, который подаём как обычный POST-запрос:
{
"protected": "%base64url(%1)",
"payload": "%base64url(%payload)",
"signature": "%2"
}
Вот это "%base64url(%1)"
— я имею ввиду, что сначала мы формируем строку JSON (%1), затем мы её приводим к base64url
(см. выше).
%1 — это JSON Web Signature (JWS), которая представляет из себя строку JSON, порядок полей важен, вида:
При первом запросе:
{
"alg": "RS256",
"jwk": %jwk,
"url": "%url",
"nonce": "%nonce"
}
При втором и последующих запросах:
{
"alg": "RS256",
"kid": "%kid",
"url": "%url",
"nonce": "%nonce"
}
%kid — получаем после первого ACME-запроса. Он будет лежать в заголовке ответа "Location". Кроме того, после каждого вызова ACME-запроса нужно обновлять nonce
. Новое значение лежит в заголовке ответа "Replay-nonce".
%2 — это результат подписи строки, составленной из полей protected
и payload
, соединённых через точку:
protected = "eeyJ1c~~~9In0"
payload = "eyJ0ZXJtc09mU2VydmljZUFncmVlZCI6dHJ1ZX0"
signData = "eeyJ1c~~~9In0.eyJ0ZXJtc09mU2VydmljZUFncmVlZCI6dHJ1ZX0" -- << -- здесь точка
signature = base64url(sign(signData)) -- << -- здесь sign
где sign — это результат команды вида:
printf '%signData' | openssl dgst -binary -sha256 -sign %s
где %s — это имя файла закрытого RSA-ключа, полученного при формировании JWK.
Пример итогового JSON:
{
"protected" : "eeyJ1c......9In0",
"payload" : "eyJ0ZXJtc09mU2VydmljZUFncmVlZCI6dHJ1ZX0",
"signature" : "A7V7CS......NgBA"
}
Запрос на регистрацию
Отправляем ACME-запрос. Payload =
{
"termsOfServiceAgreed":true
}
Получаем kid
из заголовка.
Делаем заказ
Отправляем ACME-запрос. Payload =
{
"identifiers": [
{"type":"dns","value":"%dnsName"}
]
}
Получаем ответ orderData
вида:
{
"status": "pending",
"expires": "2022-01-24T03:04:49Z",
"identifiers": [
{
"type": "dns",
"value": "%dnsName"
}
],
"authorizations": [
"https://acme-v02.api.letsencrypt.org/acme/authz-v3/69057294310"
],
"finalize": "https://acme-v02.api.letsencrypt.org/acme/finalize/367516800/56032749640"
}
Запоминаем url = authorizations[0]
. А также orderUrl
, его значение лежит в заголовке Loaction
ответа.
Настройка проверок
Для получения информации по проверкам, делаем GET-запрос на адрес authorizations[0]
. Получаем ответ вида:
{
"identifier": {
"type": "dns",
"value": "%dnsName"
},
"status": "pending",
"expires": "2022-01-24T03:04:49Z",
"challenges": [
{
"type": "http-01",
"status": "pending",
"url": "https://acme-v02.api.letsencrypt.org/acme/chall-v3/69057294310/2mXgcg",
"token": "Qo1c4MU9E_XHPIkhfSzyNuX_WvteKhBhagBJsgx1T38"
},
{
"type": "dns-01",
"status": "pending",
"url": "https://acme-v02.api.letsencrypt.org/acme/chall-v3/69057294310/dzZppQ",
"token": "Qo1c4MU9E_XHPIkhfSzyNuX_WvteKhBhagBJsgx1T38"
},
{
"type": "tls-alpn-01",
"status": "pending",
"url": "https://acme-v02.api.letsencrypt.org/acme/chall-v3/69057294310/jkROXw",
"token": "Qo1c4MU9E_XHPIkhfSzyNuX_WvteKhBhagBJsgx1T38"
}
]
}
Выбираем тип проверки и получаем соответствующую структуру с информацией.
Далее необходимо сформировать токен авторизации. Для этого необходимо снять хэш SHA-256 со строки JWK. Хеш необходимо привести к base64url.
local keyAuthorization = challengeData.token .. "." .. base64url(jwkHashBin)
Устанавливаем ответ на проверку на нашем сервере, который слушает 80-й порт:
- Для типа
http01
проверка будет заключаться в GET-запросе на наш ресурс со стороны ACME-сервера. Это может быть несколько запросов с разных адресов. Запрос будет происходить на страничку'/.well-known/acme-challenge/%token
. В ответе должен быть токен авторизации. - Для типа
dns01
проверка будет заключаться в DNS-запросе TXT-записи по ключу_acme-challenge.<YOUR_DOMAIN>
, значение должно быть равно токену авторизации.
Проверки
Для выполнения проверок посылаем ACME-запрос на адрес url
, указанный в соответствующей типу проверке структуре challenges
. Начинают выполняться проверки. Их статус необходимо проверять выполняя GET-запрос на адрес orderUrl
. В получаемой структуре нас интересует поле status
. Если оно = ready
— то всё хорошо. Если оно = invalid
, то проверка не пройдена. Если есть проблема — обратите внимание на лимиты сервиса. Например, Let’s Encrypt выдаёт не более 5 бесплатных сертификатов на домен в неделю. Есть ограничения по количеству запросов — во время отладки на живом сервисе их легко превысить.
Выпуск и загрузка сертификата
Для выпуска отправляем ACME-запрос. Payload =
{
"csr" = "%base64url(%csr)"
}
%csr — это бинарные данные CSR, которые получены путём чтения файла между строками -----BEGIN CERTIFICATE REQUEST-----
и -----END CERTIFICATE REQUEST-----
и преобразованные из base64
.
Получаем ответ вида:
{
"status": "valid",
"expires": "2022-01-24T13:51:21Z",
"identifiers": [
{
"type": "dns",
"value": "%dnsName"
}
],
"authorizations": [
"https://acme-v02.api.letsencrypt.org/acme/authz-v3/69201920610"
],
"finalize": "https://acme-v02.api.letsencrypt.org/acme/finalize/368172920/56154650070",
"certificate": "https://acme-v02.api.letsencrypt.org/acme/cert/0475ab16681caa8a851377aca198c5b36cb5"
}
Если status
= valid
, значит сертификат выпущен и лежит по адресу certificate
. Остаётся выполнить последний GET-запрос к этому адресу. В ответе будут данные сертификата. Эти данные записываем в файл — и вот, сертификат готов!