Избавляемся от паролей в репе с кодом с помощью HashiCorp Vault Dynamic Secrets

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Привет, Хабр! Меня зовут Сергей, я IT Head в компании Quadcode. Сегодня хотел бы рассказать о том, как я решил проблему с хранением паролей в открытом виде в коде одного из моих pet-проектов. Думаю, это знакомая для многих ситуация. Знакомая — и неприятная.

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

TL;DR

Кому не интересна сама история — вот спойлер, т.е. исходники приложения-примера. В важных местах я снабдил код уточняющими комментариями. Как запустить пример написано в README.md в корне репозитория с кодом.

Выбор варианта решения

Надеюсь, саму проблему я описал достаточно понятно. Теперь требовалось найти её решение. Их было несколько, но почти каждое по той либо иной причине не подходило.

Например, можно было просто вынести пароли в переменные окружения CI/CD системы и не ломать голову. В этом случае у меня не было уверенности в том, что пароль не утечёт через добавление в CI-пайплайн строки echo $DB_PASSWORD. Да и пароль всё равно надо было менять, так как он уже засветился в git history. И это надо будет делать каждый раз вручную.

Еще один вариант — хранить пароль в зашифрованном виде вместе с кодом приложения. Но и здесь не все в порядке — ведь мы получаем ещё один ключ шифрования, который нужно как-то безопасно хранить и периодически ротировать. Так что этот вариант тоже не подходил.

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

Можно передавать учётные данные через переменные окружения, в которые оркестратор (в проекте это был Kubernetes) будет внедрять учётные данные полученные из защищённого хранилища секретов. Звучит неплохо, если бы не ситуация с отзывом скомпрометированных учётных данных. Учитывая возраст проекта, не было уверенности в том что приложение сможет корректно обработать такую ситуацию без дополнительных правок в коде, так как могли быть участки кода, которые могли интерпретировать отзыв учётных данных как временную проблему (такую как разрыв соединения с базой данных), а по сути приложение должно либо аварийно завершить свою работу либо пересоздать все соединения с базой с уже новыми учётными данными. Этот вариант тоже отвалился.

Можно сделать так чтобы само приложение получало учётные данные из защищённого хранилища, а в случае сложностей с получением учётных данных аварийно завершало свою работу. Здесь нужно было вносить правки в код, в котором создаётся пул коннектов к базе данных. И это довольно простая задача, которая явно не влечёт за собой кучу регресса в бизнес-логике приложения. В целом, вариант подходящий, именно на нем я решил остановиться.

Но и это не все. Следующий этап — выбор инструментария для реализации выбранного решения. Тут оказалось всё проще, так как в проекте уже был устоявшийся стек:

  • Golang — приложения проекта написаны на этом языке, поэтому естественно доработки тоже будут на этом языке.

  • Postgresql — это, собственно, СУБД с которой работают приложения.

  • HashiCorp Vault — это инструмент с открытым исходным кодом, который обеспечивает безопасный и надежный способ хранения и распространения секретов, таких как ключи API, токены доступа и пароли. Выбрал его по следующим причинам:

    • можно развернуть инсталляцию Vault на своих серверах;

    • есть интеграция с Kubernetes;

    • официальная Golang-библиотека для работы с API Vault;

    • подробная документация с простыми гайдами;

    • на моей текущей работе Security-отдел тоже использует Vault — это тоже повлияло на моё решение, так как Vault доверяют те люди которые имеют немалый опыт работы в роли Security Engineer.

Что же — приступим

После беглого осмотра официальной документации по Vault был найден гайд по Database Dynamic Secrets. Как оказалось, есть возможность на лету создавать в базе данных пользователя с ограниченным сроком действия. Выглядит подходяще — учётные данные будут создаваться автоматически, так что нет физической возможности запушить пароль в git. Единственное — требуется решить, что делать приложению, когда срок жизни учётных данных истечёт.

Вариант “поставить время жизни учётных данных в год” и надеяться, что процесс приложения успеет перезапуститься за год мне не подходил. Это, скорее, бомба с часовым механизмом, выставленным на год вперёд, а не решение. Еще одна проблема — за год в Vault может накопиться большое количество уже ненужных динамических секретов, а следовательно и большое количество ролей в Postgresql.

Но сколько должны жить динамические учетные данные? Год — много. Одна минута — мало. Решение оказалось простым: пока жив процесс приложения, должны быть активны и учётные данные для этого процесса. В случае их компрометации можно просто перезапустить процесс, после чего он получит новые учётные данные.

Итоговое решение: при запуске процесса приложения будут генерироваться динамические учётные данные со временем жизни 5 минут. Процесс периодически (чаще чем раз в 5 минут) будет производить продление (renew) аренды своих учётных данных, тем самым продлевая доступ к базе данных ещё на 5 минут. Перед завершением процесса учётные данные отзываются. Если процесс будет убит по kill -9, то учётные данные будут автоматически отозваны по истечении срока аренды самим Vault’ом. В случае отзыва учётных данных оператором, процесс должен аварийно завершить свою работу (после чего оркестратор автоматически запустит новый процесс).

Выбираем схему и метод практической реализации

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

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

Шаг 1: Готовим Postgresql

По схеме видно, что всё зависит от Postgresql, потому начинаем именно с него. Тут особо чего-то нового не пришлось делать — в базе уже была создана отдельная роль для приложения и ей были выданы гранты на то, что ей можно делать в базе. Для простоты в данной статье роль называется app. Вот как приблизительно создавалась у нас эта роль:

CREATE ROLE app NOINHERIT; GRANT SELECT ON ALL TABLES IN SCHEMA public TO "app";

Единственное, что потребовалось дополнительно — создать в Postgresql отдельную роль для Vault с правами на создание ролей. Вот как выглядел SQL-запрос для этого:

CREATE ROLE vault-root-for-app WITH CREATEROLE NOINHERIT LOGIN PASSWORD 'password-was-removed-from-here';

Всё. Postgresql готов к интеграции с Vault.

Шаг 2: Готовим Vault

Включаем Vault движок для database secrets. Движок позволяет организовать упомянутую схему по получению динамических секретов для базы данных. Включение производится следующей командой:

vault secrets enable database

Далее создаём конфиг, при помощи которого Vault сможет создавать роли для динамических секретов. Как раз для этого ранее была создана роль vault-root-for-app.

vault write database/config/app-db plugin_name=postgresql-database-plugin connection_url="postgresql://{{username}}:{{password}}@postgres-host:5432/app-db?sslmode=disable" allowed_roles=app username="vault-root-for-app" password="vault-root-for-app-password"

Здесь хочу обратить внимание на то, что для каждой конфигурации лучше заводить отдельную роль в Postgresql для Vault (в примере выше это роль vault-root-for-app). Это обусловлено тем, что у Vault есть функция ротации пароля vault-пользователя в Postgresql (детали тут: https://learn.hashicorp.com/tutorials/vault/database-root-rotation). Если одна и та же роль будет использоваться в двух конфигурациях, то после ротации пароля в одной из конфигураций, другая перестанет работать. Проблема в том, что у неё будет информация только о пароле, который был до ротации. Отсюда вывод — для каждого конфига в Vault лучше заводить отдельную учётку с правами на создание ролей, чтобы избежать конфликтов в учётных данных.

Идём дальше — создаем Vault-роль в которой default_ttl=5m. Это как раз та самая длительность аренды секрета, если её не продлевать конечно. У Vault-роли есть еще параметр max_ttl — максимальное время аренды секрета. Когда суммарно время жизни секрета достигает max_ttl, то секрет отзывается даже если мы продлили его аренду. Поэтому я специально не указал max_ttl, так как приложение может на протяжении месяцев продлевать аренду секрета и точное значение max_ttl никогда не угадать (UPD: дальше выяснится, что я ошибался на счёт того, что если не указывать max_ttl то время жизни секрета будет бесконечным).

Если же добавлять в приложение логику, которая при приближении времени жизни секрета к max_ttl будет запрашивать новый секрет, то нужно будет решать вопрос с пересозданием текущих коннектов к базе, все еще работающих по истекающему секрету. Отсюда проблема — один из коннектов может выполнять долгий запрос и просто взять и закрыть его нельзя. Надо ждать пока он отработает, а к моменту завершения запроса секрет уже может быть отозван. Решили не усложнять логику приложения добавлением вышеописанной логики.

Вот пример команды при помощи которой была создана Vault-роль:

vault write database/roles/app db_name=app-db creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' INHERIT; GRANT app TO \"{{name}}\";" default_ttl=5m

Всё. Vault настроен. Можно, наконец, приступать к написанию кода.

Шаг 3: Пишем Golang-код

Первым делом подключаем официальный Golang-клиент по работе с API Vault:

go get github.com/hashicorp/vault/api

Далее в функции main создаём экземпляр Vault-клиента:

vaultClient, err := api.NewClient(api.DefaultConfig())

Здесь:

  • api.DefaultConfig использует значение переменной окружения VAULT_ADDR в качестве адреса API Vault-сервера. На самом деле это не адрес, а URL, так как перед адресом должен быть указан протокол по которому будет идти взаимодействие с API Vault. Варианта два: http:// или https://.

  • api.NewClient использует значение переменной окружения VAULT_TOKEN в качестве токена аутентификации в API Vault.

Функции из примера выше смотрят ещё на ряд переменных окружения, но пока достаточно только двух. Остальные можно посмотреть в коде Golang-клиента на github.

Дальше по коду запрашиваем динамический секрет у Vault:

dbSecret, err := vaultClient.Logical().Read(dbSecretPath)

В переменной dbSecretPath хранится путь со следующим форматом database/creds/{vault-role-name}. В нашем примере в этой переменной будет значение database/creds/app. database/creds/ - это путь к API, который выдаёт динамические секреты.

Далее регистрируем defer-функцию, которая будет отзывать секрет перед выходом из функции main:

defer func() {
	err := vaultClient.Sys().Revoke(dbSecret.LeaseID)
	if err != nil {
		log.Printf("Revoke error: %s", err.Error())
	}
}()

Затем мне пришлось немного покопаться в коде Vault-клиента, чтобы понять как можно организовать процесс продления аренды секрета. Наткнулся на такую вещь как Renewer. Посмотрев исходники и пример кода у типа Renewer, понял, что это точно то что нужно, и я ни с чем другим не перепутал этот тип. Вот как выглядит код который задействует Renewer:

dbSecretRenewer, err := vaultClient.NewRenewer(&api.RenewerInput{
	Secret: dbSecret,
})
if err != nil {
	panic(err)
}

go dbSecretRenewer.Renew()
defer dbSecretRenewer.Stop()
go func() {
	for {
		select {
		case err := <-dbSecretRenewer.DoneCh():
			if err != nil {
				log.Printf("Secret renewer done error: %s", err.Error())
			}
			log.Printf("Secret renewer done")
			sigChan <- syscall.SIGTERM
			return

		case renewal := <-dbSecretRenewer.RenewCh():
			log.Printf("Database secret has been renewed at %s\n", renewal.RenewedAt.Format(time.RFC3339))
		}
	}
}()

По сути здесь создается экземпляр Renewer. Он запускает отдельную горутину, которая занимается процессом продления аренды секрета. Сначала регает defer, останавливающий процесс продления перед выходом из функции main. А напоследок создаёт горутину, которая слушает два канала в цикле:

  • Канал RenewCh, который сигнализирует о том, что аренда секрета была успешно продлена. Тут всё просто: пишем в лог факт успешного продления аренды.

  • Канал - DoneCh, который сигнализирует о том, что Renewer завершил свою работу с ошибкой или без ошибки. Здесь есть очень важный момент — если Renewer завершил свою работу с ошибкой, то приложение должно начать процесс аварийного завершения. Проблема в том, что продлить аренду секрета не удалось, так что секрет в любой момент может быть отозван. А это, в свою очередь, повлечёт за собой ошибки аутентификации при создании новых соединений с базой данных, так как в базе данных уже нет информации об отозванной роли выданной приложению.

Я настоятельно рекомендую не просто писать в лог сообщение с ошибкой, а сигнализировать приложению, что пора начинать процесс аварийного завершения. В коде выше у меня был terminate-канал, в который вбрасывался SIGTERM, и процесс думал, что ему пришёл SIGTERM от операционной системы и завершал свою работу.

Да, это не аварийное завершение, но если вам нужно завершаться как-то по особенному в случае проблем с продлением секрета, то можете вбрасывать сигнал SIGUSR1, например, реагируя так, как считаете нужным. В моём случае стандартной логики graceful shutdown было достаточно.

Ещё я немного попараноил: глянул код Renewer на готовность к кратковременным отказам сети (не дольше секунды), так как без этого любое моргание сети в момент продления аренды будет приводить к аварийному завершению приложения. Обычно в таких случаях делают серию повторных запросов с прогрессивной/линейной задержкой. В коде Renewer’а такой логики не нашёл, но зато нашёл её чуть ниже — на уровне Vault HTTP Client. По-умолчанию HTTP Client делает 2 попытки с минимальной задержкой в 1000ms между попытками. В общем, к кратковременным сетевым скачкам Renewer готов.

После выполнения всего, что описано выше я понял, что близок к финалу. Остался последний штрих — поменять код, который создаёт пул коннектов к Postgresql. Нужно сделать так ,чтобы username и password брались теперь не из connection string, а из полученного секрета. Вот какой код получился:

dbConfig, err := pgxpool.ParseConfig(dbConnString)
if err != nil {
	panic(err)
}

dbConfig.ConnConfig.User = dbSecret.Data["username"].(string)
dbConfig.ConnConfig.Password = dbSecret.Data["password"].(string)

db, err := pgxpool.ConnectConfig(ctx, dbConfig)

Теперь точно все, код готов! Дальше уже идёт бизнес-логика приложения, а она у каждого своя.

UPD: апокалипсис 768 часов спустя

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

Ларчик открывается просто — у Vault есть глобальная настройка с названием max_lease_ttl, которая по-умолчанию имеет значение 768h. Я наивно думал, что если у секрета не указывать max-ttl, то секрет можно будет продлевать сколько угодно раз. Но Vault очень строг в плане максимального времени жизни секретов, так что эту настройку никак нельзя выставить в бесконечность.

Скорее всего ее ввели для того, чтобы нерадивые разработчики не могли настрогать кучу секретов с ttl в 100 лет и забить такими токенами всё хранилище Vault.

Поизучав код Vault, понял, что никакой лазейки для обхода max_lease_ttl нет. Так что я пошёл на сделку с совестью и увеличил max_lease_ttl до 43800h (5 лет), считая что ни один процесс столько не проживёт. А если и проживёт, то аварийно завершится и запустится опять.

Напоследок интересный факт: даже если роль в Postgresql уже истекла, то созданные соединения от этой роли продолжают работать, через них можно продолжать выполнять SQL-запросы. Но вот новые соединения под этой ролью естественно уже создать не получится.

Идея пошла в массы

После того, как работа была закончена, я поделился полученным опытом с Security Engineer’ами из моей же компании, поинтересовавшись их мнением. Вот их ответ:

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

  • Первая и главная — разнообразие пайплайнов команд разработки, что усложняет унификацию подхода к работе с секретами в Vault.

  • Вторая — недостаток ресурсов в команде безопасности для поддержки Vault и оказания услуг командам разработки по управлению секретами.

Сейчас в компании есть несколько вариантов хранения секретов:

  • Vault, за которым присматривают системные администраторы.

  • Переменные окружения в Gitlab.

Стоит отметить, что будущее мы связываем с Vault и в стратегии развития есть цель полноценного внедрения этого инструмента и создание процесса работы с секретами вокруг него.

Заключение

Если вы решите применить подобное решение на своих проектах, то особое внимание нужно обратить вот на что:

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

  • Аутентификационный токен для доступа к API Vault (тот что в env-переменной VAULT_TOKEN), нужно тоже безопасно доставлять до приложения. Это тема отдельной статьи, если бы я начал рассказывать и об этом, статья превратилась бы в чудовищный лонгрид.

  • Если вас напрягает то, что каждые 32 дня ваше приложение будет перезапускаться из-за достижения максимального срока аренды, для max_lease_ttl придётся выставить значение побольше или написать механизм переключения коннектов на новые учётные данные.

Источник: https://habr.com/ru/company/quadcode/blog/565690/


Интересные статьи

Интересные статьи

Работая над приложением, связанным с финансовыми операциями, возникла необходимость распознать и выделить суммы на чеках. Начиная с 13-ой версии в IOS-разработке появился...
Привет, Хабр! Меня зовут Илья Селицер. В DINS мы участвуем в разработке продукта для UCaaS-провайдера RingCentral, которая объединяет много функций — от звонков и факса до корпоративного ...
Статья о том, как упорядочить найм1. Информируем о вакансии2. Ведём до найма3. Автоматизируем скучное4. Оформляем и выводим на работу5. Отчитываемся по итогам6. Помогаем с адаптацией...
Привет, Хабр. Недавно мне выпала возможность потестировать платежный терминал с возможностью бесконтактной оплаты. Ну а раз оплата бесконтактная, значит сигнал передается по радио,...
Несколько дней назад, я решил провести реверс-инжиниринг прошивки своего роутера используя binwalk. Я купил себе TP-Link Archer C7 home router. Не самый лучший роутер, но для моих нужд вполне ...