Над менеджментом секретов нужно задумываться с самого начала работы над проектом. Кто-то может со мной не согласиться, ведь на стадии разработки возникает куча первостепенных задач, и проблема хранения секретов уходит на второй план. Попытаюсь развеять ваши сомнения на примере собственного опыта.

Меня зовут Сергей Киммель, я PHP Developer, Golang Developer и руководитель отдела разработки торгового движка. Сегодня поделюсь опытом своей команды в работе с секретами. Расскажу о проблемах, с которыми мы столкнулись, и об их решении. Дам варианты организации работы с секретами и покажу пример организации Golang-кода.

Наша история про набитые шишки

Как всё начиналось, и к чему это привело

Наш проект стартовал в далёком 2013 году. Основная разработка велась в одном Git-репозитории, в основном на PHP. В репозитории лежали все секреты, связанные с нашими API и БД. Проект активно рос, мы пилили фичи. Задумываться, где и как хранить секреты, времени не было, и мы не считали это проблемой. Нам было важнее быстро катить в прод.

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

Другую часть репозитория мы попутно переписали на Golang. Там вынесли секреты в переменные окружения. Стало чуть лучше, но всё равно при появлении нового микросервиса нужно было копипастить какие-то секреты в свежий репозиторий. Мы начинали терять контроль над тем, где какие секреты лежат. Завели Excel-документ, в котором вели учёт. Но эта инициатива быстро заглохла, потому что мы не могли найти ответственного за актуализацию файла. 

По сути, больших проблем с секретами мы так и не замечали. Так было до тех пор, пока кто-то не слил наши credentials к одной из API.

Кто-то слил наши credentials

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

Здесь и начались основные проблемы. Мы не знали:

  1. в каких сервисах какие секреты используются;

  2. как их быстро поменять, чтобы не поломать вообще всё.

Благодаря метрикам и логам мы выявили сервисы, которые вышли из строя из-за того, что упёрлись в лимиты. Прописали туда новые секреты и перезапустили эти сервисы. Стало легче, отпустило. Мы немного выдохнули, и решили, что пора отзывать старые credentials, что мы и сделали. После этого ещё пара сервисов упали. Они не упирались в лимиты, и мы не знали, что сервисы использовали старые секреты.

Тогда мы заменили их на новые, сделали перезапуск. Наконец, ближе к вечеру, руки перестали трястись, мандраж прошёл. Почти весь день сервис не выполнял свои функции, а это большие потери для бизнеса. Мы точно поняли, что пора наводить порядок в менеджменте секретов, потому что с ростом проекта решать такие инциденты будет всё сложнее. 

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

Как мы исправили ситуацию

Подняли центральное хранилище секретов, так появилась возможность их контролировать

Мы выбрали Vault версию HA (high ability) от HashiCorp. Развернули, заранее договорились с коллегами, как будем именовать секреты. Правило простое — в начале имени секрета обязательно должно присутствовать имя сервиса, который его использует. Такая договорённость позволила нам создать связующее звено между сервисом и секретом, который он использует. Этого сильно не хватало при прошедшем инциденте.

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

Мы проверили, насколько помогает новое решение, и воспроизвели искусственно ситуацию, что кто-то опять слил credentials к одному из API.

Обновили credentials — результат нас устроил, несмотря на ручной перезапуск сервисов

Мы выбрали один API в качестве испытуемого, выпустили новые credentials, прошлись по всем ключам в хранилище секретов. Нашли, где эти credentials скомпрометировано использовались, обновили там секреты на новые значения и перезапустили сервисы. Они стартанули, всё хорошо. Потом отозвали старые credentials, сервисы не вышли из строя. Везде всё обновилось, переподтянулось.

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

Добавили проверку обновления секрета: Auth-токен — тот же секрет, которым нужно управлять

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

Тут мы сразу упёрлись в особенности безопасности Vault, ведь его авторотационные токены имеют время жизни. Через 15 минут токен переставал быть валидным, из-за чего мы уже не могли делать запросы к API Vault. Надо было перевыпускать токен или продлевать время его жизни.

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

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

  1. Отказаться от манипуляций с авторизационными токенами и с приложением. Это большая дополнительная работа, которую не нужно пихать в приложение и усложнять его.

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

Даже если бы мы сделали то, что хотели выполнить ранее, не было бы 100% уверенности, что все сервисы получили новые секреты, что секреты везде обновились, и ничего нового стартовать не надо.

Организация работы с секретами — наши варианты

Из опыта, делюсь вариантами организации работы с секретами. Все они использовались нами ранее или применяются в настоящее время. У каждого варианта есть свои плюсы и минусы. 

  1.  Хранить в переменных CI

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

Каждое окружение в CI-тулинге — интерфейс или конфиг — нуждается в прописывании секретов. Уже при деплое приложения CI-тулинг кладёт в контейнер или выносит на сервер вместе с приложением сами секреты. Ваше приложение использует их, даже не подозревая о том, что кто-то их туда подложил.

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

Плюсы:

Это хороший стартовый вариант для стартапов. Не нужно поднимать серверы, потому что секреты хранятся в самом CI-тулинге, в его базе или на его серверах. Поэтому дополнительные ресурсы не потребуются.

Минусы:

Корневой минус — когда репозиториев с кодом больше, чем один, начинается дублирование секретов, они лежат в нескольких местах. Чем больше репозиториев, тем сложнее за ними следить. Плюс, если нужно обновить какие-то секреты, придётся бегать по каждому репозиторию, смотреть переменные окружения CI и всё обновлять.

  1. Забирать секреты при деплое

Этот вариант — эволюционный виток предыдущего. Он необходим, когда нужно централизованное хранилище секретов.

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

Плюсы:

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

Минусы:

Когда появляется отдельное хранилище секретов, под него нужно разворачивать и поддерживать серверы. Это тоже ресурсы, но они того стоят. Лучше немного потратиться, чтобы потом не заплатить ещё большую цену.

Также тем, кто использует публичные CI, например, Travis CI, CircleCI и т.п. надо ломать голову, как безопасно открыть ваше хранилище секретов во внешний мир. Ведь оно должно храниться внутри инфраструктуры и быть максимально защищённым от внешнего воздействия.

  1. Kubernetes/Docker secrets

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

 

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

Плюсы:

Секреты лежат в одном месте, изменений в приложении не требуется. На схеме видно, что приложение никак не взаимодействует с хранилищем. Для него всё работает, как и прежде, — кто-то подкладывает секреты либо в конфиг, либо в переменную окружения. Это тоже удобный вариант.

Минусы:

Нужно поднимать и поддерживать хранилище секретов. Также все сервисы должны работать в Kubernetes/Docker. Без этого вы теряете преимущества данного варианта, ведь что-то будет работать в Kubernetes, а что-то — нет. Придётся частично применять разные варианты, что создаст свои неудобства.

  1. Приложение забирает из хранилища

Это самый навороченный вариант. Здесь вы получаете максимальную гибкость работы с хранилищем секретов. 

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

Плюсы:

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

Минусы:

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

Лучше договориться в рамках компании, какие возможности вы будете использовать, и оформить это в виде отдельной библиотеки, в сервисах вашей компании. Иначе можно развести у себя огромный зоопарк.

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

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

Организация Golang-кода

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

Получение секрета

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

package secret

import (
    "context"
    "errors"
)

var (
    ErrNotFound = errors.New("secret not found")
)

type Getter interface {
    GetSecretBytes(ctx context.Context, secretName string) (Bytes, error)
}

type Bytes []byte

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

Метод GetSecretBytes возвращает специальный тип, объявленный ниже. Это сделано намеренно, не по ошибке. Метод GetSecretBytes лучше вызывать непосредственно в том месте, где вы хотите использовать секрет. Следуйте принципу: потребовался секрет, вызвали метод, получили значение секрета, воспользовались им в том месте, где он нужен. Сохранять его в глобальной переменной, а потом использовать — не лучший вариант. Иначе механика обновления секретов будет сильно хромать.

Защита от утечки секретов

func (b Bytes) String() string {    
  return "sensitive data"
}

func (b Bytes) Bytes() []byte {
  return b
}

Тип Bytes реализовывает простую защиту от утечки секретов в логе. Чем плох обычный []byte или string — если он нечаянно попадёт в логи, то может постепенно утечь куда-то вовне. Поэтому сделан специальный тип Bytes, который имплементирует здесь интерфейс Stringer. При попадании в форматер логера вместо содержимого секрета просто залогирует фразу “sensitive data”.

Если действительно нужно получить значение секрета, то есть второй метод Bytes, который возвращает уже непосредственно []byte. Его тоже нужно вызывать только в месте использования секрета. Нежелательно куда-то сохранять его значение, чтобы ваш секрет нечаянно куда-то не утек.

Это только пример, а не законченное решение. На просторах GitHub есть много вариантов реализации, вплоть до шифрования в памяти. Пример показывает, какие есть риски, что может случиться с вашими секретами, если с ними работать, как с []byte.

Слежение за секретами

Если вы хотите, чтобы приложение умело реагировать на изменение секретов, то лучше ввести в ваш код небольшую абстракцию. Так потом будет проще переходить с одного варианта на другой.

type Watcher interface {

	WatchSecretBytes(

		ctx context.Context,

		secretName string,

	) (

		secretBytesC <-chan Bytes,

		stop func(),

		err error,

	)

}

Здесь это интерфейс Watcher, который имеет всего один метод WatchSecretBytes. Он принимает на вход имя секрета и на выходе возвращает три выходных параметра. Они значат:

  1. Канал, в который прилетает текущее или новое значение секрета. 

Как раз из него можно читать в отдельной горутине, в зависимости от того, как вам удобнее, и производить какие-то действия, связанные с обновлением секрета. Это может быть обновление коннекта пулов или просто обновление клиента по работе с API. Всё зависит от вашей бизнес-логики.

  1. Функция остановки watcher. 

Когда вы понимаете, что больше не нужно следить за секретом, можно вызвать эту функцию. Она произведёт все операции, чтобы watcher перестал следить за секретом. Чаще всего, это вызывается по завершению приложения, но может у вас более гибкие механизмы его работы. Поэтому в интерфейсе это тоже постарались учесть.

  1. Классический параметр — ошибка.

 Если в создании watcher что-то пошло не так, то ошибка будет возвращена.

Поведение этого метода ожидает, что когда watcher будет создан, в канал сбросится текущее значение секрета. Это удобно, потому что обычно при старте приложения вы будете создавать watcher. После этого нужно получить текущее значение секрета. Это можно сделать просто синхронным вычитыванием из канала без создания отдельной горутины. А уже когда вы инициализировали приложение, можете запустить на своей горутинке, которая из этого канала уже будет получать новые значения секретов, если они будут появляться.

Выводы

Итак, самое полезное, из того, что я рассказал:

  1. Над менеджментом секретов нужно задумываться с самого начала проекта.

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

  1. Нужно выбирать тот вариант, который подходит уровню технического и административного развития вашего проекта.

Не нужно стрелять из пушки по воробьям и брать самый навороченный и крутой вариант. Возможно, вам не пригодятся все его плюсы, а получите ещё и его минусы. Поэтому советую взвесить все ЗА и ПРОТИВ каждого решения и понять, насколько оно подходит вашему проекту.

  1. Нужно уделять внимание тому, как ваше приложение реагирует на обновление секретов.

Иначе можно оказаться в ситуации, что после обновления секретов в хранилище придётся сидеть и думать — блин, а точно ли везде всё обновилось? Это неприятное ощущение. Особенно когда продакшен  горит из-за того, что «протух» секрет или ещё что-то случилось. Уделите внимание этому моменту, так можно сэкономить нервные клетки.

Обновление секрета, даже просто реагирование за счёт рестарта приложения — это тоже вариант. У нас такое работает, главное договориться об этом. Важно, чтобы у вас не было разногласий.

  1. Нужно использовать вспомогательные маскирующие типы при работе с секретами вместо обычных []byte или string.

Не используйте чистые байты и строки, есть риск, что ваши секреты утекут туда, куда не следует. На GitHub много библиотек, которые эту механику уже реализуют.

  1. Нужно именовать секреты так, чтобы по названию было понятно, в каком сервисе они используются.

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

Еще больше самых актуальных материалов можно увидеть на конференции HighLoad++ 2022 - 24 и 25 ноября в Москве. Посмотреть программу докладов и приобрести билеты можно на официальном сайте конференции.