dwe secrets — зашифрованные значения, закоммиченные в репозиторий
dwe secrets хранит значение, которое разделяет вся команда, — токен бота,
JSON сервис-аккаунта — зашифрованным в git. Маркеры расшифровываются в
памяти при загрузке конфига и никогда не записываются обратно в отслеживаемый
файл в открытом виде.
Содержание
Заголовок раздела «Содержание»- Модель
- Две формы: скаляры и целые файлы
- Блок
secrets: - Ключи: где живёт identity
- С чего начать
- Новый разработчик / новая машина
- Подкоманды
- Без ключа: что продолжает работать
- Защита вывода: маркер никогда не попадает в отрендеренный runtime-файл
- Валидация и preflight
- Куда попадает открытый текст
- Редактирование вывода
- Поведение в контейнере
- Совместимость с CLI
age - JSON-вывод
- Чего здесь нет намеренно
- Связанные ссылки
Одна пара ключей 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 — отслеживается gitvars: telegram: token: ENC[age:YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUx…]Грамматика строгая: ENC[age:<base64>], весь скаляр целиком или ничего.
Строка, которая просто содержит ENC[, — обычные данные, её не трогают.
Проход расшифровки не зависит от пути, но dwe secrets set пишет только под
vars. — единственную свободную песочницу в схеме со строгим корнем. См.
workspace.md → Строгий корень + песочница vars:.
Целые файлы. Источник конфиг-пака, чей from: заканчивается на .age, —
нативный зашифрованный age-файл. render config расшифровывает его перед
обычным рендером ${...} в hub-директорию сервиса:
render: - from: google-credentials.json.age to: config/google-credentials.jsonПуть to: никогда не выводится из источника. См.
render config.
Блок secrets:
Заголовок раздела «Блок secrets:»# workspace.yml — только слой 1secrets: recipient: age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3fgh2p| Поле | Тип | Описание |
|---|---|---|
secrets.recipient | string | Публичный age-recipient проекта (age1…). Коммитится. |
secrets: легален только в workspace.yml — объявление его в
workspace/defaults.yml или workspace/local.yml — жёсткая ошибка загрузки с
именем файла, потому что recipient «на разработчика» разбил бы команду на
группы, которые не могут читать секреты друг друга. Некорректный recipient
или значение secrets:, не являющееся мапой, — тоже ошибка загрузки.
Ключи: где живёт identity
Заголовок раздела «Ключи: где живёт identity»dwe использует первый найденный источник:
| Порядок | Источник | Значение |
|---|---|---|
| 1 | DWE_AGE_KEY | Сам текст identity (AGE-SECRET-KEY-1…) |
| 2 | DWE_AGE_KEY_FILE | Путь к файлу с текстом identity |
| 3 | ~/.config/dwe/keys/<recipient>.key | Keyfile проекта, 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 --stdingit 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 importPrivate 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….key2 encrypted value(s) and 1 .age file(s) are now readableПоле скрытое и принимает keyfile целиком, вместе с заголовком-комментарием,
даже одной склеенной строкой. Проверка идёт внутри формы: ключ, который не
парсится, или ключ от другого проекта, показывается без закрытия формы. Esc
отменяет с secrets_import_cancelled и без keyfile.
Вторая строка — тот же скан, что рендерит
dwe secrets status, поэтому импорт заканчивается
отчётом о том, что он открыл.
Предложение в dwe deploy, dwe run и dwe restart
Заголовок раздела «Предложение в dwe deploy, dwe run и dwe restart»Когда в проекте есть зашифрованные данные, пригодного identity нет, а за терминалом сидит человек, эти точки входа предлагают ввести ключ:
Enter the private identity now?This project has encrypted values that need the age identity for age1qyqs…, andthis machine does not have it. run 'dwe secrets key import' to store the identityat /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.
Что видят CI и скрипты
Заголовок раздела «Что видят CI и скрипты»Предложение не открывается, когда отвечать некому: 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
Заголовок раздела «dwe secrets init»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
Заголовок раздела «dwe secrets 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
Заголовок раздела «dwe secrets status»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_identity | Identity найден, но это значение он не открывает |
unresolved: invalid_identity | Источник задан, но не содержит ключа age — например, обрезанный DWE_AGE_KEY. Чинить нужно сам источник: пока задана env-переменная, keyfile не читается |
unresolved: corrupt | Payload повреждён — проблема не в ключе |
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_FILE | Identity загружен из этого источника |
none (looked at …) | Identity нет нигде; строка перечисляет все места, где искали |
invalid (…) | Источник задан, но не содержит ключа age — строка называет источник для починки |
wrong recipient (…) | Читаемый identity, но другого recipient; строка называет обоих |
Если identity не загрузился, отчёт заканчивается инструкцией по починке — той
же фразой, что печатает dwe validate. В --output json те же факты
структурированы под identity (см. JSON-вывод). Каждая строка
написана самим dwe: ошибка разбора age повторяет символы входа, а для
сломанного источника identity это байты приватного ключа, поэтому она никогда
не печатается.
dwe secrets set
Заголовок раздела «dwe secrets set»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
Заголовок раздела «dwe secrets get»dwe secrets get <vars.path>Расшифровывает маркер по пути и печатает открытый текст. Нужен identity.
get читает слои как они записаны, поэтому сообщает сам секрет, а не
объединённое значение: когда маркер затенён плейнтекстовым оверрайдом,
dwe secrets get печатает секрет, а dwe vars get —
оверрайд, который побеждает в рантайме.
Путь без маркера — это secrets_not_encrypted.
dwe secrets encrypt / decrypt
Заголовок раздела «dwe secrets encrypt / decrypt»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 / import
Заголовок раздела «dwe secrets key export / import»dwe secrets key exportdwe 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….key2 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
Заголовок раздела «dwe secrets key list»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
Заголовок раздела «dwe secrets key remove»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
Заголовок раздела «dwe secrets rekey»dwe secrets rekeyГенерирует новую пару ключей и перешифровывает на неё каждый
закоммиченный секрет — каждый маркер в файлах слоёв и каждый источник
*.age. Запускайте, когда identity мог утечь или когда его держатель больше
не должен читать секреты проекта.
Эта машина обязана прочитать каждый существующий секрет. Сначала пробуется настроенный identity, затем каждый другой keyfile в директории ключей, поэтому наполовину перекеенное дерево доводится до конца.
Порядок — это восстановимая последовательность, а не транзакция:
- Проход только на чтение — расшифровать и провалидировать всё в
память, затем прогнать каждую правку файла слоя на черновой копии.
Повреждённый маркер, нерасшифровываемый
.ageили маркер, чью YAML-форму нельзя переписать на месте, прерывает команду здесь, не записав ничего. - Записать новый keyfile — первая мутация. Старый keyfile сохраняется.
- Перешифровать каждый файл
.age(атомарно) и каждый файл слоя (по одной вклеенной строке на маркер). - Обновить
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 при каждом рендере.
Валидация и preflight
Заголовок раздела «Валидация и preflight»Три валидатора домена 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 тоже доступен и расшифровывает на
стороне хоста.
Совместимость с CLI age
Заголовок раздела «Совместимость с CLI age»Здесь нет ничего приватно-форматного. Payload маркера — это base64 бинарного age-файла с одной строфой X25519:
# Открыть маркер через CLI age:dwe secrets get vars.telegram.token # способ dweecho 'YWdlLWVuY3J5…' | base64 -d | age -d -i ~/.config/dwe/keys/age1….key
# Открыть источник пака:age -d -i ~/.config/dwe/keys/age1….key creds.json.ageKeyfile — обычный age-файл identity, поэтому age, age-keygen -y и любые
другие age-инструменты работают с ним.
JSON-вывод
Заголовок раздела «JSON-вывод»Каждая подкоманда поддерживает --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-файла. - Нет гейта на чтение со стороны контейнера (см. поведение в контейнере).
- Нет редактирования вывода дочерних процессов.
Связанные ссылки
Заголовок раздела «Связанные ссылки»workspace.yml/defaults.yml/local.yml— объединённый 3-слойный конфиг, строгий корень и песочницаvars:dwe vars— чтение и правкаvars.*и как там всплывает<encrypted>render config— конфиг-паки, источники.age, hub-директорияrender env— генерация.envи защита от маркера- Шаблоны — что доступно шаблонам ide / ai / git
validate.yml— домены валидации и preflight- Структура проекта — где живёт директория ключей