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