Как работают ACME-клиенты для авто-получения SSL сертификата (в том числе бесплатного)

Введение

ACME — это стандартный (RFC8555) протокол для автоматической выдачи или отзыва SSL-сертификатов (X.509). Наиболее известным сервисом для бесплатной выдачи SSL-сертификатов является Let’s Encrypt. Но и есть другие сервисы, например Zero SSL.
В статье имеются следующие оговорки и ограничения:

  • Рассматривается только вторая (последняя) версия ACME-протокола.
  • В процессе используется линуксовый openssl. Поэтому для других ОС требуется адаптация.
  • Механизм описан в виде инструкции.
  • Реализацию можно посмотреть в виде Lua-модуля здесь. Другие реализации — можно посмотреть здесь.

Итак, сама инструкция:

Подготавливаем CSR

Предварительно необходимо сформировать запрос на подпись сертификата (Certificate Signing Request) — CSR. Этот файл (назовём его csr.pem) содержит информацию о вашем домене и организации. А именно:

  1. Доменное имя (CN) — на которое выпускается сертификат;
  2. Организация (O) — полное имя организации, которой принадлежит сайт;
  3. Отдел (OU) — подразделение организации, которое занимается выпуском сертификата;
  4. Страна (C) — код из двух символов, соответствующий стране организации (список);
  5. Штат/Область (ST) и город (L) — местонахождение организации;
  6. 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-запрос к этому адресу. В ответе будут данные сертификата. Эти данные записываем в файл — и вот, сертификат готов!

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *