Секреты в git: sops и age с мастер-ключом в Yandex Cloud KMS или на парольной фразе
Секреты проекта — пароль базы, ключи подписи токенов, секреты OAuth-клиентов, SMTP — хочется держать рядом с кодом: в том же репозитории, с той же историей, в тех же ветках. Но в открытом виде им в git не место. В этой статье — схема, которой я пользуюсь для блога: файлы с секретами лежат в репозитории зашифрованными sops на ключе age, а сам ключ age хранится там же, зашифрованный мастер-ключом. Разберу два варианта мастер-ключа — в Yandex Cloud KMS и без облака, на парольной фразе, — а в конце пройдусь по альтернативам: Google Cloud, AWS, Azure, Vault, аппаратные ключи.
Инструменты
sops (Secrets OPerationS) шифрует не файл целиком, а значения в нём. Ключи YAML, JSON, .env остаются открытыми, и в git diff видно, какой секрет изменился, хотя и не видно, на что. Вот фрагмент зашифрованных значений чарта блога, как он лежит в git:
database:
password: ENC[AES256_GCM,data:lPFUNnhq…UAQ=,iv:baYT…,tag:V9NF…,type:str]
rabbitmq:
url: ENC[AES256_GCM,data:85G9nSGK…,iv:0o88…,tag:dSEE…,type:str]
sops:
age:
- recipient: age1<публичный-ключ>…
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
…
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-04T14:36:59Z"
mac: ENC[AES256_GCM,data:…]
version: 3.11.0
Устроено это так: sops генерирует случайный ключ данных, шифрует им каждое значение (AES-256-GCM), а сам ключ данных шифрует для каждого получателя из секции sops: — в примере получатель один, ключ age. Внизу — MAC всего файла: подмену или удаление значения sops заметит.
age — современная замена PGP для шифрования файлов: один бинарник, одна команда, ключи в одну строку. Пара ключей выглядит так:
# created: 2026-10-01T14:09:00+03:00
# public key: age1<публичный-ключ>…
AGE-SECRET-KEY-1…
Публичная половина (age1…) — получатель, на которого шифруют; она не секретна и записывается прямо в настройки репозитория. Секретная (AGE-SECRET-KEY-1…) расшифровывает, и вот её хранить где-то надо.
Оба инструмента ставятся из релизов на GitHub (getsops/sops, FiloSottile/age) или через scoop/winget/brew. Проверка:
sops --version
age --version
age-keygen --version
Общая схема
Независимо от того, где лежит мастер-ключ, в репозитории получается одно и то же:
repo/
├── age.agekey.enc ← секретный ключ age, зашифрованный мастер-ключом
├── decrypt.cmd ← мастер-ключ → %USERPROFILE%\sops\blog-age.agekey
├── encrypt.cmd ← обратно (нужен раз, при создании ключа)
├── src/
│ ├── .env.enc ← секреты dev-стенда для docker compose
│ ├── .env ← расшифровано, в .gitignore
│ ├── decrypt.cmd
│ └── encrypt.cmd
└── charts/
├── .sops.yaml ← получатель: публичный ключ age
├── secrets.enc.yaml ← секреты продакшена для Helm
├── secrets.dec.yaml ← расшифровано, в .gitignore
├── decrypt.cmd
└── encrypt.cmd
Два уровня: мастер-ключ расшифровывает ключ age (один раз на машине), а ключ age расшифровывает файлы с секретами (каждый день). Зачем не шифровать файлы сразу мастер-ключом? Три причины.
- sops умеет age «из коробки», а Yandex Cloud KMS — нет. Для AWS, GCP и Azure у sops есть встроенная поддержка, и там двухуровневая схема не обязательна (об этом ниже), но она всё равно удобна.
- Повседневная работа не зависит от облака: расшифровал ключ один раз — и дальше
sopsработает офлайн, без токенов и без сетевых вызовов на каждый файл. - Ключ age — это то, что отдаётся CI: одна строка в секретах GitHub Actions. Доступ к облачному KMS для CI настраивать не нужно.
Соглашение об именах простое: *.enc.* — в git, *.dec.* и .env — в .gitignore. Ошибиться с git add невозможно.
Вариант 1: мастер-ключ в Yandex Cloud KMS
Подходит, когда над проектом работают несколько человек или машин: доступ к ключу выдаётся и отзывается ролями в облаке, а не передачей пароля.
Ключ в KMS. Нужен Yandex Cloud CLI (yc) с настроенным профилем. Создаём симметричный ключ:
yc kms symmetric-key create --name blog-master --default-algorithm aes-256
В ответе будет id ключа — его и используем в скриптах. Имя blog-master удобно для людей, но CLI ищет ключ по имени в папке активного профиля, а по ID — где угодно, поэтому в скриптах лучше ID.
Доступ к ключу — роль kms.keys.encrypterDecrypter на ключ для каждого, кому нужно расшифровывать. Это и есть управление доступом ко всем секретам проекта: отозвали роль — человек больше не может получить ключ age. (Уже полученный ключ у него, конечно, останется; об этом в разделе про ротацию.)
Скрипт шифрования ключа age, encrypt.cmd в корне репозитория. Создаёт ключ age, если его нет, и шифрует его в KMS:
@echo off
rem Encrypts %USERPROFILE%\sops\blog-age.agekey with the blog-master key in Yandex Cloud KMS
rem into age.agekey.enc. Generates the age key first if it does not exist.
setlocal
set "KMS_KEY_ID=<id ключа>"
set "KEY_DIR=%USERPROFILE%\sops"
set "KEY_FILE=%KEY_DIR%\blog-age.agekey"
if not exist "%KEY_DIR%" mkdir "%KEY_DIR%"
if not exist "%KEY_FILE%" (
age-keygen -o "%KEY_FILE%" || exit /b 1
)
yc kms symmetric-crypto encrypt --id %KMS_KEY_ID% --plaintext-file "%KEY_FILE%" --ciphertext-file "%~dp0age.agekey.enc"
exit /b %ERRORLEVEL%
Получившийся age.agekey.enc (несколько сотен байт) коммитится. Публичный ключ из вывода age-keygen пригодится на следующем шаге.
Скрипт расшифровки, decrypt.cmd рядом:
@echo off
setlocal
set "KMS_KEY_ID=<id ключа>"
set "KEY_DIR=%USERPROFILE%\sops"
set "KEY_FILE=%KEY_DIR%\blog-age.agekey"
if not exist "%KEY_DIR%" mkdir "%KEY_DIR%"
yc kms symmetric-crypto decrypt --id %KMS_KEY_ID% --ciphertext-file "%~dp0age.agekey.enc" --plaintext-file "%KEY_FILE%"
exit /b %ERRORLEVEL%
На новой машине: установить yc, войти, выполнить decrypt.cmd — и ключ age лежит в %USERPROFILE%\sops. Больше облако не нужно.
Вариант 2: без облака, на парольной фразе
Для личного проекта или одного разработчика облако избыточно. У age есть режим с парольной фразой (-p): файл шифруется ключом, выведенным из пароля через scrypt. Применяем его к тому же ключу age:
age-keygen -o ~/sops/blog-age.agekey
age -p -o age.agekey.enc ~/sops/blog-age.agekey
age спросит фразу дважды (или предложит сгенерировать). Расшифровка на другой машине:
age -d -o ~/sops/blog-age.agekey age.agekey.enc
Те же два скрипта, что в первом варианте, с заменой yc kms … на age -p / age -d. Всё остальное — .sops.yaml, файлы *.enc.*, CI — не меняется, и перейти с пароля на KMS (или обратно) можно в любой момент: это один перешифрованный файл age.agekey.enc.
Фразу храните в менеджере паролей. Она — единственное, что отделяет содержимое репозитория от всех секретов проекта, так что генерируйте её, а не придумывайте: age предлагает фразу из случайных слов, если оставить поле пустым.
Можно и совсем без промежуточного файла: sops умеет работать с зашифрованным ключом age напрямую, если указать его в SOPS_AGE_KEY_FILE, — тогда фразу спросят при каждом вызове. Мне удобнее расшифровать ключ один раз на машине.
Шифруем секреты
Дальше оба варианта одинаковы. В папке с секретами — .sops.yaml с правилом: какие файлы на кого шифровать.
creation_rules:
- path_regex: .*\.enc\.(yaml|json)$
age: age1<публичный-ключ>…
Редактируем secrets.dec.yaml обычным редактором и шифруем:
export SOPS_AGE_KEY_FILE=~/sops/blog-age.agekey
sops --encrypt --filename-override secrets.enc.yaml --output secrets.enc.yaml secrets.dec.yaml
sops --decrypt --output secrets.dec.yaml secrets.enc.yaml
Два неочевидных ключа:
--filename-override— sops выбирает правило из.sops.yamlпо имени зашифрованного файла, а мы подаём ему*.dec.yaml, под котороеpath_regexне подходит.--outputвместо перенаправления>— если расшифровка не удалась, перенаправление оставит пустой файл поверх прежнего открытого текста, а--outputничего не тронет.
Для .env docker compose нужен тип dotenv, и у него есть особенность: парсер sops не принимает CR, поэтому файл должен быть с переводами строк LF даже на Windows.
sops --input-type dotenv --output-type dotenv --encrypt --age age1<публичный-ключ>… --output .env.enc .env
sops --input-type dotenv --output-type dotenv --decrypt --output .env .env.enc
У меня это завёрнуто в encrypt.cmd/decrypt.cmd в каждой папке с секретами; скрипт чарта обходит все *.dec.yaml и *.dec.json. Одна деталь, которую стоит повторить: sops при каждом шифровании берёт новый ключ данных и новые IV, поэтому перешифрованный файл всегда отличается от предыдущего, даже если секреты не менялись. В git это шум: коммит «изменил пароль базы» выглядит так же, как коммит «ничего не менял, перешифровал». Скрипт шифрования у меня сначала расшифровывает текущий *.enc.* во временный файл, сравнивает с *.dec.* и шифрует заново только то, что изменилось:
sops --decrypt --output "%TEMP%\compare.tmp" secrets.enc.yaml
fc /b "%TEMP%\compare.tmp" secrets.dec.yaml >nul && set "SKIP=1"
Альтернатива для правок прямо в зашифрованном файле — sops edit secrets.enc.yaml: откроет расшифрованный текст в $EDITOR и перешифрует только изменившиеся значения. Это и есть штатный способ, при котором diff остаётся честным; *.dec.* мне нужны ради helm -f и docker compose, где файл должен лежать открытым.
CI
Деплой блога делает GitHub Actions: собирает образы и выполняет helm upgrade с секретами из charts/secrets.enc.yaml. Для этого runner'у нужен только ключ age — содержимое blog-age.agekey одной строкой в секрете репозитория SOPS_AGE_KEY. Ни yc, ни парольная фраза в CI не нужны:
- name: Install sops
run: |
curl -sSfL -o sops "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.amd64"
sudo install -m 0755 sops /usr/local/bin/sops
- name: Prepare secrets
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: |
set +x
umask 077
sops --decrypt --output "$RUNNER_TEMP/secrets.dec.yaml" charts/secrets.enc.yaml
- name: Deploy
run: helm upgrade --install prod chart -f "$RUNNER_TEMP/secrets.dec.yaml" --rollback-on-failure --wait
SOPS_AGE_KEY sops читает из переменной окружения напрямую, файл создавать не нужно. set +x и umask 077 — чтобы расшифрованное не попало в лог и было читаемо только владельцу; шаг Cleanup с if: always() удаляет файл после деплоя.
Несколько получателей и ротация
Пока получатель один, схема «ключ age + мастер-ключ» работает как общий ключ команды. Но sops позволяет перечислить несколько получателей, и тогда у каждого разработчика может быть свой ключ age, а мастер-ключ становится не нужен вовсе:
creation_rules:
- path_regex: .*\.enc\.(yaml|json)$
age: >-
age1aaaa…,
age1bbbb…,
age1cccc…
Ключ данных шифруется для каждого, расшифровать может любой. Добавили человека — дописали его публичный ключ и выполнили sops updatekeys secrets.enc.yaml: sops перешифрует ключ данных, не трогая значения. Убрали — то же самое, но секреты после этого надо сменить: ушедший уже их видел, и никакая криптография этого не отменит. То же относится к отозванной роли в KMS и к смене парольной фразы: ротация ключа защищает будущие секреты, а не прошлые.
Для совсем больших команд у sops есть key_groups с порогом по Шамиру: расшифровать можно, только собрав ключи из нескольких групп. Для блога это перебор, но знать полезно.
Что ещё можно взять в мастер-ключи
Yandex Cloud я выбрал потому, что там у меня остальная инфраструктура. У sops есть встроенная поддержка других хранилищ ключей, и с ними двухуровневая схема не обязательна: ключ KMS можно указать прямо в .sops.yaml, и sops сам сходит в облако при каждой операции.
Google Cloud KMS. Создаём ключ и указываем его в правиле:
gcloud kms keyrings create blog --location global
gcloud kms keys create master --keyring blog --location global --purpose encryption
gcloud auth application-default login
creation_rules:
- path_regex: .*\.enc\.(yaml|json)$
gcp_kms: projects/<project>/locations/global/keyRings/blog/cryptoKeys/master
Доступ — роль roles/cloudkms.cryptoKeyEncrypterDecrypter. Можно и по моей схеме: gcloud kms encrypt --plaintext-file blog-age.agekey --ciphertext-file age.agekey.enc … и дальше age, как с Яндексом; тогда облако нужно только при первой настройке машины, а CI обходится ключом age.
AWS KMS. Самая старая интеграция sops: kms: arn:aws:kms:eu-central-1:123456789012:key/… в правиле, доступ через IAM и обычную цепочку учётных данных AWS. Можно перечислить несколько ключей в разных регионах — файл расшифруется, пока доступен хотя бы один.
Azure Key Vault. azure_kv: https://<vault>.vault.azure.net/keys/<key>/<version>, вход через az login или управляемую идентичность.
HashiCorp Vault. hc_vault_transit_uri: https://vault.example.com/v1/transit/keys/sops — движок transit, токен из VAULT_TOKEN. Вариант для тех, у кого Vault уже есть; заводить его ради sops не стоит.
PGP. sops его поддерживает, и много статей написано именно про PGP, но сегодня это худший выбор: громоздкие ключи, агент, сроки действия, подписи — ничего из этого для шифрования файлов не нужно. age появился как раз в ответ на это.
Аппаратные ключи. Для age есть плагины: age-plugin-yubikey держит секретный ключ на YubiKey, age-plugin-tpm — в TPM компьютера; получатели для них начинаются с age1yubikey1… и age1tpm1…. Ключ в принципе не покидает устройство, зато CI с таким ключом не поработает — ему оставляют отдельный обычный ключ age как ещё одного получателя.
Менеджер паролей. Самый простой вариант для одного человека: секретный ключ age хранится в 1Password/Bitwarden/KeePass, а на машину копируется руками. По сути это вариант 2 без файла age.agekey.enc в репозитории. Минус — на новой машине нужен доступ к хранилищу паролей, которого у CI или у коллеги нет.
Как выбирать
- Один человек, пара машин — парольная фраза (вариант 2) или ключ в менеджере паролей. Ноль инфраструктуры.
- Несколько человек или сервисов, уже есть облако — KMS того облака, где живёт проект: напрямую в sops (AWS/GCP/Azure) или через зашифрованный ключ age (Yandex Cloud и всё, чего sops не знает).
- Команда, где у каждого свой ключ, — несколько получателей age и
sops updatekeys; мастер-ключ не нужен.
В любом варианте в репозитории лежат .sops.yaml, файлы *.enc.* и, если мастер-ключ есть, age.agekey.enc с парой скриптов. Новый разработчик выполняет одну команду и получает все секреты, которые ему положены, а CI обходится одной строкой в секретах. И главное — секреты версионируются вместе с кодом: откат на тег v4.0.12 возвращает и те значения, с которыми этот тег деплоился.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.