26 апреля 2018 Время чтения: 8 минут

Формирование JWS и JWK из rsa-ключей на примере интеграции Let’s Encrypt и ISPmanager

Дмитрий Смирнов

Дмитрий Смирнов

Программист ISPmanager

ISPSystem
Всем привет! Меня зовут Дмитрий Смирнов, я разработчик из ISPsystem и это именно я в ответе за появление в панели ISPmanager 5 интеграции с Let’s Encrypt. Расскажу, как проходила разработка плагина, как он менялся и как пришел в теперешнее состояние. Из текста узнаете, как формировать JWS и JWK из rsa-ключей и получать Let’s Encrypt сертификат для ACME v01. Если интересно, добро пожаловать под кат.

Let’s Encrypt 1.0

Первая версия плагина была прекрасна во всех отношениях. Ее успех сравним только с ее монументальным крахом (да-да, сценаристы «Матрицы» тоже читали эту статью). Задачу мне тогда поставили примерно так: вот тебе letsencrypt.org, сделай что-нибудь с этим. Ну я и сделал.
Изначально плагин просто тянул с гитхаба официальный клиент Let’s Encrypt и работал напрямую с ним. Да и дружелюбным к пользователям он, мягко говоря, не был. Нет домена? Ничего не заказываем. Не резолвятся псевдонимы? Закругляемся. Вся подготовительная работа по выпуску сертификата ложилась на плечи пользователя, и любая ошибка приводила к неудачному получению сертификата.
Надо ли говорить, что плагин вернули на доработку. Так началось мое увлекательное путешествие в удивительный мир интернет-безопасности и клиентоориентированности.

Let’s Encrypt 2.0

Перед вторым подходом к разработке мы сформулировали несколько задач:
  1. Реализовать получение сертификата на уровне протокола ACME.
  2. Подробно информировать пользователя о процессе.
  3. Сделать возможным получение сертификата при создании веб-домена.
  4. Ожидать резолва имен домена в течение суток после начала процесса выдачи.
Разумеется, главным вызовом для меня стал первый пункт. Вооружившись официальной документацией, я начал разработку.
Сервис Let’s Encrypt (LE) был создан корпорацией Internet Security Research Group (ISRG). Специально для него ISRG разработали протокол Automatic Certificate Management Environment (ACME). Сам по себе процесс получения сертификата представляет собой POST-запросы к сервису LE, где тело запроса представлено в виде JSON, обернутого в JSON Web Signature (JWS).
Шаги для получения выглядят так:
  1. регистрация,
  2. авторизация и получение способов подтверждения владения доменом,
  3. подтверждение владения,
  4. получение сертификата.
Начнем по-порядку.

Регистрация и авторизация пользователя

Для создания и авторизации пользователя нужна пара rsa ключей в pem-формате, которые впоследствии послужат основой для конструирования JWS.

openssl genrsa -out private.pem 2048
Структура данных POST-запроса для общения с ACME v01:

{
  "header": jws, //JSON Web Signature
  "protected": Base64Url(jws + Replay-Nonce), //Nonce — защита от повторов
  "payload": Base64Url(payload), //Запрос
  "signature": Base64Url(sign(protected.payload, private.pem)) //Подпись
}
Здесь стоит заострить внимание на трех вещах. Во-первых, Replay-Nonce возвращается в хедерах ответа acme-v01.api.letsencrypt.org/directory. Во-вторых, Payload — это JSON, в котором вы объясняете, чего, собственно, хотите от ACME в данном конкретном случае. В-третьих, JWS представляет из себя JSON следующего вида (оговорюсь, что есть способы получения подписи другими алгоритмами. Здесь приведен всего один, возможно, простейший):

{
    "alg" : "RS256",
    "jwk" : { //JSON Web Key
     // key type -- тип семейства криптографических 
     // алгоритмов использованных для ключа
    "kty" : "RSA",
     // публичная экспонента ключа в виде HexToBase64UrlEnodedByte
    "e" : "...", 
     // modulus ключа в виде  HexToBase64UrlEnodedByte
    "n" : "..." 
  }
}
Встал вопрос, где брать данные для JWK. Недолгие поиски в интернете дали свои плоды, и я нашел простой способ посмотреть на саму пару pem-ключей в расшифрованном виде. Вот пример:

openssl rsa -text -noout < private.pem
Вывод команды в максимально сокращенном виде:

Private-Key: (2048 bit)
modulus:
    00:a8:c5:cc:9c:24:9b:d1:8d:9a:67:81:4d:1f:57:
   ...
    8c:45:51:9e:26:fc:12:35:9e:a0:10:fd:80:94:cc:
    09:a5
publicExponent: 65537 (0x10001)
privateExponent:
   ...
prime1:
    ...
prime2:
    ...
exponent1:
    ...
exponent2:
    ...
coefficient:
    ...
Вот и они, так нужные нам данные, бери — не хочу. Я взял, привел к нужному виду, создал JWS. Но меня ждало жестокое разочарование: подпись оказалась неправильной. Все это вылилось в несколько долгих часов поиска информации в интернете, отладки и безысходности. И все-таки ответ всплыл.
Оказалось, что первые два нуля являются артефактами, появляющимися при кодировании целого числа средствами ASN.1, но есть другой способ получить модулус в готовом для обработки и вставки в JWS виде.

openssl rsa -noout -modulus < private.pem
Вуаля:

Modulus=A8C5CC9C249BD18D9A67814D1F57...8C45519E26FC12359EA010FD8094CC09A5

Получение сертификата

Теперь давайте пройдемся по запросам и payload’ам. В первую очередь позовем GET для acme-v01.api.letsencrypt.org/directory. Из полученного JSON

{
  "key-change": "https://acme-v01.api.letsencrypt.org/acme/key-change",
  "meta": {
    "terms-of-service": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf"
  },
  "new-authz": "https://acme-v01.api.letsencrypt.org/acme/new-authz",
  "new-cert": "https://acme-v01.api.letsencrypt.org/acme/new-cert",
  "new-reg": "https://acme-v01.api.letsencrypt.org/acme/new-reg",
  "revoke-cert": "https://acme-v01.api.letsencrypt.org/acme/revoke-cert",
  "zH_Sr0qwmwM": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417"
}
Возьмем адрес пользовательского соглашения и адреса для запросов сертификата.

Регистрация


url = directory["new-reg"]
payload = {
  "resource": "new-reg",
  "agreement": directory["meta"]["terms-of-service"]
}

Авторизация

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

url = directory["new-authz"]
payload = {
  "resource": "new-authz",
  "identifier": {
    "type":"dns",
    "value": "name"
  }
}

Проверки

Приступим к проверкам имен домена. Для версии ACME v01 доступно три способа: http, dns, tls. Наш выбор пал на первый способ, как самый простой и доступный. Суть проста: в директории домена должна быть создана поддиректория .well-known/acme-challenge, куда будет положен токен проверки — файлик с именем, указанным в проверке.
Сам токен должен содержать в себе строку имя_токена.Base64Url(отпечаток_jwk) — это будет так называемый ключ авторизации. Получить отпечаток легко можно с помощью OpenSSL командой:

echo jwk | openssl dgst -sha256 -binary | base64url
Боюсь, для bash вам придется написать функцию base64url самостоятельно.
Как бы легко это ни было, я умудрился застрять на несколько часов. Детские ошибки — самые страшные. В openssl в конце JWK передавался символ переноса строки. Будьте внимательны к этим данным, отпечаток должен быть чистым” :).
Тело запроса будет выглядеть так:

payload = {
  "resource": "challenges",
  "keyAuthorization": ключ авторизации
}
а url берем из JSON проверки.
Я написал хитрый механизм, который рассовывал токены в нужные узлы кластера и потом их удалял, что в последствии оказалось лишней работой (об этом позднее).

Выдача сертификата


url = directory["new-cert"]
payload = {
  "resource": "new-cert",
  "csr": csr
}
И, о чудо, первый сертификат LE был получен успешно!

Клиентоориентированность

Оставалось решить проблемы, которые ждали рядовых пользователей. Как обойти неизбежный синхронный выпуск сертификата, когда еще не резолвятся псевдонимы домена? Мы решили, что пользователь должен получить сертификат сразу же при заказе, но самоподписанный.
Выпускаем самоподписанный сертификат, подключаем к домену и регистрируем внутренний заказ на сертификат от LE. Каждые 5 минут начинаем процедуру получения. Если она завершается неудачно, спокойно ждем следующей попытки. На разрешение всех возможных проблем даем пользователю 24 часа, и только потом сдаемся и вычеркиваем сертификат из очереди на выдачу.
Готовый свеженький сертификат от LE остается подложить на место старого самоподписанного. Вот и все. Именно таким плагин интеграции с Let’s Encrypt увидел свет.

Трудности

Мы старались предусмотреть все проблемы, которые могли возникнуть при выпуске сертификата, но некоторые ошибки все-таки ускользнули от нашего пытливого взора. Основной проблемой стали многочисленные и вездесущие файлы-настройки .htaccess. Очень часто они приводили к ситуации, когда токен проверки, заботливо положенный в директорию домена, просто напросто оказывался недоступен. И единственным выходом для пользователя было временное отключение его настроек.
Спустя несколько месяцев стало ясно, что механизм рассылки токенов по директориям доменов себя не оправдал. Для всех созданных средствами панели веб-доменов, мы начали добавлять псевдоним /.well-known/acme-challenge/, ведущий в директорию /usr/local/mgr5/www/letsencrypt. Именно в нее начали помещаться токены для проверки, что в дальнейшем свело ошибки доступа к минимуму.

Проверка через DNS

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

echo ключ_авторизации | openssl dgst -sha256 -binary | base64url
Вот и все на сегодня. Про переход плагина на ACME v02 и поддержку wildcard сертификатов читайте в следующем выпуске.