Перейти к содержимому

dwe secrets — зашифрованные значения, закоммиченные в репозиторий

dwe secrets хранит значение, которое разделяет вся команда, — токен бота, JSON сервис-аккаунта — зашифрованным в git. Маркеры расшифровываются в памяти при загрузке конфига и никогда не записываются обратно в отслеживаемый файл в открытом виде.

Одна пара ключей X25519 age на проект:

  • Публичный recipient (age1…) коммитится в workspace.yml под secrets.recipient. Для шифрования больше ничего не нужно, поэтому любой, у кого есть репозиторий, может добавить секрет.
  • Приватный identity (AGE-SECRET-KEY-1…) никогда не попадает в git. Он живёт в ~/.config/dwe/keys/<recipient>.key либо в DWE_AGE_KEY / DWE_AGE_KEY_FILE для CI. Прочитать секрет могут только держатели identity.

Расшифровка происходит один раз, в загрузчике конфига, поэтому ${vars.*}, exports.env, dwe vars, хеш деплоя и любой другой потребитель объединённого конфига видят открытый текст.

Формат — age. Payload маркера — это age-файл, обёрнутый в base64: снимите обёртку ENC[age:…], раскодируйте payload из base64 — и CLI age его прочитает. См. Совместимость с CLI age.

Скалярные значения. Любая строка в любом слое конфига (workspace.yml, workspace/defaults.yml, workspace/local.yml) может быть маркером ENC[age:…]:

# workspace/defaults.yml — отслеживается git
vars:
telegram:
token: ENC[age:YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUx…]

Грамматика строгая: ENC[age:<base64>], весь скаляр целиком или ничего. Строка, которая просто содержит ENC[, — обычные данные, её не трогают.

Проход расшифровки не зависит от пути, но dwe secrets set пишет только под vars. — единственную свободную песочницу в схеме со строгим корнем. См. workspace.md → Строгий корень + песочница vars:.

Целые файлы. Источник конфиг-пака, чей from: заканчивается на .age, — нативный зашифрованный age-файл. render config расшифровывает его перед обычным рендером ${...} в hub-директорию сервиса:

workspace/templates/config/bot/manifest.yml
render:
- from: google-credentials.json.age
to: config/google-credentials.json

Путь to: никогда не выводится из источника. См. render config.

# workspace.yml — только слой 1
secrets:
recipient: age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3fgh2p
ПолеТипОписание
secrets.recipientstringПубличный age-recipient проекта (age1…). Коммитится.

secrets: легален только в workspace.yml — объявление его в workspace/defaults.yml или workspace/local.yml — жёсткая ошибка загрузки с именем файла, потому что recipient «на разработчика» разбил бы команду на группы, которые не могут читать секреты друг друга. Некорректный recipient или значение secrets:, не являющееся мапой, — тоже ошибка загрузки.

dwe использует первый найденный источник:

ПорядокИсточникЗначение
1DWE_AGE_KEYСам текст identity (AGE-SECRET-KEY-1…)
2DWE_AGE_KEY_FILEПуть к файлу с текстом identity
3~/.config/dwe/keys/<recipient>.keyKeyfile проекта, 0600

Первый найденный источник обязан совпасть с настроенным recipient. Несовпадение — это wrong_identity с именем источника; перехода к следующему источнику не происходит.

Директория ключей создаётся с правами 0700 (существующая более свободная ужимается), каждый keyfile пишется с 0600 и O_CREATE|O_EXCL — DWE никогда не перезаписывает файл identity.

Identity читается как первый токен AGE-SECRET-KEY-1… в строке, не являющейся комментарием #, поэтому keyfile от CLI age с заголовком # public key: работает как есть, а файл, где живой ключ лежит под закомментированным старым, разрешается в живой ключ. Более поздний токен игнорируется, а не считается ошибкой. Когда все токены лежат внутри комментариев — вставка склеила заголовок и ключ в одну строку — сканируется весь текст целиком, если только в строке, не являющейся комментарием, не найдётся испорченный AGE-SECRET-KEY-1…: тогда это некорректный ключ, а не чужой.

Окно терминала
# Один раз на проект, на одной машине:
dwe secrets init # сгенерировать пару ключей
git add workspace.yml && git commit # закоммитить recipient
# Добавить секрет (нужен только закоммиченный recipient):
pbpaste | dwe secrets set vars.telegram.token --stdin
git add workspace/defaults.yml && git commit
# Онбординг коллеги — identity передаётся вне репозитория:
dwe secrets key export # → менеджер паролей
# …на другой машине:
dwe secrets key import # скрытый ввод: вставить ключ
dwe secrets key import --file identity.txt # …или прочитать из файла

После этого ${vars.telegram.token} везде резолвится в открытый текст, а dwe vars get vars.telegram.token его печатает.

Клон несёт публичный recipient; приватный identity — нет. Пока его нет на этой машине, любое зашифрованное значение читается как <encrypted>, а любая lifecycle-команда упирается в secrets.unresolved.

Положите его в одно из трёх мест из порядка поиска — первый найденный источник обязан совпасть с recipient.

В терминале, без --file и без пайпа, import спрашивает ключ сам:

$ dwe secrets key import
Private identity for age1qyqs…
Paste the AGE-SECRET-KEY-… line, or the whole keyfile; the typed characters are not echoed.
> ••••••••••••
identity for age1qyqs… stored at /home/dev/.config/dwe/keys/age1qyqs….key
2 encrypted value(s) and 1 .age file(s) are now readable

Поле скрытое и принимает keyfile целиком, вместе с заголовком-комментарием, даже одной склеенной строкой. Проверка идёт внутри формы: ключ, который не парсится, или ключ от другого проекта, показывается без закрытия формы. Esc отменяет с secrets_import_cancelled и без keyfile.

Вторая строка — тот же скан, что рендерит dwe secrets status, поэтому импорт заканчивается отчётом о том, что он открыл.

Когда в проекте есть зашифрованные данные, пригодного identity нет, а за терминалом сидит человек, эти точки входа предлагают ввести ключ:

Enter the private identity now?
This project has encrypted values that need the age identity for age1qyqs…, and
this machine does not have it. run 'dwe secrets key import' to store the identity
at /home/dev/.config/dwe/keys/age1qyqs….key, or set DWE_AGE_KEY / DWE_AGE_KEY_FILE
[ Enter key ] [ Abort ]

Enter key открывает тот же скрытый ввод, и команда продолжается в том же запуске — предложение работает на сырых слоях до загрузки конфига, поэтому конфиг читается один раз и уже расшифрованным. Abort (или Esc) завершает команду подсказкой и ничего не записывает: типизированный secrets_no_identity в меню dwe deploy, та же фраза в dwe run / dwe restart.

  • dwe restart предлагает до того, как что-то остановит, поэтому отказ оставляет стек работающим. dwe restart <сервис> идёт сразу в docker restart и не запускает ни предложение, ни preflight.
  • В dwe deploy предложение стоит на входе в меню, покрывая Plan, а не только Run и мастер.
  • Неинтерактивные точки входа сохраняют жёсткую ошибку: dwe deploy run, dwe reset, dwe render env и dwe render config.

Предложение не открывается, когда отвечать некому: stdin не терминал, --yes (его определяют только dwe run и dwe restart), --output json или DWE_NONINTERACTIVE=1. В каждом случае остаётся прежний отказ — стена preflight secrets.unresolved, — поэтому вывод и код возврата пайплайна не меняются. key import не изменился: identity из пайпа читается как раньше, а на терминале --output json и DWE_NONINTERACTIVE=1 отказывают с secrets_identity_source_required вместо запроса.

Сломанный источник сообщают, а не предлагают починить вводом

Заголовок раздела «Сломанный источник сообщают, а не предлагают починить вводом»

Раз побеждает первый найденный источник и перехода к следующему нет, DWE_AGE_KEY, который не содержит identity этого проекта, импортом ключа не чинится — свежий keyfile не будет прочитан. Гейт называет источник:

$DWE_AGE_KEY is set but does not hold the identity for age1qyqs…;
unset it or fix it — a keyfile is not consulted while it is set

Существующий keyfile с чужим ключом отклоняется так же, с указанием на dwe secrets key remove. Оба отказа срабатывают в любом режиме, интерактивном или нет.

Пишущие команды берут локи проекта (deploy.lock → snapshot.lock) и не запускают preflight — это правки конфига, а не мутации стека. status, get и key export ничего не пишут. decrypt не берёт локи, но пишет файл с открытым текстом. key list и key remove работают с машинной директорией ключей, запускаются вне проекта и берут локи только тогда, когда проект разрешён.

init, set и rekey правят файл слоя, заменяя отдельные строки. Они не переэнкодят документ, поэтому отступы, пустые строки, комментарии, якоря, merge-ключи <<:, стиль кавычек и переводы строк остаются побайтово теми же: один dwe secrets set в размеченный defaults.yml даёт диф в одну строку. Новый ключ вставляется — в конец файла для нового верхнеуровневого блока, иначе после ближайшего существующего маппинга.

Несколько форм нельзя отредактировать на месте, и они отклоняются, файл остаётся нетронутым (secrets_write_unsupported), с указанием пути и способа починки:

Отклонённая формаЧто сделать
Литеральный или свёрнутый блочный скаляр, либо plain/quoted скаляр, перенесённый на несколько строкЗаписать значение в одну строку
Цель внутри flow-коллекции ({a: 1}, [x, y])Записать её как блочную коллекцию
Родитель, который является null (vars: без вложенных ключей), последовательностью или скаляромМатериализовать родителя как блочный маппинг
Родитель, достижимый через YAML-алиас (vars: *common), или ключ, который может наследоваться через merge-ключ <<:Записать ключ в этом маппинге явно

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

dwe secrets init

Генерирует пару ключей X25519, пишет identity в ~/.config/dwe/keys/<recipient>.key и вклеивает secrets.recipient в workspace.yml. Keyfile пишется первым — закоммиченный recipient без читаемого identity закрыл бы проекту доступ к собственным секретам; если запись в workspace.yml затем падает, keyfile удаляется, чтобы повторный запуск не упёрся в запрет перезаписи.

Отказывается работать, если secrets.recipient уже выставлен, ветвясь по тому, доступен ли здесь identity проекта, и сообщая об этом в детали identity:

  • identity: "available" — значения восстановимы, и замена пары ключей — это rekey. Кроме порядка поиска просматривается и директория ключей, поэтому DWE_AGE_KEY, выставленный для другого проекта, не превращает здоровый проект в случай ниже.
  • identity: "missing" — ничего здесь не открывает проект, и rekey не может отработать в принципе (ему нужно прочитать каждое значение, прежде чем переписать его). Отказ сначала предлагает key import, а init --replace-recipient — как восстановление.
dwe secrets init --replace-recipient [--yes]

Выход из ситуации утраченного identity. Команда выпускает новую пару ключей поверх старого secrets.recipient — и это всё: каждый существующий маркер ENC[age:…] и источник *.age остаются на месте, по-прежнему зашифрованные на старый recipient. Новая пара ключей их не открывает, поэтому для проекта они потеряны и возвращаются только повторным вводом.

Это восстановление, а не отзыв доступа. Шифротекст не меняется, так что любой, у кого остался старый identity, по-прежнему читает все эти значения. Когда цель именно отобрать доступ, нужна команда rekey, которая перешифровывает дерево на новую пару ключей.

Осиротевшие значения оставлены намеренно — это запись о том, какие секреты нужно ввести заново. dwe secrets set <path> перезаписывает каждый по месту, dwe secrets status — оставшийся список дел, и secrets.unresolved держит lifecycle-команды остановленными, пока он не пуст. Финальный отчёт называет каждое осиротевшее значение.

Отказывается, пока здесь хоть что-то читается (secrets_identity_available, со счётчиком readable) — это случай rekey. Чтобы всё равно выбросить их — сохраните нужное через dwe secrets get, удалите открывающие их identity через dwe secrets key remove <recipient> --force и запустите снова.

Требуется подтверждение с числом значений на кону; --yes его пропускает, а режим, в котором спросить негде, отказывается с secrets_confirmation_required. Подтверждение выполняется до взятия локов проекта, поэтому открытый запрос не стопорит другие команды dwe; secrets.recipient перечитывается уже под локом, и параллельное изменение отклоняется с secrets_recipient_changed без записи. Старый keyfile не трогается — его удаляет key remove.

В проекте без secrets.recipient флаг отклоняется (secrets_no_recipient).

dwe secrets status

Отчёт, который запускают, чтобы понять, почему что-то заблокировано. Ни одно зашифрованное значение не может его уронить — даже без ключа и с каждым значением нечитаемым он завершается с кодом 0. (Конфиг, который вообще не грузится, — по-прежнему ошибка.)

Показывает настроенный recipient, identity, который есть на этой машине (а когда его нет — где искали), и две инвентаризации: каждый скаляр ENC[age:…] во всех трёх файлах слоёв, как layer / path / state, и каждый *.age под workspace/templates/config/**.

Каждая запись реально расшифровывается, поэтому отчёт различает причины:

СостояниеЗначение
decrypted / decryptableНастроенный identity его читает
decrypted: stale_key / decryptable: stale_keyЧитается здесь, но только старым keyfile
unresolved: no_identityНа этой машине нет identity для этого recipient
unresolved: wrong_identityIdentity найден, но это значение он не открывает
unresolved: invalid_identityИсточник задан, но не содержит ключа age — например, обрезанный DWE_AGE_KEY. Чинить нужно сам источник: пока задана env-переменная, keyfile не читается
unresolved: corruptPayload повреждён — проблема не в ключе

corrupt определяется без ключа (форма маркера, base64, заголовок age), поэтому разработчика без ключа не отправляют искать ключ, который бы не помог.

Наполовину перекеенное дерево описывается по каждому значению: сначала пробуется настроенный identity, затем каждый другой keyfile в директории ключей. Это строки stale_key, показанные жёлтым — загрузчик конфига пробует только настроенный identity, поэтому такое значение на этапе загрузки по-прежнему wrong_identity, и secrets.unresolved продолжает блокировать lifecycle-команды, пока rekey не закончит. Кандидат *.age, не прошедший дисциплину путей (симлинк, устройство), — not decryptable с причиной отказа, а не пропускается молча.

Строки отсортированы (порядок слоёв, затем путь), поэтому вывод стабилен и его можно диффать.

Маркер, который вышележащий слой перебивает плейнтекстом, прекрасно расшифровывается — и всё равно не является тем, что читает проект. Такая строка рендерится жёлтым и несёт перебивающий слой в ячейке состояния:

decrypted (shadowed by workspace/local.yml)

Затенён может быть и unresolved маркер — здесь он проходит как shadow_match: unknown, а в dwe validate secrets как secrets.shadowed:unknown. Во всём остальном плейнтекст его прикрывает: объединённый конфиг резолвится, поэтому в повседневной работе утрата identity больше нигде не проявляется — до dwe render config, которому откатываться не на что.

В --output json затенённая строка несёт два дополнительных поля:

ПолеЗначение
shadowed_byФайл слоя, поставляющий выигрывающий плейнтекст
shadow_match: identicalПеребивающее значение совпадает с маркером — почти всегда копия, оставшаяся после шифрования
shadow_match: differentЗначение другое — осознанный локальный оверрайд
shadow_match: unknownМаркер здесь не расшифровывается, значения не сравнивались

Маркер, перебитый другим маркером, не сообщается: выигравшее значение по-прежнему зашифровано на диске. dwe validate secrets сообщает о том же как о предупреждении — см. secrets.shadowed.

Строка Identity описывает поиск честно — источник, который прочитали и отвергли, никогда не выглядит как отсутствующий ключ:

ЗаголовокЗначение
keyfile (…) / $DWE_AGE_KEY / $DWE_AGE_KEY_FILEIdentity загружен из этого источника
none (looked at …)Identity нет нигде; строка перечисляет все места, где искали
invalid (…)Источник задан, но не содержит ключа age — строка называет источник для починки
wrong recipient (…)Читаемый identity, но другого recipient; строка называет обоих

Если identity не загрузился, отчёт заканчивается инструкцией по починке — той же фразой, что печатает dwe validate. В --output json те же факты структурированы под identity (см. JSON-вывод). Каждая строка написана самим dwe: ошибка разбора age повторяет символы входа, а для сломанного источника identity это байты приватного ключа, поэтому она никогда не печатается.

dwe secrets set <vars.path> [value] [--file defaults|workspace] [--stdin]

Шифрует значение на recipient проекта и записывает его маркером в отслеживаемый файл слоя. Нужен только recipient.

Значение берётся из позиционного аргумента (который попадает в историю шелла), из --stdin (весь stdin, с одним обрезанным завершающим переводом строки и предшествующим \r), либо из скрытого промпта на терминале. Передача и аргумента, и --stdin — это secrets_value_ambiguous.

  • Префикс vars. обязателен, в отличие от dwe vars set.
  • Никакого приведения типов. Значение хранится строкой, поэтому секрет, похожий на число ("123"), останется тем, что вы напечатали. Сравните с dwe vars set, который разбирает аргумент как YAML-скаляр.
  • --file выбирает workspace/defaults.yml (по умолчанию) или workspace.yml. --file local отклоняется с указанием на dwe vars set — workspace/local.yml в gitignore, личное состояние, где шифрование ничего не даёт.
  • Спуск через существующий не-мап-узел (vars.db.host.port, где host — скаляр) отклоняется как неподдерживаемая форма, как и остальные отклонённые формы.
  • Подготовленный документ валидируется как слой конфига до записи, поэтому set не может оставить слой незагружаемым.

Значение получают до взятия локов проекта, поскольку скрытый промпт может висеть сколько угодно; recipient затем перечитывается под локом, поэтому значение не может быть зашифровано на recipient, который параллельный rekey успел вывести из обращения.

Запись меняет только целевую строку. Отсутствующий workspace/defaults.yml создаётся с правами 0644 — только local.yml принудительно переводится в 0600.

dwe secrets get <vars.path>

Расшифровывает маркер по пути и печатает открытый текст. Нужен identity.

get читает слои как они записаны, поэтому сообщает сам секрет, а не объединённое значение: когда маркер затенён плейнтекстовым оверрайдом, dwe secrets get печатает секрет, а dwe vars get — оверрайд, который побеждает в рантайме.

Путь без маркера — это secrets_not_encrypted.

dwe secrets encrypt <file> [--out PATH|-] [--force]
dwe secrets decrypt <file.age> [--out PATH|-] [--force]

Помощники для целых файлов под источники конфиг-паков. encrypt пишет <file>.age рядом со входом; decrypt снимает суффикс .age (входу, который не заканчивается на .age, нужен явный --out). Обе отказываются перезаписать без --force. encrypt нужен только recipient; decrypt нужен identity.

Флаг называется --out, а не -o: корневая команда владеет -o для --output. --out - пишет в stdout и отклоняет --output json (secrets_raw_stream).

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

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

dwe secrets key export
dwe secrets key import [--file|-f PATH]

Identity никогда не лежит в git — он передаётся через менеджер паролей или другой канал вне репозитория.

export печатает AGE-SECRET-KEY-1… в stdout и предупреждает в stderr (только в текстовом режиме), когда stdout — терминал.

import читает identity из --file, из пайпа stdin или из скрытого промпта на терминале, если нет ни того, ни другого. Каким бы ни был источник, он проверяет, что recipient совпадает с настроенным (secrets_identity_mismatch), и только тогда пишет keyfile с правами 0600. Он берёт локи проекта, поэтому импорт не может гоняться с rekey за установкой identity, который выводится из обращения.

Вставляйте либо строку AGE-SECRET-KEY-1…, либо весь keyfile целиком. Промпт валидирует на месте — ключ, который не парсится, или ключ от другого проекта показывается без закрытия формы. Esc отменяет с secrets_import_cancelled. Так как запись идёт с O_EXCL, уже установленный identity сообщается до открытия формы.

Промпт никогда не открывается без терминала: identity из пайпа читается со stdin, пустой stdin — это secrets_identity_source_required, а на терминале --output json и DWE_NONINTERACTIVE=1 отказывают тем же кодом.

Успешный импорт заканчивается тем, что открыл ключ:

identity for age1… stored at ~/.config/dwe/keys/age1….key
2 encrypted value(s) and 1 .age file(s) are now readable

Считаются только значения, которые открывает настроенный identity. Если скан вообще не отрабатывает, импорт всё равно успешен, а вторая строка становится the readability report could not be built: <reason>; JSON тогда опускает markers_readable / files_readable в пользу report_error.

dwe secrets key list

Все identity, установленные на этой машине, отсортированные по имени файла:

Directory — /home/dev/.config/dwe/keys
╭───────────┬───────────────┬────────────────────╮
│RECIPIENT │FILE │STATE │
├───────────┼───────────────┼────────────────────┤
│age1broken │age1broken.key │unparsable │
│age1current│age1current.key│ok (current project)│
│age1locked │age1locked.key │unreadable │
│age1other │age1other.key │ok │
│age1parsed │age1stale.key │misnamed │
╰───────────┴───────────────┴────────────────────╯
СостояниеЗначение
okФайл содержит тот age-identity, который заявлен его именем
unreadableФайл не удалось прочитать (права, директория, битая ссылка)
unparsableВ файле нет age-identity
misnamedПарсится, но принадлежит другому recipient, чем говорит имя файла — строка показывает распарсенный recipient

Директория ключей общемашинная, а не проектная, поэтому ничего не удаляется автоматически: ключ здесь может принадлежать любому другому проекту. Единственное отношение, которое называет list, — строка этого проекта, помеченная current project. Вне проекта не помечается ничего, и команда всё равно работает. Пустая или отсутствующая директория печатает No identities in <dir>. и завершается с кодом 0.

Состояния — фиксированный словарь: текст ошибки ввода-вывода или парсера в вывод не попадает, потому что оба повторяют содержимое файла. Для unreadable и unparsable в колонке RECIPIENT — основа имени файла, а не что-то прочитанное из файла.

dwe secrets key remove <recipient> [--force] [--yes|-y]

Удаляет ~/.config/dwe/keys/<recipient>.key. Аргумент называет файл, поэтому файл misnamed удаляется под своим собственным именем; попытка нацелиться на recipient, который для него показывает key list, — это secrets_key_not_found, как и отсутствующий файл.

Удаление файла, который СОДЕРЖИТ identity текущего проекта, отклоняется (secrets_key_in_use), пока не передан --force. Проверка читает файл, а не его имя, поэтому покрывает и misnamed-файл, несущий ключ этого проекта. Файл, который ничего не открывает — без age identity, с чужим ключом или битым симлинком, — удаляется без --force.

Файл, чьи байты не удаётся прочитать, отклоняется (secrets_key_unreadable) до --force: на удаление файла право чтения не нужно, так что пропустить его означало бы стереть материал, который никто не признал мёртвым.

Иначе удаление подтверждается интерактивно. Там, где спросить нельзя — нет терминала, --output json, DWE_NONINTERACTIVE=1, — это secrets_confirmation_required, и путь дальше — --yes; отказ печатает kept <path> и завершается с кодом 0. Локи проекта держатся вокруг удаления, когда проект определён.

dwe secrets rekey

Генерирует новую пару ключей и перешифровывает на неё каждый закоммиченный секрет — каждый маркер в файлах слоёв и каждый источник *.age. Запускайте, когда identity мог утечь или когда его держатель больше не должен читать секреты проекта.

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

Порядок — это восстановимая последовательность, а не транзакция:

  1. Проход только на чтение — расшифровать и провалидировать всё в память, затем прогнать каждую правку файла слоя на черновой копии. Повреждённый маркер, нерасшифровываемый .age или маркер, чью YAML-форму нельзя переписать на месте, прерывает команду здесь, не записав ничего.
  2. Записать новый keyfile — первая мутация. Старый keyfile сохраняется.
  3. Перешифровать каждый файл .age (атомарно) и каждый файл слоя (по одной вклеенной строке на маркер).
  4. Обновить secrets.recipient последним.

Падение посередине оставляет на диске оба identity и смешанное дерево; повторный запуск rekey сводит его. Отказы после шага 2 говорят об этом явно, и JSON-конверт отличает их от отказов на проходе чтения (те несут written: false).

Удалите старый keyfile, когда каждый разработчик импортирует новый identity.

Проект, чьи секреты здесь не расшифровываются, всё равно загружается. Маркеры остаются в конфиге литералами, и:

ПоверхностьПоведение
dwe status, dwe docs, dwe validate, dwe prompt, dwe commandsРаботают штатно
dwe vars list / get / inspect, TUI-браузерПоказывают <encrypted> — никогда шифротекст
dwe secrets statusСообщает про каждое значение и причину; код возврата 0
dwe run, dwe restart, dwe deploy, dwe reset, dwe stop, мастер деплояБлокируются preflight-валидатором secrets.unresolved. В терминале dwe run / dwe restart и меню dwe deploy сначала предлагают ввести identity
dwe render env, dwe render configПадают, называя значение, которое было бы записано
dwe render ide / ai / git / workspaceРаботают — рендерят по санитизированному конфигу и выдают маркер

Отсутствие ключа никогда не отрендерит секрет как "" и никогда не запишет маркер в runtime .env или вывод конфиг-пака. Санитизированные паки могут выдавать закоммиченный маркер.

dwe stop блокируется, потому что выполняет stop-хуки lifecycle.yml — обычные пользовательские команды, которые могут ссылаться на ${vars.*}; он делит стадию preflight stop с dwe reset. --skip-preflight гасит стек на машине без ключа.

Зашифрованные project.name / project.prefix горячий путь dwe prompt считает невыставленными — у него свой мягкий парсер, полный конфиг он не грузит. Маркер в фильтре compose-меток не совпал бы ни с одним контейнером.

Защита вывода: маркер никогда не попадает в отрендеренный runtime-файл

Заголовок раздела «Защита вывода: маркер никогда не попадает в отрендеренный runtime-файл»

Два рендерера работают без preflight (dwe render env, dwe render config), а dwe run рендерит .env до своего preflight, поэтому они соблюдают политику сами:

  • .env — проверяется каждое выдаваемое значение: системные переменные (PROJECT и COMPOSE_PROJECT_NAME) и каждое правило exports.env. Маркер — это ошибка с именем переменной и её исходного пути. Срабатывает во всех четырёх местах записи .env: dwe render env, авторегенерация compose перед dwe docker up / run / exec / restart / build, dwe services enable / disable и рендер, который dwe run делает до своего preflight. Корневой .env также переводится в 0600.
  • Конфиг-паки — после рендера ${...} вывод, всё ещё содержащий маркер, отклоняется с именем пути to:.
  • Паки ide / ai / git / workspace — их выводы обычно отслеживаются git, поэтому эти рендереры грузят санитизированный конфиг, собранный по сырым слоям без прохода расшифровки. Каждое поле, доступное шаблону (.Raw, .Vars, .Project, .Runtime, .Services, .ServiceCfg), несёт маркер там, где настоящий конфиг несёт открытый текст, поэтому шаблон, читающий секрет, выдаёт шифротекст — уже закоммиченный, безвредный.

Выводы конфиг-паков с источником .age пишутся с правами 0600 и явно chmod-ятся. Остальные выводы конфиг-паков используют 0644, поэтому скалярный секрет, подставленный в отрендеренный .env, попадает в hub-директорию из gitignore с обычными правами пака. Контейнер спокойно читает файл 0600, потому что работает под тем же UID/GID хоста, который публикует exports.env.

Выводы AI и IDE создаются с правами 0644 и сохраняют существующие права. Выводы workspace следуют правам выбранного источника, нормализованным до 0644 или 0755, с явным chmod при каждом рендере.

Три валидатора домена secrets (dwe validate secrets):

ВалидаторВидСрабатывает, когда
secrets.recipientконтентЕсть маркеры или источники .age, но secrets.recipient отсутствует либо не является корректным age1…; либо payload маркера повреждён (corrupt)
secrets.unresolvedготовностьХоть одно значение в объединённом конфиге не расшифровано, либо источник .age резолвящегося конфиг-пака не расшифровывается загруженным identity
secrets.shadowedэффективность (предупреждение)Вышележащий слой перебивает маркер плейнтекстом, поэтому зашифрованное значение никогда не читается

secrets.recipient сам грузит сырые слои, когда конфиг не загрузился, поэтому scoped-запуск dwe validate secrets всё равно диагностирует некорректный recipient.

secrets.unresolved — второе исключение из правила, что в preflight попадают только env.* и checks.* (первое — config.validate): это вопрос готовности. Он подмешан в preflight.Run и в гейт мастера деплоя, поэтому dwe run / deploy / reset и меню останавливаются с одной и той же подсказкой. В интерактивном dwe run / dwe restart / dwe deploy сначала срабатывает предложение ввести ключ, поэтому валидатор видит только то, что предложение не покрыло; отказ от предложения до валидатора не доходит.

Нерасшифрованные маркеры группируются по причине, одна диагностика на причину со списком отсортированных путей — у разработчика без ключа все маркеры не расшифрованы по одной причине, и строка на каждый маркер похоронила бы единственное осмысленное действие. Скан источников .age повторяет то, что обходит render config, поэтому выключенный сервис или нерезолвящийся пак невидимы валидатору ровно так же, как и во время рендера.

Затенённый маркер — предупреждение, а не ошибка. Перебить общий секрет локально законно, поэтому это никогда не блокирует и не работает в preflight. Находки группируются по перебивающему файлу и по тому, совпадает ли перебивающее значение с маркером или отличается. См. Затенённые маркеры — что при этом показывает dwe secrets status.

Здоровый проект говорит об этом. Каждый валидатор выдаёт строку ✓, когда не находит проблем, — validation result: 3 checks, а не validation skipped (no files found). Каждая строка появляется только тогда, когда валидатору было что проверить: строке recipient нужен корректный secrets.recipient, строке unresolved — инвентарь для чтения (1 encrypted value(s) and 0 config-pack source(s) readable via keyfile), а строке shadowed — хоть какие-то маркеры (1 encrypted value(s), none shadowed by a plaintext override). Проект без блока secrets: остаётся молчаливым. Identity называется словом-источником (env / env-file / keyfile), никогда путём. Preflight и гейт мастера деплоя отфильтровывают строки ✓.

Расшифрованные значения по замыслу существуют в:

  • памяти процесса — объединённый конфиг;
  • корневом .env проекта — в gitignore, 0600;
  • выводах конфиг-паков в hub-директории сервиса — в gitignore через /services/;
  • .dwe/generated.yml — 0600, в gitignore, когда сбор generated: читает отрендеренный hub-файл с расшифрованным значением;
  • контейнере, который читает hub-директорию;
  • выводе дочерних процессов, которые запустил сам пользователь.

Их держат вне:

  • отслеживаемых git файлов — выводы паков ide / ai / git / workspace рендерятся по санитизированному конфигу (см. защиту вывода);
  • собственных эхо команд dwe — трассировки -v / --debug, их копии в .dwe/logs и любая поверхность плана / dry-run (см. ниже);
  • вывода dwe vars при отсутствии ключа — <encrypted>, а не шифротекст.

Значение, переданное в dwe secrets set в командной строке, попадает в историю шелла. Используйте --stdin или скрытый промпт для всего важного.

Каждое значение, расшифрованное загрузчиком конфига, регистрируется в подсистеме трассировки, которая печатает *** вместо него. Редактирование покрывает:

  • Диагностические эхо — эхо команд -v / --debug и копии в .dwe/logs для параллельных шагов пайплайна.
  • Причины пропуска на живом прогоне — строку Skipped: <step> (when: …) и её аналог для параллельной группы. Причина — только для отображения: она никогда не пишется в .dwe/deploy/state.yml, поэтому на хеш деплоя не влияет.
  • Поверхности плана и dry-run — dwe deploy plan (таблица, --format shell и --output json, включая поле unresolved), dwe reset plan и dwe reset step --dry-run. Редактирование происходит в функциях отображения, которые собирают эти строки, до того как значение закавычат или встроят в аргумент --set k=v.

Поскольку --format shell тоже редактируется, это предпросмотр того, что будет выполнено, а не скрипт для запуска. То, что реально исполняется, не редактируется никогда.

  • Значения короче 4 рун не редактируются — редактирование "1" изрешетило бы каждую строку вывода. dwe secrets set такое значение всё равно сохранит, но предупредит в stderr.
  • Вывод дочерних процессов не редактируется (явная не-цель).
  • Редактирование — не граница доступа. dwe vars get и dwe secrets get печатают открытый текст по замыслу — см. vars.md → Вывод не маскируется.

Редактирование — объединение множеств, живущее в течение процесса, поэтому параллельные пайплайны и сценарии dwe test --parallel видят один согласованный набор.

dwe secrets не входит в allowlist контейнерных команд. Ни один контейнер не может сгенерировать, перекеить, экспортировать или импортировать ключ, а также добавить секрет.

DWE_AGE_KEY и DWE_AGE_KEY_FILE вычищаются из клиентского окружения на шиме и заново подставляются из окружения демона, поэтому контейнер не может указать хостовому dwe на файл по своему выбору, а хост, работающий с identity только из переменной окружения, продолжает работать через мост.

Чтение намеренно не закрыто. dwe vars get из bridged-контейнера доходит до хостового dwe, который держит identity, и возвращает открытый текст — та же экспозиция, что у контейнера уже есть через отрендеренный .env и конфиг-файлы. dwe render config тоже доступен и расшифровывает на стороне хоста.

Здесь нет ничего приватно-форматного. Payload маркера — это base64 бинарного age-файла с одной строфой X25519:

Окно терминала
# Открыть маркер через CLI age:
dwe secrets get vars.telegram.token # способ dwe
echo 'YWdlLWVuY3J5…' | base64 -d | age -d -i ~/.config/dwe/keys/age1….key
# Открыть источник пака:
age -d -i ~/.config/dwe/keys/age1….key creds.json.age

Keyfile — обычный age-файл identity, поэтому age, age-keygen -y и любые другие age-инструменты работают с ним.

Каждая подкоманда поддерживает --output json (с --pretty) и держит stdout чистым; типизированные ошибки сериализуются в конверт {"error":{…}} в stderr.

КомандаФорма
init{"recipient": "age1…", "keyfile": "/…/age1….key"} — --replace-recipient добавляет old_recipient плюс orphaned_markers / orphaned_files, с теми же формами строк, что сообщает status
status{"recipient": "age1…", "identity": {"source": "keyfile|env|env-file|", "keyfile": "…", "reason": "…", "error": "…", "hint": "…"}, "markers": [{"layer": "…", "path": "…", "state": "…", "reason": "…", "shadowed_by": "…", "shadow_match": "identical|different|unknown"}], "files": [{"file": "…", "state": "…", "reason": "…", "detail": "…"}]} — shadowed_by / shadow_match появляются только у затенённого маркера; у файловой строки reason остаётся в фиксированном словаре (no_identity / wrong_identity / invalid_identity / corrupt / stale_key / unreadable), а detail несёт свободный текст причины за unreadable
set{"path": "vars.…", "file": "workspace/defaults.yml"}
get{"path": "vars.…", "value": "…"}
encrypt / decrypt{"from": "…", "to": "…"}
key export{"recipient": "age1…", "identity": "AGE-SECRET-KEY-1…"}
key import{"recipient": "age1…", "keyfile": "/…/age1….key", "markers_readable": N, "files_readable": N} — счётчики заменяются на report_error, когда поверхность не удалось просканировать
key list{"keys": [{"recipient": "age1…", "file": "/…/age1….key", "state": "ok|unreadable|unparsable|misnamed", "current": true}]}
key remove{"recipient": "age1…", "keyfile": "/…/age1….key", "removed": true}
rekey{"old_recipient": "age1…", "recipient": "age1…", "keyfile": "…", "markers": N, "layers": ["…"], "files": ["…"]}

key list всегда отдаёт keys массивом — [], когда директория пуста или отсутствует, никогда null. key remove всегда сообщает removed: true: отказ — это типизированный конверт ошибки, а не payload, говорящий, что ничего не произошло.

В status identity — объект, а не плоская строка: source — стабильный словарь, по которому ветвится скрипт (прочитанный источник, заполняется и при неудаче), reason — стабильное слово причины, error — человеческая формулировка, hint — как починить. Неудача загрузки identity здесь данные, а не ошибка.

Любой отказ несёт типизированный код secrets_*. Те из них, что описывают состояние, на которое вы реагируете, а не отказ ввода-вывода:

secrets_already_initialized (деталь identity — available или missing), secrets_identity_available (init --replace-recipient, когда значения ещё читаются, со счётчиком readable), secrets_recipient_changed, secrets_no_recipient, secrets_no_identity, secrets_identity_mismatch, secrets_identity_invalid, secrets_identity_source_required, secrets_not_encrypted, secrets_path_invalid, secrets_file_invalid, secrets_value_ambiguous, secrets_value_required, secrets_input_missing, secrets_output_required, secrets_output_invalid, secrets_output_exists, secrets_raw_stream, secrets_rekey_blocked, secrets_import_cancelled, secrets_write_unsupported (отклонённая форма — файл не тронут), а для директории ключей — secrets_key_in_use, secrets_key_unreadable, secrets_key_not_found, secrets_confirmation_required и secrets_recipient_invalid.

Остальные — пооперационные отказы ввода-вывода семейства secrets_<operation>_failed: secrets_encrypt_failed, secrets_decrypt_failed, secrets_write_failed, secrets_keygen_failed, secrets_keyfile_write_failed, secrets_recipient_write_failed, secrets_scan_failed, secrets_rekey_failed, secrets_key_list_failed, secrets_key_remove_failed, secrets_output_write_failed, secrets_value_read_failed, secrets_input_read_failed, secrets_identity_read_failed.

  • Нет namespace ${secret.*}. Секрет — это значение vars.*, которое просто зашифровано at rest; второй namespace раздвоил бы каждого потребителя.
  • Нет маскирования в dwe vars для расшифрованных значений — вывод не является границей доступа (см. vars.md → Вывод не маскируется). <encrypted> появляется только тогда, когда значение не удалось расшифровать.
  • Нет списков recipient. Один recipient на проект; скалярное поле позже можно вырастить в список, ничего не сломав.
  • Нет поддержки .age в паках ide / ai / git — их выводы отслеживаются.
  • Нет secrets unset — удалите ключ из YAML-файла.
  • Нет гейта на чтение со стороны контейнера (см. поведение в контейнере).
  • Нет редактирования вывода дочерних процессов.