Хабрахабр

[Из песочницы] История одного SSL рукопожатия

Привет, Хабр!

Казалось бы, дело нехитрое, но вылилось оно в блуждание в исходниках JDK с неожиданным финалом. Недавно мне пришлось прикручивать SSL с двухсторонней аутентификацией (mutual authentication) к Spring Reactive Webclient. Опыта набралось на целую статью, которая может оказаться полезной инженерам в повседневных задачах или при подготовке к собеседованию.

Постановка задачи

Есть REST-сервис на стороне заказчика, который работает через HTTPS.
Необходимо обращаться к нему из клиентского Java приложения.

Я проверила их работоспособность с помощью Postman: указала пути к ним в настройках и, пульнув запрос, убедилась что сервер отвечает 200 OK и осмысленный response body. Первое, что мне выдали в этом квесте, это 2 файла с расширением .pem — клиентский сертификат и приватный ключ. Отдельно проверила, что без клиентского сертификата сервер возвращает HTTP статус 500 и короткое сообщение в теле респонса, о том, что произошёл «Security exception» с определённым кодом.

Далее следовало правильно сконфигурировать клиентское Java приложение.

Для REST-запросов я использовала Spring Reactive WebClient с неблокирующим вводом-выводом.
В документации есть пример, как его можно кастомизировать, прокинув ему объект SslContext, который как раз хранит сертификаты и приватные ключи.

Моя конфигурация в упрощённом варианте была практически копипастой из документации:

SslContext sslContext = SslContextBuilder .forClient() .keyManager(…) /* Есть оверлоад, который принимает .pem файлы сертификата и приватного ключа (и, опционально, пароль). PEM файлы должны быть в определенной кодировке. Проверить/переконвертировать можно с помощью openssl утилиты. Альтернативно можно передать и KeyManagerFactory. */ .build(); ClientHttpConnector connector = new ReactorClientHttpConnector( builder -> builder.sslContext(sslContext)); WebClient webClient = WebClient.builder() .clientConnector(connector).build();

Придерживаясь принципа TDD, я также написала тест, в котором вместо WebClient используется WebTestClient, выводящий кучу отладочной информации. Самый первый assertion был таким:

webTestClient .post() .uri([как называется эндпоинт]) .body(BodyInserters.fromObject([тело запроса, в моём случае, обычная строка])) .exchange() .expectStatus().isOk()

Этот простой тест сразу же не прошёл: сервер вернул 500 с таким же body, как и в случае, если в Postman не указать клиентский сертификат.

Эта мера была избыточной, но исключала половину вариантов наверняка. Отдельно отмечу, что на время отладки, я включила опцию «не проверять серверный сертификат», а именно — передала инстанс InsecureTrustManagerFactory в качестве TrustManager для SslContext.

Всё это можно посмотреть используя Wireshark — это такой популярный анализатор сетевого трафика. Отладочная информация в тесте не проливала свет на проблему, но выглядело всё, будто что-то пошло не так на этапе SSL handshake, поэтому я решила более подробно сравнить как происходит соединение в обоих случаях: для Postman и для Java клиента. Заодно увидела как происходит SSL handshake с двухсторонней аутентификацией, так сказать, вживую (это очень любят спрашивать на собеседованиях):

  • Перво-наперво клиент отправляет сообщение Client Hello, содержащее метаинформацию вроде версии протокола и списка алгоритмов шифрования, которые он поддерживает
  • В ответ сервер отправляет сразу пачку следующих сообщений: Server Hello, Certificate, Server Key Exchange, Certificate Request, Server Hello Done.
    В Server Hello указывается выбранный сервером алгоритм шифрования (cipher suite). Внутри Certificate лежит сертификат сервера. Server Key Exchange несёт в себе некоторую информацию, необходимую для шифрования, в зависимости от выбранного алгоритма (нас сейчас не интересуют детали, поэтому будем считать что это просто публичный ключ, хотя это некорректно!). Также, в случае двухсторонней аутентификации, в Certificate Request сообщении сервер делает запрос клиентского сертификата и поясняет, какие форматы он поддерживает и каким issuers доверяет.
  • Получив эту информацию, клиент проверяет сертификат сервера и отправляет свой сертификат, свой «публичный ключ», и другую информацию, в следующих сообщениях: Certificate, Client Key Exchange, Certificate Verify. Последним идёт ChangeCipherSpec сообщение, сигнализирующее о том, что всё дальнейшее общение будет происходить в зашифрованном виде
  • Наконец, после всех этих расшаркиваний, cервер проверят сертификат клиента и, если с ним всё в порядке, то отдаёт ответ.

После пятнадцати минут втыкания в траффик, я заметила, что Java клиент в ответ на Certificate Request от сервера, по какой-то причине не отправляет свой сертификат, в отличие от Postman клиента. То есть, Certificate message есть, но он пустой.

Дальше мне нужно было бы посмотреть сначала в спецификацию протокола TLS, которая говорит буквально следующее:

If the certificate_authorities list in the certificate request message was non-empty, one of the certificates in the certificate chain SHOULD be issued by one of the listed CAs.

Речь идёт о списке certificate_authorities, указанном в Certificate Request message, приходящем от сервера. Клиентский сертификат (хотя бы один из цепочки) должен быть подписан одним из issuers, перечисленных в этом списке. Назовём это проверкой X.

Netty HttpClient, который лежит в основе Spring WebClient, использует по умолчанию SslEngine из JDK. Я об этом условии не знала и обнаружила его, когда дошла в отладке до глубин JDK (у меня это JDK9). ClientHandshaker класса и в хэндлере для serverHelloDone сообщения обнаружилась проверка X, которая не проходила: ни один из issuer-ов в цепочке клиентского сертификата не находился в списке issuer-ов, которым доверяет сервер (из Certificate Request message от сервера). Альтернативно можно переключить его на OpenSSL провайдер, добавив необходимые зависимости, но мне это, в конечном счёте, не потребовалось.
Итак, я расставила брейкпоинты внутри sun.security.ssl.

Скрипт не делал ничего лишнего, кроме отправки HTTPS запроса с использованием Requests библиотеки, и возвращал 200 OK. Я обратилась к заказчику за новым сертификатом, но заказчик возразил, что у него всё работает отлично, и вручил Python скрипт, которым он обычно проверял работоспособность сертификатов. Сразу вспомнился анекдот: «Вся рота идет не в ногу, один поручик шагает в ногу». Окончательно я удивилась, когда старый добрый curl тоже вернул 200 OK.

Не зная, что ещё можно проверить, я полезла бесцельно бродить по документации к curl, и на Github, где обнаружила вот такой известный баг. Curl — это, конечно, авторитетная утилита, но и TLS стандарт тоже не кусок туалетной бумаги.

Я не поленилась, собрала curl из исходников с опцией --with-gnutls, и отправила многострадальный реквест. Репортер описывал точь-в-точь проверку X: в curl с дефолтным бекэндом (OpenSSL) она не выполнялась, в отличие от curl с GnuTLS бекендом. И, наконец-то, ещё один клиент, кроме JDK, вернул HTTP статус 500 вместе с «Secutiry exception»!

С ним моя конфигурация для WebClient заработала отлично. Я написала об этом заказчику в ответ на аргумент «Ну curl-же работает» и получила новый сертификат, заново сгенеренный и аккуратно установленный на сервере. Happy End.

Эпопея с настройкой SSL заняла со всеми переписками больше двух недель (туда же вошло изучение подробных логов Java, ковыряние кода другого проекта, который у заказчика работает, и просто ковыряние в носу).

Однако, и на это есть пояснения в спеке:
Что меня долго сбивало с толку, помимо разницы в поведении клиентов, так это то, что сервер был настроен таким образом, что сертификат запрашивал, но не проверял.

Also, if some aspect of the certificate chain was unacceptable (e.g., it was not signed by a known, trusted CA), the server MAY at its discretion either continue the handshake (considering the client unauthenticated) or send a fatal alert.

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть