Введение во взаимную аутентификацию сервисов на Java c TLS/SSL

от автора

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

Введение

Если говорить о формате настройки сертификатов для безопасной передачи данных. Как правило, данные действия производят на каком-либо веб-сервере типа nginx или apache, стоящем на входе во внутреннюю сеть компании и dmz. Благодаря ему можно разделить защищенную внутреннюю сеть и внешнюю сеть интернет. Далее, внутри доверенной сети каждый поступает по-разному. Кто-то считает, что все внутренние сервисы могут взаимодействовать друг с другом без каких-то ограничений, и контроль пользователей управляется уже в GUI посредством логина и пароля для конкретного приложения с разграничением ролей в рамках приложения. Кто-то идет дальше, подключая LDAP и используя логин, пароль пользователя из общего хранилища.

Существуют различные протоколы и технологии типа RADIUS, Kerberos или OAuth/OpenID для работы с вопросами аутентификации. Кто-то использует схемы с базовой аунтефикацией, передавая логин и пароль в base64, кто-то использует JsonWebToken, еще существует возможность использования сертификатов для проверки не только сервера и клиента. В результате получается ситуация, что мы формируем защищенное соединение клиента и сервера, в котором шифруем передаваемые данные и доверяем не только серверу, с которого эти данные забираем, но и знаем о том, кто именно забирает эти данные с нашего сервера, так как он предоставляет клиентский сертификат.

В рамках моей работы в ТехЦентре Дойче Банка мы в обязательном порядке для всех межсервисных взаимодействий используем SSL-сертификаты — даже в UAT окружении. В Java используем JKS, как более привычный контейнер сертификатов и паролей для этой системы.

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

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

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

  • SSL (англ. Secure Sockets Layer — уровень защищённых сокетов) — криптографический протокол, использующий асимметричную криптографию для аутентификации ключей обмена, симметричное шифрование для сохранения конфиденциальности, коды аутентификации сообщений для целостности сообщений. Третья версия протокола описана в рабочем предложении RFC-6101. В последующем в SSL была обнаружена уязвимость CVE-2014-3566 в связи на базе. В третьей версии протокола было разработано новое рабочее предложение RFC-5246 протокола, получившего название TLS.
  • TLS (англ. Transport Layer Security — Протокол защиты транспортного уровня) — криптографический протокол, развивающие идеи SSLv3 и закрывающий имеющиеся там уязвимости.

Формат сертификатов

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

Помимо команды, позволяющей создать ключи вместе с keystore, которая выглядит следующим образом:

keytool -genkey -alias example.com -keyalg RSA -keystore keystore.jks  -keysize 2048

Есть ряд других команд под катом, которые могут быть полезны в работе с JKS и просто с ключами и сертификатами в Java

Примеры полезных команды утилиты keytool

  • Создание запроса сертификата (CSR) для существующего Java keystore
    keytool -certreq -alias example.com -keystore keystore.jks -file example.com.csr
  • Загрузка корневого или промежуточного CA сертификата
    keytool -import -trustcacerts -alias root -file Thawte.crt -keystore keystore.jks
  • Импорт доверенного сертификата
    keytool -import -trustcacerts -alias example.com -file example.com.crt -keystore keystore.jks
  • Генерация сапоподписанного сертификата и keystore
    keytool -genkey -keyalg RSA -alias selfsigned -keystore keystore.jks -storepass password -validity 360 -keysize 2048
  • Просмотр сертификата
    keytool -printcert -v -file example.com.crt
  • Проверка списка сертификатов в keystore
    keytool -list -v -keystore keystore.jks
  • Проверка конкретного сертификата по алиасу в keystore
    keytool -list -v -keystore keystore.jks -alias example.com
  • Удаление сертификата из keystore
    keytool -delete -alias example.com -keystore keystore.jks
  • Изменение пароля для keystore
    keytool -storepasswd -new new_storepass -keystore keystore.jks
  • Экспорт сертификата из keystore
    keytool -export -alias example.com -file example.com.crt -keystore keystore.jks
  • Список доверенный корневых сертификатов
    keytool -list -v -keystore $JAVA_HOME/jre/lib/security/cacerts
  • Добавление нового корневого сертификата в trustStore
    keytool -import -trustcacerts -file /path/to/ca/ca.pem -alias CA_ALIAS -keystore $JAVA_HOME/jre/lib/security/cacerts

KeyStore & TrustStore

Говоря о JKS, стоит отметить, что данные файлы могут использоваться как KeyStore так и TrustStore. Это два различных типа хранилищ, которые находятся в JKS файлах. Одно из них (KeyStore) содержит более чувствительную информацию типа приватного ключа, и поэтому требует пароля для доступа к этой информации. В противовес чему TrustStore хранит информацию о доверенных сертификатах, которые конечно же присутствуют в операционной системе. Например, для Linux систем мы сможем их найти в /usr/local/share/ca-certificates/

Но так же эти сертификаты идут в поставке Java в файле cacerts, который по умолчанию расположен в директории java.home\lib\security и имеет пароль по умолчанию changeit.

Данная информация может быть полезна в тех случаях, когда установка JDK/JRE осуществляется в компании централизовано из одного источника, и имеется возможность добавления туда своих доверенных сертификатов компании для prod/uat окружения.

Ниже приведена таблица с некоторыми различиями KeyStore & TrustStore.

Keystore TrustStore
Храняться ваши приватные ключи и сертификаты (клиентские или серверные) Храняться доверенные сертификаты (корневые самоподписанные CA root)
Необходим для настойки SSL на сервере Необходим для успешного подключения к серверу на клиентской стороне
Клиент будет хранить свой приватный ключ и сертификат в keystore Сервер будет валидировать клиента при двусторонней аутентификации на основании сертификатов в trustStore
javax.net.ssl.keyStore используется для работы с keystore javax.net.ssl.trustStore используется для работы с trustStore

Подключение SSL к NettyServer

При создании нового проекта, подразумевающего взаимодействие клиента и сервера бинарными данными(protobuf) через защищенные вебсокеты (wss), возник вопрос подключения SSL в netty сервер. Как оказалось, это не представляет особых проблем, достаточно в билдере сетевого интерфейса добавить метод .withSslContext(), в который необходимо передать созданный контекст.

NettyServer.builder()         .addHttpListener(         NetworkInterfaceBuilder.forPort(serviceUri.getPort())             .withSslContext(sslContextFactory.getSslContext()),         PathHandler.path()             .addExactPath(serviceUri.getPath(), createServiceHandler()     )     .build();

Для того что бы сформировать серверный и клиентский SSL-контекст можно использовать один единственный билдер — SslContextBuilder и его методы — forServer и forClient. У этого билдера надо заполнить ряд обязательных полей, такие как trustManager и ketManager. Эти менеджеры мы можем получить из соответствующий фабрик — TrustManagerFactory и KeyManagerFactory.

 SslContextBuilder.forServer(getKeyManagerFactory())         .trustManager(getTrustManagerFactory())         .build();

Синтаксис данных фабрик практически аналогичен с разницей в том, что для trustManager-а мы используем только пароль для самого jks файла,

private TrustManagerFactory getTrustManagerFactory() throws Exception {     final TrustManagerFactory tmFactory = TrustManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());     final KeyStore keyStore = KeyStore.getInstance("JKS");     final InputStream trustStoreFile = getTrustStoreFile();     keyStore.load(trustStoreFile, trustStorePassword.toCharArray());     tmFactory.init(keyStore);     return tmFactory; }

а для KeyStore при инициализации нам необходимо дополнительно передать пароль от самого ключа.

private KeyManagerFactory getKeyManagerFactory() throws Exception {     final KeyManagerFactory kmFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());     final KeyStore keyStore = KeyStore.getInstance("JKS");     final InputStream keyStoreFile = getKeyStoreFile();     keyStore.load(keyStoreFile, keyStorePassword.toCharArray());     kmFactory.init(keyStore, keyPassword.toCharArray());     return kmFactory; }

И в целом это все, что необходимо для добавления SSL в NettyServer.

Подключение SSL в gRPC / RSocket

Если говорить о двунаправленном обмене бинарными данными, современных тенденциях к написанию реактивных приложений, стоит отметить gRPC и RSocket для создания подобных приложений. Но поскольку в основании этих протоколов можно использовать Netty как транспорт, логика конфигурирования останется. Поэтому я не буду уделять этому много внимания.

Подключение SSL в Spring Boot для RestController

Но в мире Java разработки Spring стал де факто стандартом для DI. А вместе с внедрением зависимости люди используют и другие технологии, которые удобно собрать в одном Spring Boot приложении, не расходуя множество времени на конфигурирование всего зоопарка технологий. Конечно же, это приводит к избыточности зависимостей и повышении скорости загрузки, но упрощает разработку. Куда проще написать аннотацию RestController, чем самому разбираться с тем, как корректно хендлить запросы через сервлеты. А для того, чтобы перенаправить всё взаимодействие через сервлеты в защищённый канал с использованием сертификатов, в Spring Boot есть два пути проcтой и более сложный для кастомных решений.
В первом случае достаточно воспользоваться набором пропертей

server.ssl.key-store-type=JKS server.ssl.key-store=classpath:cert.jks server.ssl.key-store-password=changeit server.ssl.key-alias=key trust.store=classpath:cert.jks trust.store.password=changeit

И все будет сделано за вас. Либо, если требуется более кастомная конфигурация, поднятие коннекторов на разных портах с нестандартными настройками и так далее, тогда путь лежит в сторону использования интерфейса WebServerFactoryCustomizer, имплементации которого существуют для всех основных контейнеров будь то Jetty, Tomcat или Undertow.

Поскольку это функциональный интерфейс, его довольно просто можно описать через lambda c параметром типа Connector. Для него мы можем выставить флаг setSecure(true), затем заполнить необходимые параметры для ProtocolHandler-а, выставив ему пути до jks c keystore и trustStore и соответствующие пароли к ним. Например, для tomcat код будет выглядеть подобным образом:

@Bean WebServerFactoryCustomizer<ConfigurableWebServerFactory> containerCustomizer() throws Exception {     TomcatConnectorCustomizer customizer;     String absoluteKeystoreFile = keystoreFile.getAbsolutePath();     String absoluteTruststoreFile = truststoreFile.getAbsolutePath();     boolean sslEnabled = checkSslSettings(absoluteKeystoreFile, absoluteTruststoreFile, keystorePass);     if (sslEnabled) {         customizer = (connector) -> {             connector.setPort(port);             connector.setSecure(true);             connector.setScheme("https");             Http11NioProtocol proto = (Http11NioProtocol) connector.getProtocolHandler();             proto.setSSLEnabled(true);             proto.setClientAuth(clientAuth);             proto.setKeystoreFile(absoluteKeystoreFile);             proto.setKeystorePass(keystorePass);             proto.setTruststoreFile(absoluteTruststoreFile);             proto.setTruststorePass(truststorePass);             proto.setKeystoreType(keyStoreType);             proto.setKeyAlias(keyAlias);             proto.setCiphers(chiphers);         };     } else {         customizer = (connector) -> {             connector.setPort(port);             connector.setSecure(false);             if (sslEnabled) {                 log.error("Key- or Trust-store file — {} or {} — is not found, or keystore password is missing, REST service on port {} is disabled", absoluteKeystoreFile, absoluteTruststoreFile, port);                 ((Http11NioProtocol) connector.getProtocolHandler()).setMaxConnections(0);             }         };     }      return (ConfigurableWebServerFactory factory) -> {         ConfigurableTomcatWebServerFactory tomcatWebServerFactory = (ConfigurableTomcatWebServerFactory) factory;         tomcatWebServerFactory.addConnectorCustomizers(customizer);     };

И после этого мы отдаем на откуп «магии» спринга перехват всех веб-запросов к нашему сервису, для того чтобы обеспечить безопасность соединения.

Тестирование TLS/SSL

Для того чтобы провести тестирование реализованного безопасного подключения имеется возможность создать ketstore программно, используя классы из пакета java.security.* Это даст возможность тестировать различное поведение системы в случае разных ситуаций типа истекших сертификатов, проверки корректной валидации доверенных сертификатов и так далее.

Чтобы грамотно проверить работоспобность придется пройти по всем составным частям jks и воссоздать программно внутри KeyStore пару ключей KeyPair, свой сертификат X509Cetrificate, цепочку родительских сертификатов, подпись и доверенные корневые сертификаты.

Для упрощения этой задачи можно воспользоваться библиотекой bouncyСastle, которая предоставляет ряд дополнительный возможностей в дополнение к стандартным классам в Java, посвященным криптографии из Java Cryptography Architecture (JCA) и Java Cryptography Extension (JCE).

Некоторые аспекты работы с этой библиотекой присутствуют для kotlin и Java в зеркале их репозитория на github (https://github.com/bcgit/bc-java и https://github.com/bcgit/bc-kotlin).

На верхнем уровне абстракции создание keyStore для целей тестирования может выглядеть следующим образом:

KeyStore generateKeyStore(String password) {      X509Certificate2 ca = X509Certificate2Builder()         .setSubject("CN=CA")         .build()       X509Certificate2 int = X509Certificate2Builder()         .setIssuer(ca)         .setSubject("CN=Intermediate")         .build()      X509Certificate2 cert = X509Certificate2Builder()         .setIssuer(int)         .setSubject("CN=Child")         .setIntermediate(false)         .build()      return KeyStoreBuilder()         .addTrustedCertificate("test-ca", ca)         .addPrivateKey("test-pk", cert.keyPair, password, asList(cert, int, ca))         .build() }

Здесь мы, соответственно, можем увидеть наш доверительный корневой сертификат CA, сертификат cert, который выпущен промежуточным звеном, и нашу пару ключей (приватный и публичный), которые хранятся вместе с сертификатом в поле KeyPair keyPair класса X509Certificate2, расширяющем X509Certificate.

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

Соответственно, остается нюанс в непосредственном написании двух билдеров — X509Certificate2Builder и KeyStoreBuilder. Конечно же в java существует java.security.KeyStore.Builder, но он весьма общего плана и имеет единственный ценный метод — newInstance, а хочется чего-то более явного для добавления доверенных сертификатов и приватных ключей. По этой причине был написан свой билдер.

Свой билдер использует в конечном итоге метод setEntry класса KeyStore для единообразного добавления сущностей доверенных сертификатов и приватных ключей, используя различные имплементации типа Entry (TrustedCertificateEntry или PrivateKeyEntry).

И поскольку KeyStore#setEntry имеет сигнатуру setEntry(String alias, Entry entry, ProtectionParameter protParam) с 3 параметрами, мы их можем объединить в один класс item и в итоге в методе KeyStoreBuilder#build() останется лишь следующий код:

public KeyStore build() {     try {         KeyStore keyStore = KeyStore.getInstance("JKS", "SUN");         keyStore.load(null, null);         for (Item it : entries.values()) {             keyStore.setEntry(it.alias, it.entry, it.parameter);         }         return keyStore;     } catch (IOException | GeneralSecurityException e) {         throw new RuntimeException(e.getMessage(), e);     }

А при добавлении сущностей в entries мы будем использовать сигнатуру аналогичную KeyStore#setEntry, но публичным интерфейсом, использующим этот setEntry будут более понятные методы addPrivateKey

public KeyStoreBuilder addPrivateKey(String alias, KeyPair pair, String password, List<X509Certificate> chain) {     addEntry(alias,              new KeyStore.PrivateKeyEntry(pair.getPrivate(), chain),              new KeyStore.PasswordProtection(password.toCharArray()));     return this;  }

и метод addTrustedCertificate,

public KeyStoreBuilder addTrustedCertificate(String alias, X509Certificate cert) {     addEntry(alias, new KeyStore.TrustedCertificateEntry(cert), null);     return this; }

которые мы использовали выше при генерации keyStore.

C билдером X509 сертификата дела обстоят чуть сложнее, поскольку основная часть логики там будет сосредоточена в методе build(). Чтобы не загромождать статью болейрплейт кодом сеттеров, которые просто устанавливают значения полей билдера, я сразу перейду к реализации метода build() для X509Certificate2, опустив методы, связанные с установкой значений в билдер, и использую вместо них локальные переменные:

public X509Certificate2 build() {     if (Security.getProvider("BC") == null) {         Security.addProvider(new BouncyCastleProvider());     }      final X500Name subject = new X500Name(this.subject);     final KeyPair subjectKeyPair = newKeyPair(subjectKeyStrength);     final boolean selfSigned = this.issuer == null;     final X500Name issuer = selfSigned ? subject : new X500Name(this.issuer.getSubjectDN().getName());     final KeyPair issuerKeyPair = selfSigned ? subjectKeyPair : this.issuer.getKeyPair();      // Create x509 certificate     final Date notBefore = new Date();     final Date notAfter = new Date(notBefore.getTime() + 20L * 365 * 24 * 60 * 60 * 1000);     final BigInteger serialNumber = BigInteger.valueOf(SERIALS.incrementAndGet());     final SubjectPublicKeyInfo subjectKeyInfo = SubjectPublicKeyInfo.getInstance(subjectKeyPair.getPublic().getEncoded());     final X509v3CertificateBuilder builder = new X509v3CertificateBuilder(         issuer, serialNumber, notBefore, notAfter, subject, subjectKeyInfo);      // Get the certificate back     final AlgorithmIdentifier sigAlgId = new DefaultSignatureAlgorithmIdentifierFinder().find("SHA256withRSA");     final AlgorithmIdentifier digAlgId = new DefaultDigestAlgorithmIdentifierFinder().find(sigAlgId);      try {         final BcX509ExtensionUtils extensionUtils = new BcX509ExtensionUtils();         builder.addExtension(             new ASN1ObjectIdentifier("2.5.29.14"), // Subject Key Identifier             false,             extensionUtils.createSubjectKeyIdentifier(                 SubjectPublicKeyInfo.getInstance(subjectKeyPair.getPublic().getEncoded()))         );         builder.addExtension(             new ASN1ObjectIdentifier("2.5.29.35"), // Authority Key Identifier             false,             extensionUtils.createAuthorityKeyIdentifier(                 SubjectPublicKeyInfo.getInstance(issuerKeyPair.getPublic().getEncoded()))         );         builder.addExtension(             new ASN1ObjectIdentifier("2.5.29.19"), // Basic Constraints             false,             new BasicConstraints(intermediate));          AsymmetricKeyParameter privateKey = PrivateKeyFactory.createKey(issuerKeyPair.getPrivate().getEncoded());         ContentSigner signer = new BcRSAContentSignerBuilder(sigAlgId, digAlgId)         .build(privateKey);         X509Certificate cert = new JcaX509CertificateConverter()             .setProvider("BC")             .getCertificate(builder.build(signer));          return new X509Certificate2(cert, subjectKeyPair);     } catch (IOException | OperatorCreationException | CertificateException e) {         throw new RuntimeException(e.getMessage(), e);     } }  private static KeyPair newKeyPair(int subjectKeyStrength) {     try {         if (subjectKeyStrength <= 0) {             subjectKeyStrength = DEFAULT_KEY_STRENGTH; // 2048          }          // Create the public/private rsa key pair         KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA", "BC");         keyPairGen.initialize(subjectKeyStrength, SecureRandom.getInstance("SHA1PRNG"));         return keyPairGen.generateKeyPair();     } catch (GeneralSecurityException e) {         throw new RuntimeException(e.getMessage(), e);     } }

Все недостающие в стандартной библиотеке классы импортированы из bouncycastle.
В начале работы необходимо проинициализировать провайдер bouncycastle, если это еще не было сделано ранее.

Пара ключей генерируется с использованием java.security.KeyPairGenerator, который позволяет создать ключи с заданный алгоритмом и хеш-функцией. В данном примере был использован RSA c SHA1PRNG.

Далее мы объявляем поля, необходимые для сертификата, такие как даты начала и окончания, эмитент и серийный номер.

Затем мы добавляем расширения для сертификата, описывающие субъект и указание корневого сертификата, подписавшего его. В конце концов, получаем сертификат.

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

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

В результате, например, для проверки безопасного соединения в Spring boot приложении без использования стандартного пути с пропертями достаточно будет создать шаблонное приложение с вебом, например, через Spring Initializr и

добавить в главный класс следующий код

@SpringBootApplication @RestController public class Server {     static String certFile = System.getProperty("user.home") + "/cert.jks";     static String defaultPassword = "changeit";      @GetMapping("/hello")     public String hello() {         System.out.println("request /hello");         return "hello";     }      @Bean     WebServerFactoryCustomizer<ConfigurableWebServerFactory> containerCustomizer() {         TomcatConnectorCustomizer customizer = (connector) -> {             connector.setPort(8080);             connector.setSecure(true);             connector.setScheme("https");             Http11NioProtocol proto = (Http11NioProtocol) connector.getProtocolHandler();             proto.setSSLEnabled(true);             proto.setClientAuth("true");             proto.setKeystoreFile(certFile);             proto.setKeystorePass(defaultPassword);             proto.setTruststoreFile(certFile);             proto.setTruststorePass(defaultPassword);             proto.setKeystoreType("JKS");         };          return (ConfigurableWebServerFactory factory) -> {             ConfigurableTomcatWebServerFactory tomcatWebServerFactory = (ConfigurableTomcatWebServerFactory) factory;             tomcatWebServerFactory.addConnectorCustomizers(customizer);         };     }      public static void main(String[] args) {         SpringApplication.run(Server.class, args);     } }  class Client {     RestTemplate restTemplate() throws Exception {         SSLContext sslContext = new SSLContextBuilder()                 .loadTrustMaterial(new URL(Server.certFile), Server.defaultPassword.toCharArray())                 .build();         SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory(sslContext);         HttpClient httpClient = HttpClients.custom()                 .setSSLSocketFactory(socketFactory)                 .build();         HttpComponentsClientHttpRequestFactory factory =                 new HttpComponentsClientHttpRequestFactory(httpClient);         return new RestTemplate(factory);     }      public static void main(String[] args) {         String response = new RestTemplate().getForObject("https://localhost:8080/hello", String.class);         System.out.println("received " + response);     } }

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

ссылка на оригинал статьи https://habr.com/ru/company/dbtc/blog/487318/


Комментарии

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

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