Перейти к основному содержимому

Ошибки рукопожатия SSL

· 10 мин. чтения

1. Обзор

Secured Socket Layer (SSL) — это криптографический протокол, который обеспечивает безопасность при обмене данными по сети. В этом руководстве мы обсудим различные сценарии, которые могут привести к сбою рукопожатия SSL, и способы его устранения.

Обратите внимание, что наше введение в SSL с использованием JSSE более подробно описывает основы SSL.

2. Терминология

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

С момента появления SSL многие продукты и языки, такие как OpenSSL и Java, имели ссылки на SSL, которые они сохраняли даже после того, как TLS вступил во владение. По этой причине в оставшейся части этого руководства мы будем использовать термин SSL для общего обозначения криптографических протоколов.

3. Настройка

Для целей этого руководства мы создадим простые серверные и клиентские приложения, используя Java Socket API для имитации сетевого подключения.

3.1. Создание клиента и сервера

В Java мы можем использовать сокеты для установления канала связи между сервером и клиентом по сети . Сокеты являются частью Java Secure Socket Extension (JSSE) в Java.

Начнем с определения простого сервера:

int port = 8443;
ServerSocketFactory factory = SSLServerSocketFactory.getDefault();
try (ServerSocket listener = factory.createServerSocket(port)) {
SSLServerSocket sslListener = (SSLServerSocket) listener;
sslListener.setNeedClientAuth(true);
sslListener.setEnabledCipherSuites(
new String[] { "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256" });
sslListener.setEnabledProtocols(
new String[] { "TLSv1.2" });
while (true) {
try (Socket socket = sslListener.accept()) {
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
out.println("Hello World!");
}
}
}

Определенный выше сервер возвращает сообщение «Hello World!» к подключенному клиенту.

Далее давайте определим базового клиента, который мы подключим к нашему SimpleServer:

String host = "localhost";
int port = 8443;
SocketFactory factory = SSLSocketFactory.getDefault();
try (Socket connection = factory.createSocket(host, port)) {
((SSLSocket) connection).setEnabledCipherSuites(
new String[] { "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256" });
((SSLSocket) connection).setEnabledProtocols(
new String[] { "TLSv1.2" });

SSLParameters sslParams = new SSLParameters();
sslParams.setEndpointIdentificationAlgorithm("HTTPS");
((SSLSocket) connection).setSSLParameters(sslParams);

BufferedReader input = new BufferedReader(
new InputStreamReader(connection.getInputStream()));
return input.readLine();
}

Наш клиент печатает сообщение, возвращенное сервером.

3.2. Создание сертификатов в Java

SSL обеспечивает секретность, целостность и подлинность сетевых коммуникаций. Сертификаты играют важную роль в установлении подлинности.

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

Для этого мы можем использовать keytool, который поставляется с JDK:

$ keytool -genkey -keypass password \
-storepass password \
-keystore serverkeystore.jks

Приведенная выше команда запускает интерактивную оболочку для сбора информации о сертификате, такой как общее имя (CN) и отличительное имя (DN). Когда мы предоставляем все необходимые данные, он генерирует файл serverkeystore.jks , который содержит закрытый ключ сервера и его открытый сертификат.

Обратите внимание, что файл serverkeystore.jks хранится в формате хранилища ключей Java (JKS), который является собственностью Java. В наши дни keytool напомнит нам, что мы должны рассмотреть возможность использования PKCS # 12, который он также поддерживает.

Далее мы можем использовать keytool для извлечения общедоступного сертификата из сгенерированного файла хранилища ключей:

$ keytool -export -storepass password \
-file server.cer \
-keystore serverkeystore.jks

Приведенная выше команда экспортирует общедоступный сертификат из хранилища ключей в виде файла server.cer . Давайте используем экспортированный сертификат для клиента, добавив его в его хранилище доверенных сертификатов:

$ keytool -import -v -trustcacerts \
-file server.cer \
-keypass password \
-storepass password \
-keystore clienttruststore.jks

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

Более подробную информацию об использовании хранилища ключей Java можно найти в нашем предыдущем руководстве .

4. SSL-рукопожатие

Рукопожатия SSL — это механизм, с помощью которого клиент и сервер устанавливают доверительные отношения и логистику, необходимые для защиты их соединения по сети .

Это очень организованная процедура, и понимание ее деталей может помочь понять, почему она часто терпит неудачу, о чем мы собираемся рассказать в следующем разделе.

Типичные шаги рукопожатия SSL:

  1. Клиент предоставляет список возможных версий SSL и наборов шифров для использования.
  2. Сервер соглашается с конкретной версией SSL и набором шифров, отвечая своим сертификатом.
  3. Клиент извлекает открытый ключ из сертификата и отвечает зашифрованным «предмастер-ключом».
  4. Сервер расшифровывает «пре-мастер-ключ» с помощью своего закрытого ключа.
  5. Клиент и сервер вычисляют «общий секрет», используя обменный «премастер-ключ».
  6. Клиент и сервер обмениваются сообщениями, подтверждающими успешное шифрование и дешифрование с использованием «общего секрета».

Хотя большинство шагов одинаковы для любого рукопожатия SSL, существует тонкая разница между односторонним и двусторонним SSL. Давайте быстро рассмотрим эти различия.

4.1. Рукопожатие в одностороннем SSL

Если мы обратимся к шагам, упомянутым выше, во втором шаге упоминается обмен сертификатами. Односторонний SSL требует, чтобы клиент мог доверять серверу через свой общедоступный сертификат. Это оставляет сервер доверять всем клиентам , которые запрашивают соединение. Сервер не может запрашивать и проверять общедоступный сертификат у клиентов, что может представлять угрозу безопасности.

4.2. Рукопожатие в двустороннем SSL

При использовании одностороннего SSL сервер должен доверять всем клиентам. Но двусторонний SSL позволяет серверу также устанавливать доверенных клиентов. Во время двустороннего рукопожатия и клиент, и сервер должны предоставить и принять общедоступные сертификаты друг друга, прежде чем можно будет установить успешное соединение.

5. Сценарии неудачного рукопожатия

Сделав этот краткий обзор, мы можем с большей ясностью рассмотреть сценарии отказа.

Квитирование SSL при односторонней или двусторонней связи может завершиться неудачно по нескольким причинам. Мы рассмотрим каждую из этих причин, смоделируем сбой и поймем, как избежать таких сценариев.

В каждом из этих сценариев мы будем использовать созданные ранее SimpleClient и SimpleServer .

5.1. Отсутствует сертификат сервера

Попробуем запустить SimpleServer и подключить его через SimpleClient . Хотя мы ожидаем увидеть сообщение «Hello World!», нам представлено исключение:

Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
Received fatal alert: handshake_failure

Теперь это указывает на то, что что-то пошло не так. Приведенное выше исключение SSLHandshakeException в абстрактной форме указывает, что клиент при подключении к серверу не получил никакого сертификата.

Чтобы решить эту проблему, мы будем использовать хранилище ключей, которое мы сгенерировали ранее, передав их серверу в качестве системных свойств:

-Djavax.net.ssl.keyStore=clientkeystore.jks -Djavax.net.ssl.keyStorePassword=password

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

Поможет ли это нам получить ожидаемый результат? Давайте узнаем в следующем подразделе.

5.2. Сертификат недоверенного сервера

Когда мы снова запускаем SimpleServer и SimpleClient с изменениями в предыдущем подразделе, что мы получаем на выходе:

Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
sun.security.validator.ValidatorException:
PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target

Что ж, это сработало не совсем так, как мы ожидали, но похоже, что это не удалось по другой причине.

Этот конкретный сбой вызван тем, что наш сервер использует самозаверяющий сертификат, который не подписан центром сертификации (CA).

Действительно, каждый раз, когда сертификат подписывается чем-то другим, кроме того, что находится в хранилище доверенных сертификатов по умолчанию, мы увидим эту ошибку. Хранилище доверенных сертификатов по умолчанию в JDK обычно поставляется с информацией об используемых ЦС.

Чтобы решить эту проблему здесь, нам придется заставить SimpleClient доверять сертификату, представленному SimpleServer . Давайте воспользуемся хранилищем доверенных сертификатов, которое мы сгенерировали ранее, передав их клиенту в качестве системных свойств:

-Djavax.net.ssl.trustStore=clienttruststore.jks -Djavax.net.ssl.trustStorePassword=password

Обратите внимание, что это не идеальное решение. В идеальном сценарии мы должны использовать не самозаверяющий сертификат, а сертификат, сертифицированный центром сертификации (ЦС), которому клиенты могут доверять по умолчанию.

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

5.3. Отсутствует сертификат клиента

Попробуем еще раз запустить SimpleServer и SimpleClient, применив изменения из предыдущих подразделов:

Exception in thread "main" java.net.SocketException: 
Software caused connection abort: recv failed

Опять же, не то, что мы ожидали. SocketException здесь говорит нам, что сервер не может доверять клиенту. Это потому, что мы настроили двусторонний SSL. В нашем SimpleServer у нас есть:

((SSLServerSocket) listener).setNeedClientAuth(true);

Приведенный выше код указывает, что SSLServerSocket требуется для аутентификации клиента через его общедоступный сертификат.

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

Мы перезапустим сервер и передадим ему следующие системные свойства:

-Djavax.net.ssl.keyStore=serverkeystore.jks \
-Djavax.net.ssl.keyStorePassword=password \
-Djavax.net.ssl.trustStore=servertruststore.jks \
-Djavax.net.ssl.trustStorePassword=password

Затем мы перезапустим клиент, передав эти системные свойства:

-Djavax.net.ssl.keyStore=clientkeystore.jks \
-Djavax.net.ssl.keyStorePassword=password \
-Djavax.net.ssl.trustStore=clienttruststore.jks \
-Djavax.net.ssl.trustStorePassword=password

Наконец, у нас есть желаемый результат:

Hello World!

5.4. Неверные сертификаты

Помимо вышеуказанных ошибок, рукопожатие может завершиться неудачей по разным причинам, связанным с тем, как мы создали сертификаты. Одна распространенная ошибка связана с неправильным CN. Давайте изучим детали хранилища ключей сервера, которое мы создали ранее:

keytool -v -list -keystore serverkeystore.jks

Когда мы запускаем приведенную выше команду, мы можем увидеть детали хранилища ключей, в частности владельца:

...
Owner: CN=localhost, OU=technology, O=foreach, L=city, ST=state, C=xx
...

CN владельца этого сертификата имеет значение localhost. CN владельца должен точно совпадать с хостом сервера. Если есть какое-либо несоответствие, это приведет к исключению SSLHandshakeException .

Давайте попробуем перегенерировать сертификат сервера с CN как что угодно, кроме localhost. Когда мы сейчас используем регенерированный сертификат для запуска SimpleServer и SimpleClient , он быстро дает сбой:

Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
java.security.cert.CertificateException:
No name matching localhost found

Приведенная выше трассировка исключения ясно указывает на то, что клиент ожидал получить сертификат с именем localhost, который он не нашел.

Обратите внимание, что JSSE по умолчанию не требует проверки имени хоста. Мы включили проверку имени хоста в SimpleClient посредством явного использования HTTPS:

SSLParameters sslParams = new SSLParameters();
sslParams.setEndpointIdentificationAlgorithm("HTTPS");
((SSLSocket) connection).setSSLParameters(sslParams);

Проверка имени хоста является распространенной причиной сбоя и, как правило, всегда должна применяться для повышения безопасности. Подробную информацию о проверке имени хоста и ее важности для безопасности с помощью TLS см. в этой статье .

5.5. Несовместимая версия SSL

В настоящее время существуют различные криптографические протоколы, включая различные версии SSL и TLS.

Как упоминалось ранее, SSL в целом был вытеснен TLS из-за его криптографической стойкости. Криптографический протокол и версия — это дополнительный элемент, который клиент и сервер должны согласовать во время рукопожатия.

Например, если сервер использует криптографический протокол SSL3, а клиент использует TLS1.3, они не могут согласовать криптографический протокол, и будет сгенерировано исключение SSLHandshakeException .

В нашем SimpleClient давайте изменим протокол на что-то, что не совместимо с протоколом, установленным для сервера:

((SSLSocket) connection).setEnabledProtocols(new String[] { "TLSv1.1" });

Когда мы снова запустим наш клиент, мы получим SSLHandshakeException :

Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
No appropriate protocol (protocol is disabled or cipher suites are inappropriate)

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

5.6. Несовместимый набор шифров

Клиент и сервер также должны согласовать набор шифров, который они будут использовать для шифрования сообщений.

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

В нашем SimpleClient давайте изменим набор шифров на что-то, что несовместимо с набором шифров, используемым нашим сервером:

((SSLSocket) connection).setEnabledCipherSuites(
new String[] { "TLS_RSA_WITH_AES_128_GCM_SHA256" });

Когда мы перезапустим наш клиент, мы получим SSLHandshakeException :

Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
Received fatal alert: handshake_failure

Опять же, трассировка исключения довольно абстрактна и не говорит нам о конкретной проблеме. Решением такой ошибки является проверка включенных наборов шифров, используемых как клиентом, так и сервером, и обеспечение наличия по крайней мере одного общего набора.

Обычно клиенты и серверы настроены на использование широкого спектра наборов шифров, поэтому вероятность возникновения этой ошибки меньше. Если мы сталкиваемся с этой ошибкой, обычно это происходит потому, что сервер настроен на использование очень избирательного шифра. Сервер может выбрать применение выборочного набора шифров из соображений безопасности.

6. Заключение

В этом руководстве мы узнали о настройке SSL с использованием сокетов Java. Затем мы обсудили рукопожатия SSL с односторонним и двусторонним SSL. Наконец, мы рассмотрели список возможных причин, по которым рукопожатия SSL могут не работать, и обсудили решения.

Как всегда, код примеров доступен на GitHub .