Привет, Хабр! Меня зовут Андрей Чернов, я Java‑архитектор в СберТехе, где разрабатываю архитектуру микросервисов. Сейчас я расскажу про нюансы работы с секретами в Java‑сервисах на всеми любимом Spring Boot и про наш опыт такой работы. В современном мире практически не осталось автономных, ни с чем не интегрированных, сервисов. А секреты в первую очередь нужны для безопасных интеграций.

Статья будет состоять из двух частей. В первой расскажу про особенности работы с секретами в Java на Spring Boot — где их брать и как применять к вашему сервису на примере того, как мы делаем это в Platform V Sessions Data (распределенный in‑memory кеш для клиентских сессий, который позволяет снизить нагрузку на внешние сервисы и базу данных). Также расскажу про стандартные варианты обновления секретов «на горячую» (не останавливая, не перезапуская сервисы, и даже не снимая с них нагрузку) и что с ними не так.

Во второй части подробно разберу, как мы обновляем секреты «на горячую». Эти советы помогут вам улучшить работу с секретами, а значит сделать сервисы более защищёнными. Ведь если секреты попадут в руки злоумышленников, те смогут атаковать ваш сервис — вывести его из строя, украсть конфиденциальные данные и т. п. А любая успешная атака чревата для компаний потерей денег, нервов, времени и репутации.

Зачем современным сервисам секреты

Секреты — это конфигурационные параметры, которые, с одной стороны, необходимы для работы сервиса, а с другой, именно они представляют особый интерес для злоумышленников. Это могут быть, например, SSL‑сертификаты (публичный сертификат, его закрытый ключ, корневой доверенный сертификат), учётные данные (имя пользователя, пароль) и т. п.

Поэтому архитектуру безопасной работы с секретами в сервисах нужно тщательно продумывать.

Для того чтобы стало понятнее, давайте рассмотрим секреты нашего сервиса — Platform V Sessions Data (распределённый in‑memory кеш клиентских сессий).

Вот как выглядит архитектура:

  • in‑memory кеш находится в памяти master‑узлов кластера;

  • потребители получают данные из master через нашу клиентскую библиотеку, которая кеширует их в near cache;

  • master откидывает списки сессий в приложение servant, которое сохраняет их в БД;

  • back нашей админки — manager — обращается к servant за этими данными из БД.

Все наши три приложения написаны на Java и используют Spring Boot.

Секреты здесь нужны в первую очередь для безопасных интеграций между приложениями, а также для связи с базой данных. В обоих случаях используется Mutual TLS (mTLS), для чего требуются SSL‑сертификаты. То есть, в нашем случае SSL‑сертификаты нужны:

  • во всех взаимодействиях между приложениями (там используется HTTPS с mTLS);

  • для обращений servant к БД PostgreSQL по JDBC.

Кроме того, нам нужны учётные данные (имя пользователя и пароль) для связи с базой данных.

Откуда безопасно получать секреты

Провайдеров секретов довольно много. Среди них HashiCorp Vault, Azure Key Vault, AWS Secret Manager и многие другие. Их задача — минимизировать риск компрометации секретов при хранении и передаче в сервисы.

Один из самых популярных — HashiCorp Vault. С безопасностью хранения его создатели конкретно заморочились: секреты хранятся в базе данных Vault, зашифрованные мастер‑ключом, который зашифрован root‑ключом, и он тоже зашифрован.

Секреты Vault тоже предоставляет сервисам безопасно:

  • во‑первых, для транспорта используется HTTPS с TLS;

  • во‑вторых, Vault обязательно аутентифицирует каждый обратившийся сервис, для чего есть множество вариантов на все случаи жизни и для любого облака;

  • в‑третьих, Vault выполняет авторизацию сервисов на основе гибких политик, то есть чётко знает, кому и какие секреты можно отдавать.

Секреты из Vault попадают в приложения, развёртываемые в контейнерах/виртуалках, разными способами:

  1. Стандартный: использовать Vault Agent sidecar в Kubernetes. Он приносит секреты из Vault по HTTPS и сохраняет их в виде файлов в emptyDir (он общий c основным контейнером пода).

  2. Более элегантный способ: использовать External Secrets Operator. Он тоже по HTTPS приносит секреты из Vault, но сохраняет их в виде уже куберовых сущностей kind: Secret, снабжая секретами весь кластер. Сервисам остаётся только смонтировать файлы с секретами в свой контейнер.

  3. Ещё один способ: напрямую интегрироваться с Vault по HTTPS. В этом случае сервис сам приносит секреты из Vault.

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

Чтобы получать секреты, мы используем не ванильный HashiCorp Vault, а собственный сервис, который по API полностью совместим с Vault.

Перед тем как погрузиться в Java‑код, где будут примеры из нашего сервиса Platform V Sessions Data, расскажу, какие именно секреты мы используем непосредственно из Java.

Когда спасает Istio

Для HTTPS‑взаимодействий в своих кластерах Kubernetes мы используем Istio. Он бер`т на себя:

  • терминацию mTLS для входящего трафика в Ingress;

  • инициацию mTLS для исходящего трафика в Egress.

Приложениям внутри namespace Kubernetes не нужно самим использовать SSL‑сертификаты дляHTTPS‑взаимодействий, так как за них всё делает Istio. А вот master‑хранилище у нас развёртывается на виртуалках, поэтому там нет никакой Istio‑магии — с SSL‑сертификатами работает сам master из Java‑кода. Это касается и клиентских сертификатов, и серверных.

Что касается базы данных, то, как уже было сказано, для связи с ней также требуются SSL‑сертификаты, но в этот раз уже для JDBC (Istio Egress нас тут не спасает). Кроме того, для связи с БД нужны ещё и учётные данные. Эти секреты приходится применять напрямую из Java‑кода нашего приложения servant.

Таким образом, из приложений нашего сервиса Platform V Sessions Data сами в Java‑коде потребляют секреты только master‑хранилище и servant, а нашей клиентской jar и manager повезло — им секреты не нужны, их спасает Istio.

Как применять секреты при старте Java-сервисов на Spring Boot

Теперь мы подобрались к Java‑коду и Spring Boot. Напомню ещё раз, что наши секреты — это файлы:

  • серверные SSL‑сертификаты для HTTPS;

  • клиентские SSL‑сертификаты для HTTPS;

  • клиентские SSL‑сертификаты для JDBC;

  • креды БД.

В первую очередь нам нужно уметь применять эти секреты в Java‑коде сервисов при их старте. Может показаться, что это просто: читаешь файлы и применяешь их секретное содержимое. Но дьявол, как обычно, в деталях.

А) Применение серверных SSL‑сертификатов.

При запуске нашего master‑хранилища нужно применять серверные SSL‑сертификаты к встроенному в Spring Boot Tomcat. Пути до хранилищ сертификатов указываем в application.yaml в стандартных properties key‑store и trust‑store:

Однако пароли к этим хранилищам сертификатов так в Spring Boot не скормить, так как ему нужны строки, а у нас есть только файлы с этими строками внутри.

В Platform V Sessions Data мы используем трюк с customizer фабрики Tomcat. При запуске Spring Boot мы самостоятельно читаем три пароля из файлов и программно задаём их Tomcat.

В таких случаях всегда вспоминаем best practice: в Java‑коде работать с секретными значениями нужно не через обычные строки, а через char[] и сразу занулять массив после использования. Так секреты не засветятся в heap dump JVM, а значит не попадут в руки администраторов сервиса, которым знать его секреты не положено.

Но в этом конкретном случае API Spring Boot умеет принимать пароли только в виде обычных строк. Поэтому стоит признать: здесь best practice соблюсти не получится.

Б) Применение клиентских SSL‑сертификатов.

При запуске master‑хранилища нам также требуется применять клиентские SSL‑сертификаты для исходящих HTTPS‑взаимодействий.

Чтобы отправлять HTTP‑запросы, мы используем Jersey клиент. А чтобы применить к нему клиентские SSL‑сертификаты, мы сами в Java‑коде собираем SSLContext. Для этого читаем файлы с хранилищами сертификатов и паролями от них:

В этот раз мы можем соблюсти best practice: читаем пароли в char[], зануляя массив после использования, и это здорово.

Построенный таким образом SSLContext мы передаем создаваемому экземпляру Jersey клиента:

На этом всё. Дальше наш master‑сервис использует этот построенный экземпляр клиента для исходящих HTTPS‑запросов (например, к servant).

В) Применение SSL‑сертификатов для соединений с БД.

Нашему приложению servant при старте нужно применять SSL‑сертификаты для соединения с БД PostgreSQL. В этом случае пути до файлов с сертификатами зашиваются в JDBC URL. Это три файла:

  • сертификат,

  • его приватный ключ,

  • корневой сертификат, которым должен быть подписан серверный сертификат Postgre.

Сертификаты используются в pem‑формате (просто кодированные в base64), поэтому здесь не нужны пароли от хранилищ сертификатов.

Создавая bean HikariDataSource, мы просто задаем ему такой JDBC URL, куда зашиты пути до SSL‑сертификатов.

И это тоже все. Дальше JDBC‑драйвер Postgre, устанавливая соединение с БД, использует заданные в URL SSL‑сертификаты. Нам самим даже не приходится читать файлы с ними.

Г) Применение учётных данных БД

Для соединений с PostgreSQL servant при запуске должен применять ещё и файлы с учётными данным БД. В отличие от SSL‑сертификатов, имя пользователя и пароль мы сами читаем из секретных файлов и задаём их при создании bean HikariDataSource.

Здесь мы снова вспоминаем про best practice: секреты нужно читать в char[] с занулением массива после использования. Жаль, но соблюсти это здесь тоже не получится: как и в случае со Spring, библиотека HikariCP требует учётные данные в виде обычных строк.

Зачем обновлять секреты

Итак, секреты применены, сервис запускается, работает безопасно, используя mTLS во всех интеграциях. Что еще нужно? Нужно уметь обновлять секреты, ведь они могут поменяться. Вот некоторые причины:

  • периодическая ротация SSL‑сертификатов;

  • компрометация кредов, требующая замены логина/пароля (не дай бог) и т. п.

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

При любом способе интеграции с HashiCorp Vault, сервис периодически получает из него обновления файлов с секретами. Остаётся только вовремя реагировать на эти изменения файлов и применять новые секреты. И здесь также очень много нюансов и сложностей.

Почему не перезапуск для обновления секретов

Казалось бы, можно просто перезапустить сервис для применения новых секретов, полученных из HashiCorp Vault. Звучит заманчиво: это просто и надёжно. Однако от этой идеи пришлось отказаться, и вот почему.

1) Начнем с Kubernetes. Первое, что приходит на ум — это триггернуть rolling update для последовательного перезапуска всех подов приложения, чтобы применились обновления секретов.

Здесь возможны два варианта.

  1. Во время rolling update количество подов сервиса временно уменьшится. Мы на это не согласны, так как не хотим увеличить нагрузки на оставшиеся поды, чтобы не провоцировать ошибки и не ухудшить качество сервиса.

  2. Во время rolling update количество подов временно увеличится. На это мы тоже не готовы идти при каждом обновлении секретов. Мы стараемся максимально рационально подходить к расходованию железа в кластерах. Бывают такие секреты, которые используют многие сервисы (например, trust store для mTLS). Если при изменении таких секретов десятки и сотни сервисов запросят дополнительные поды, то в моменте резко увеличится потребление железа в кластере. Для такой задачи, как применение новых секретов, это слишком дорого.

2) Продолжим нашим master‑хранилищем на виртуалках.

Там нет rolling update из коробки. Более того, master — это stateful‑приложение, которое хранит данные в оперативной памяти. Поэтому нужно максимально избегать перезапуска его узлов, тем более для такой цели как простое применение новых секретов.

Мы, выполнив такой анализ, решили применять обновления секретов прямо «на горячую», не останавливая, не перезапуская сервисы, и даже не снимая с них нагрузку. Мы называем это hot reload. И это стало настоящим вызовом.

Почему не @RefreshScope для обновления секретов

Разумеется, сначала мы поискали стандартные средства, которыми можно выполнить hot reload секретов. Первым делом попробовали использовать аннотацию @RefreshScope из Spring Cloud. Выглядит очень удобно — навешиваешь эту аннотацию на bean, который нужно пересоздать при изменении конфигурации Spring:

А после изменения файлов с секретами (они — часть конфигурации Spring):

  • либо вызываешь RefreshEndpoint actuator‑а по HTTP: http://<host>:<port>/actuator/refresh ‑request POST

  • либо прямо в коде приложения публикуешь RefreshEvent: applicationContext.publishEvent(new RefreshEvent(this, <event>, <eventName>))

И всё! Первое следующее обращение к bean приведет к его пересозданию по новой конфигурации (то есть, в нашем случае, по новым секретам).

Однако, на практике этот способ подошел нам только для пересоздания bean HTTP‑клиента по новым SSL‑сертификатам.

А чтобы через @RefreshScope обновить серверные сертификаты Tomcat, в Spring Boot просто не оказалось подходящего bean. Самое очевидное решение — повесить аннотацию на bean с фабрикой Tomcat (TomcatServletWebServerFactory). Но тогда Spring Boot перестает стартовать из‑за ошибки:

Unable to start ServletWebServerApplicationContext due to multiple ServletWebServerFactory beans: scopedTarget.tomcatServletWebServerFactory,tomcatServletWebServerFactory

Почему‑то Spring Boot воспринимает фабрику Tomcat в RefreshScope как дополнительный, второй bean с таким типом, хотя фабрика должна быть singleton.

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

Что касается bean HikariDataSource, то там использование @RefreshScope приводит к разрыву всех текущих соединений с БД: потоки, ожидающие ответа от базы, получают PSQLException. Это происходит потому, что HikariDataSource реализует интерфейс AutoClosable, а RefreshScope при пересоздании bean по новой Spring конфигурации разрушает предыдущий экземпляр методом close() для AuthCloseable beans.

К сожалению, пришлось признать, что мы не можем использовать @RefreshScope для hot reload секретов.

Почему не SSL bundles для обновления секретов

Мы стали искать другие решения для hot reload и попробовали SSL bundles, которые появились в Spring Boot 3.2.

Одна из их основных фич — как раз hot reload SSL‑сертификатов при их изменении. Нужно просто задать в application.yaml именованный bundle с SSL‑сертификатами и указать для него флаг reloadOnUpdate.

Так включается слежение за файлами сертификатов и перезагрузка bundle «на горячую», что, действительно, очень удобно.

Однако на практике reload SSL bundles нам не подошёл по нескольким причинам.

  1. Не работает в k8s, когда секреты монтируются из kind: Secret. External Secrets Operator может менять kind: Secret на лету. При этом монтируемые файлы с секретами — это symlinks, которые остаются неизменными, из‑за чего нет реакции на изменение секретов.

  2. Не следит за изменениями паролей от keyStore и trustStore. В application.yaml пароли для bundles задаются строками, а не ссылками на файлы, поэтому обновить пароли невозможно.

  3. Reload срабатывает при любых событиях изменения файлов. На практике было необходимо следить за конкретными событиями (например, только за удалениями).

В общем, вариант с SSL bundles тоже оказался не для нас из‑за того, что для использования в production они пока сыроваты. Надеюсь, что в очередной версии Spring Boot перечисленные выше недостатки исправят.

В итоге, мы были вынуждены реализовать свой, универсальный инструмент для hot reload любых секретов в Java‑сервисах на Spring Boot, которые могут деплоиться как в кубер, так и на виртуалки. Подробнее об этом расскажу в следующей статье.

Итоги и выводы

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

  2. Для ротации секретов не стоит использовать rolling update, особенно если у вас высоконагруженный сервис. Даже временное уменьшение или увеличение числа подов — это слишком дорого для обновления секретов.

Комментарии (1)


  1. dersoverflow
    26.12.2024 11:59

    И снова Security through obscurity...

    МТС ничего не ответил по сути https://habr.com/en/companies/ru_mts/articles/861822/ . Давайте посмотрим, что скажет Сбер. Итак, вы пишете:

    Используйте внешнего провайдера секретов для ваших сервисов.

    Прекрасно! Теперь вопрос: Используете ли вы секрет для доступа к самому ПровайдеруСекретов?

    1. Если нет, то злоумышленник прочитает ПровайдерСекретов и получит секреты для ваших сервисов...

    2. Если да, то вам нужен ПровайдерСекретов2, чтобы хранить секрет для ПровайдераСекретов...

    Вы видите суть проблемы?