Секреты в 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 расшифровывает файлы с секретами (каждый день). Зачем не шифровать файлы сразу мастер-ключом? Три причины.

  1. sops умеет age «из коробки», а Yandex Cloud KMS — нет. Для AWS, GCP и Azure у sops есть встроенная поддержка, и там двухуровневая схема не обязательна (об этом ниже), но она всё равно удобна.
  2. Повседневная работа не зависит от облака: расшифровал ключ один раз — и дальше sops работает офлайн, без токенов и без сетевых вызовов на каждый файл.
  3. Ключ 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 возвращает и те значения, с которыми этот тег деплоился.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.