
Вопросы авторизации и аутентификации и в целом аспектов защиты информации все чаще возникают в процессе разработки приложений, и каждый с разной степенью фанатизма подходит к решению данных вопросов. С учетом того, что последние несколько лет сферой моей деятельности является разработка ПО в финансовом секторе, в частности, систем расчета рисков, я не мог пройти мимо этого, особенно учитывая соответствующее образование. Поэтому в рамках данной статьи решил осветить эту тему и рассказать, с чем мне пришлось столкнуться в процессе настройки наших приложений.
Введение
Если говорить о формате настройки сертификатов для безопасной передачи данных. Как правило, данные действия производят на каком-либо веб-сервере типа 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
- Создание запроса сертификата (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 и keyManager. Эти менеджеры мы можем получить из соответствующий фабрик — 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
Для того чтобы провести тестирование реализованного безопасного подключения имеется возможность создать keystore программно, используя классы из пакета java.security.* Это даст возможность тестировать различное поведение системы в случае разных ситуаций типа истекших сертификатов, проверки корректной валидации доверенных сертификатов и так далее.
Чтобы грамотно проверить работоспобность придется пройти по всем составным частям jks и воссоздать программно внутри KeyStore пару ключей KeyPair, свой сертификат X509Certificate, цепочку родительских сертификатов, подпись и доверенные корневые сертификаты.
Для упрощения этой задачи можно воспользоваться библиотекой 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 файл, если надо будет создать его на лету можно воспользоваться примерами, которые я приводил выше и доработать для себя так как будет удобно.
