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

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


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


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


Первое, что мне выдали в этом квесте, это 2 файла с расширением .pem — клиентский сертификат и приватный ключ. Я проверила их работоспособность с помощью Postman: указала пути к ним в настройках и, пульнув запрос, убедилась что сервер отвечает 200 OK и осмысленный response body. Отдельно проверила, что без клиентского сертификата сервер возвращает 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. Эта мера была избыточной, но исключала половину вариантов наверняка.

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

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

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

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

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

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

Комментарии (5)


  1. vladbarcelo
    23.06.2018 13:09

    Вы точно не ошиблись? Заказчик выдал вам приватный ключ от своего ssl-сертификата?


    1. edithwang Автор
      23.06.2018 13:25
      +1

      Не ошиблась. Аутентификация же двухстронняя)
      Не от своего ssl-сертификата, а от клиентского
      Для клиентского приложения сгенерировали пару: приватный ключ и сертификат (в составе которого, соответственно, публичный ключ), и выдали мне.
      Этот сертификат установили на сервере в списке доверенных. Приватный ключ, как водится, только на стороне клиентского приложения хранится


  1. vladbarcelo
    23.06.2018 15:03
    +1

    А, я понял, ключ для вашего приложения. Спасибо.


  1. more_cry
    25.06.2018 09:53
    +1

    Спасибо за статью, было приятно осознать, что кто-то сталкивался с такого же рода ошибкой ура, я не один. Правда, дело было в dotnet, но суть точно такая же, как ни странно. При чем тем же курлом оправдывались – работает же!


    1. edithwang Автор
      25.06.2018 09:59

      Взаимно)
      А вы каким образом докопались до root cause?