Введение во взаимную аутентификацию сервисов на 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 файл, если надо будет создать его на лету можно воспользоваться примерами, которые я приводил выше и доработать для себя так как будет удобно.

Источник: habr.ru