Java: HTTPS requests with certificates using HttpClient (two-way authentication)
The problem is that the server requires a certificate (two-way authentication). I was provided with two files: client.cert.pem and client.key.pem , from which I obtained keystore.jks and keystore.p12 using keytool and openssl.
I use curl I am successfully getting data from the server
curl --cert client.cert.pem --key client.key.pem .
But in the code I can’t pass the certificate correctly.
By studying many examples on the Internet I came up with this code:
final char[] JKS_PASSWORD = "password".toCharArray(); SSLContext sslContext = SSLContexts.custom().loadTrustMaterial( new File("ssl/keystore.jks"), JKS_PASSWORD, new TrustSelfSignedStrategy()).build(); HttpClient httpsClient = HttpClients.custom().setSSLContext(sslContext).build(); HttpResponse rResponse = httpsClient.execute(new HttpGet(MY_URL));
In this case y I get an exception:
java.lang.NoSuchMethodError: org.apache.http.impl.client.HttpClientBuilder.setSSLContext(Ljavax/net/ssl/SSLContext;)Lorg/apache/http/impl/client/HttpClientBuilder;
If you use SSLConnectionSocketFactory :
. SSLConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory( sslContext, new String[] < "TLSv1", "TLSv1.1", "TLSv1.2" >, null, SSLConnectionSocketFactory.getDefaultHostnameVerifier()); HttpClient httpsClient = HttpClients.custom().setSSLSocketFactory(sslSocketFactory).build(); HttpResponse rResponse = httpsClient.execute(new HttpGet(MY_URL));
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
I obviously messed up something, but I can’t figure out what. Any help is welcome!
От HTTP до HTTPS
В современном мире без веб-приложений никак. И начнём мы с небольшого эксперимента. В детстве я помню, как во всех ларьках продавалась такая газета, как «Аргументы и факты». Вспомнил я о них потому, что по моему личному восприятию из детства, эти газеты выглядели всегда странно. И решил, а не зайти ли нам на их сайт:
Если перейти в справку Google Chrome, то мы прочитаем, что данный сайт не использует защищённое соединение и информация, которой вы обмениваетесь с сайтом, может быть доступна посторонним. Давайте проверим какие-нибудь другие новости, например новости Санкт-Петербурга от «Фонтанки», электронного СМИ:
Как видно, у сайта Фонтанки с безопасностью по этим данным проблем нет. Получается, что веб-ресурсы могут быть безопасными, а могут и не быть. Так же видим, что обращение к не защищённым ресурсам происходит по протоколу HTTP. А если ресурс защищён, то обмен данными осуществляется по протоколу HTTPS, где S на конце обозначает «Secure». Протокол HTTPS описан в спецификации rfc2818: «HTTP Over TLS». Давайте попробуем создать своё веб-приложение и сами увидеть, как это работает. И попутно будем разбираясь в терминах.
Веб-приложение на Java
Итак, нам нужно создать самое простое веб-приложение на Java. Для начала, нам нужно само приложение на Java. Для этого воспользуемся системой автоматической сборки проекта Gradle. Это нам позволит не создавать вручную нужную структуру каталогов + Gradle за нас будет управлять всеми необходимыми для проекта библиотеками и обеспечивать, чтобы они были доступны при выполнении кода. Подробнее про Gradle можно прочитать в небольшом обзоре: «Краткое знакомство с Gradle». Воспользуемся Gradle Init Plugin’ом и выполним команду:
gradle init --type java-application
После этого откроем билд скрипт build.gradle , в котором описано, из каких библиотек состоит наш проект, которые Gradle нам предоставит. Добавим туда зависимость от веб-сервера, на котором мы будем экспериментировать:
Чтобы веб-приложение работало, нам обязательно нужен веб-сервер, где будет размещено наше приложение. Веб серверов существует огромное множество, но основные это: Tomcat, Jetty, Undertow. Мы с Вами выберем на этот раз Undertow. Чтобы понять, как нам работать с этим нашим веб-сервером перейдём на официальный сайт Undertow и перейдём в раздел документации. Мы с Вами подключили зависимость от Undertow Core, поэтому нам интересует раздел про этот самый Core, то есть ядро, основу веб-сервера. Самым простым способом является использование Builder API для Undertow:
public static void main(String[] args) < Undertow server = Undertow.builder() .addHttpListener(8080, "localhost") .setHandler(new HttpHandler() < @Override public void handleRequest(final HttpServerExchange exchange) throws Exception < exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain"); exchange.getResponseSender().send("Hello World"); >>).build(); server.start(); >
Работает это просто. Благодаря Undertow Builder API мы добавляем HTTP слушателя на адрес localhost и порт 8080. Этот слушатель получает запросы от веб-браузера и возвращает в ответ строку «Hello World». Отличное веб-приложение. Но как мы видим, мы используем протокол HTTP, т.е. такой обмен данными небезопасен. Давайте разбираться, как же осуществляют обмен по протоколу HTTPS.
Требования для HTTPS
Но чтобы его добавить нам нужен SSLContext. Интересно, что SSLContext — это класс не из Undertow, а javax.net.ssl.SSLContext . Класс SSLContext входит в так называемый «Java Secure Socket Extension» (JSSE) — расширение Java для обеспечения безопасности интернет соединения. Данное расширение описано в документе «Java Secure Socket Extension (JSSE) Reference Guide». Как видно из вступительной части документации, JSSE предоставляет фрэймворк и Java реализацию протоколов SSL и TLS. Как же нам получить SSLContext? Открываем JavaDoc SSLContext и находим метод getInstance. Как видно, для получения SSLContext нам нужно указать название «Secure Socket Protocol». В описании параметров дано указание, что эти названия можно посмотреть в «Java Cryptography Architecture Standard Algorithm Name Documentation». Поэтому, последуем указанию и идём в документацию. И видим, что мы можем выбрать между SSL и TLS:
public SSLContext getSSLContext() < // 1. Получаем контекст, в рамках которого будем работать по TLS протоколу SSLContext context = null; try < context = SSLContext.getInstance("TLS"); >catch (NoSuchAlgorithmException e) < throw new IllegalStateException(e); >return context; >
Создав новый контекст вспоминаем, что SSLContext описывался в «Java Secure Socket Extension (JSSE) Reference Guide». Читаем и видим, что «A newly created SSLContext should be initialized by calling the init method». То есть создать контекст — мало. Его нужно инициализировать. И это логично, т.к. про безопасность мы рассказали только то, что мы хотим использовать протокол TLS. Чтобы инициализировать SSLContext нам нужно предоставить три вещи: KeyManager, TrustManager, SecureRandom.
KeyManager
KeyManager — это менеджер ключей. Он отвечает за то, какой «authentication credential» предоставить тому, кто к нам обратиться. Credential можно перевести, как удостоверение. Удостоверение нужно, чтобы клиент был уверен что сервер тот, за кого себя выдаёт и ему можно доверять. Что будет использовано в качестве удостоверения? Как мы помним, Server Identity проверяется по цифровому сертификату сервера. Этот процесс можно представить следующим образом:
- alias: Псевдоним или просто имя, под которым будет сохранена запись в Keystore keyalg: Алгоритм шифрования ключей. Выберем алгоритм RSA, который является по сути стандартным решением для нашей цели.
- keysize: Размер ключа (в битах). Минимальный рекомендуемый размер 2048, т.к. размер меньше уже взламывался. Подробнее можно прочитать здесь: «a ssl certificate in 2048 bit».
- dname: Distinguished Name, отличительное имя.
- validity: Продолжительность в днях, в течении которых генерируемый сертификат валиден, т.е. действителен.
- ext: Certificate Extension, указанные в «Named Extensions».
- -ext san:critical=dns:localhost,ip:127.0.0.1 > для выполнения subject matching по SubjectAlternativeName
- -ext bc=ca:false > чтобы указать, что данный сертификат не используется для подписи других сертификатов
keytool -genkeypair -alias ssl -keyalg RSA -keysize 2048 -dname "CN=localhost,OU=IT,O=Javarush,L=SaintPetersburg,C=RU,email=contact@email.com" -validity 90 -keystore C:/keystore.jks -storepass passw0rd -keypass passw0rd -ext san:critical=dns:localhost,ip:127.0.0.1 -ext bc=ca:false
Т.к. будет создан файл убедитесь, что у Вас есть все права на создание файла. Кроме того, скорей всего, Вы увидите совет вроде этого:
Тут нам говорят, что JKS — проприетарный формат. Проприетарный — значит является частной собственностью авторов и предназначен для использования только в Java. При работе со сторонними утилитами может возникнуть конфликт, поэтому нас и предупреждают. Кроме того, мы можем получить ошибку: The destination pkcs12 keystore has different storepass and keypass . Эта ошибка возникает из-за того, что используются разные пароли от записи в Keystore и от самого keystore. Как сказано в документации к keytool, «For example, most third-party tools require storepass and keypass in a PKCS #12 keystore to be the same». Мы можем указать сами ключ (например, -destkeypass entrypassw). Но лучше не нарушать требований и задать одинаковый пароль. Итак, импорт может выглядеть следующим образом:
keytool -importkeystore -srckeystore C:/keystore.jks -destkeystore C:/keystore.jks -deststoretype pkcs12
keytool -export -alias ssl -storepass passw0rd -file C:/server.cer -keystore C:/keystore.jks
keytool -list -v -keystore C:/keystore.jks -storepass passw0rd
public KeyStore getKeyStore() < // Согласно https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#KeyStore try(FileInputStream fis = new FileInputStream("C:/keystore.jks"))< KeyStore keyStore = KeyStore.getInstance("pkcs12"); keyStore.load(fis, "passw0rd".toCharArray()); return keyStore; >catch (IOException ioe) < throw new IllegalStateException(ioe); >catch (KeyStoreException | NoSuchAlgorithmException | CertificateException e) < throw new IllegalStateException(e); >>
public KeyManager[] getKeyManagers(KeyStore keyStore) < String keyManagerAlgo = KeyManagerFactory.getDefaultAlgorithm(); KeyManagerFactory keyManagerFactory = null; try < keyManagerFactory = KeyManagerFactory.getInstance(keyManagerAlgo); keyManagerFactory.init(keyStore, "passw0rd".toCharArray()); return keyManagerFactory.getKeyManagers(); >catch (NoSuchAlgorithmException e) < throw new IllegalStateException(e); >catch (UnrecoverableKeyException | KeyStoreException e) < throw new IllegalStateException(e); >>
Наша первая цель достигнута. Осталось разобраться, что такое TrustManager. TrustManager описан в документации JSSE в разделе «The TrustManager Interface». Он очень похож на KeyManager, но его цель проверить, можно ли доверять тому, кто запрашивает соединение. Если совсем грубо, то это KeyManager наоборот =) У нас нет необходимости в TrustManager’е, поэтому передадим null. Тогда будет создан TrustManager по умолчанию, не проверяющий конечного пользователя, который выполняет запросы на наш сервер. В документации так и сказано: «default implementation will be used». Аналогично с SecureRandom. Если мы укажем null, то будет использована реализация по умолчанию. Вспомним только, что SecureRandom — это класс, относящийся к JCA и описанный в документации JCA в разделе «The SecureRandom Class». Итого, подготовка с учётом всех вышеописанных методов может выглядеть следующим образом:
public static void main(String[] args) < // 1. Подготавливаем приложение к работе по HTTPS App app = new App(); SSLContext sslContext = app.getSSLContext(); KeyStore keyStore = app.getKeyStore(); KeyManager[] keyManagers = app.getKeyManagers(keyStore); try < sslContext.init(keyManagers, null, null); >catch (KeyManagementException e)
// 2. Поднимаем сервер int httpsPort = 443; Undertow server = Undertow.builder() .addHttpsListener(httpsPort, "localhost", sslContext) .setHandler(new HttpHandler() < @Override public void handleRequest(final HttpServerExchange exchange) throws Exception < exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain"); exchange.getResponseSender().send("Hello World"); >>).build(); server.start(); >
На этот раз наш сервер будет доступен по адресу https://localhost:443 Однако, мы по прежнему получим ошибку, что нельзя доверять данному ресурсу:
Управление сертификатами
Причина в том, что данный сертификат является самоподписанным (Self-signed Certificate). Под самоподписанным SSL сертификатом понимают сертификат открытого ключа, изданный и подписанный тем же лицом, которое он идентифицирует. То есть его не выдавал никакой уважаемый центр сертификации (CA, он же Certificate Authority). Центр сертификации (Certificate Authority) выступает как доверенное лицо и похож на нотариуса в обычной жизни. Он заверяет, что выданные им сертификаты надёжны. Услуга выдачи сертификатов такими CA является платной, поэтому утеря доверия и репутационные риски никому не нужны. По умолчанию есть несколько центров сертификации, которым доверяют. Этот список доступен для редактирования. И управление списком центров сертификации в каждой операционной системе свой. Например, управление данным списком в Windows можно прочитать здесь: «Manage Trusted Root Certificates in Windows». Давайте добавим сертификат в доверенные, как указано в сообщении об ошибке. Для этого, сначала, скачаем сертификат:
В OS Windows нажмём Win+R и выполним mmc для вызова консоли управления. Далее нажмём Ctrl+M для добавления раздела «Сертификаты» в текущую консоль. Далее в подразделе «Доверенные корневые центры сертификации» выполним Действия / Все задачи / Импорт . Выполним импорт файла, скачанного ранее в файл. Браузер мог запомнить прошлое состояние доверия к сертификату. Поэтому, перед открытием страницы нужно выполнить рестарт браузера. Например, в Google Chrome в адресной строке необходимо выполнить chrome://restart . В OS Windows для просмотра сертификатов так же можно воспользоваться утилитой certmgr.msc :