Привет!
Недавно мне пришлось прикручивать 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.
Автор: edithwang