ADR 0149 — Подпись опубликованных артефактов
Статус
Proposed. Первый ADR ФАЗЫ 29. Ничто здесь не меняет того, что содержат образы, и того, что делает runner, — меняется то, что публикуется рядом с ними, и то, что становится проверяемым.
Контекст
Продукт не подписывает ничего из того, что публикует. Поиск cosign,
sigstore, slsa и attestation в .github/, scripts/, docker/ и
Makefile возвращает единственное реальное вхождение, и оно не покрывает ни
образ, ни бинарник: npm publish --provenance npm-пакета runner
(.github/workflows/publish-runner.yml:188, с id-token: write, которого оно
требует, в :27).
Что остаётся без происхождения:
- Четыре образа в GHCR.
release.ymlсобирает и публикует черезdocker/bake-action(.github/workflows/release.yml:286-303), фиксирует digests в.release/images.json(:308-319, черезscripts/ci/images-manifest.ts) и прикладывает этот манифест к Release (:321-332). Workflow объявляет три разрешения —contents: write,pull-requests: write,packages: write(:40-43) — и не имеетid-token: write, которого требует keyless-подпись. - Пять бинарников runner.
build-runner-binaries.ymlсобирает матрицу (:58-74), строит и загружает каждый файл черезgh release upload(:227) — и больше ничего. Нетchecksums.txt,.sig,.ascиsha256нашего артефакта ни в одном workflow. Существующиеsha256sum -c(ci.yml:201,453,458,462,docker/engine/Dockerfile.prod:95,100,104) проверяют сторонний бинарник, и это ровно тот образец, который репозиторий уже умеет применять и никогда не применял к себе. - Путь загрузки, который предлагает продукт.
GET /runner-releases/binary— это@Public()(apps/api/src/interfaces/http/runner/runner-releases.controller.ts:73-74), проверяет вход (platformпротив закрытого allowlist,:97) и передаёт байты GitHub прямо клиенту (:111-133). Нет checksum, подписи или какой-либо проверки целостности. ADR 0146:208-212 уже называет это BRB-005 — «не образец для подражания».
Это и есть BRB-005, и именно он остановил установщик одной строкой в
ФАЗЕ 28: «установщик одной строкой без подписи хуже текущей загрузки»
(../explanation/fase-28-pasta-do-usuario.md:171-173). Порядок ФАЗЫ 29
вытекает отсюда — без этого ADR ADR 0150
был бы ровно тем, что отвергла предыдущая фаза.
Решение
1. cosign keyless, с идентичностью workflow
release.yml получает id-token: write и подписывает четыре образа по
digest — digest, который он уже вычислил и уже записал в
.release/images.json, никогда по тегу, который является подвижным
указателем. Подписывающая идентичность — сам workflow (OIDC GitHub Actions), и
именно её требует проверка: --certificate-identity workflow и
--certificate-oidc-issuer GitHub.
Keyless, а не пара ключей, управляемая вручную, по той причине, которую
репозиторий уже применяет к цепочке поставок: приватный ключ потребовал бы
хранения, ротации и ещё одного секрета CI — а недавняя история репозитория
включает PAT, который истёк и провалил два запуска tag-release незаметно для
всех, пока не появилась автоматическая issue
(ADR 0139). Эфемерные учётные
данные, выведенные из исполнения, не истекают в шкафу.
2. Один подписанный checksums.txt, а не пять подписей
build-runner-binaries.yml начинает публиковать checksums.txt с SHA-256
пяти бинарников и подписывает этот файл. Подпись каждого бинарника дала бы
пять артефактов подписи для проверки и пять способов остаться проверке
наполовину; подписанный манифест покрывает набор одной проверкой, и это формат,
который умеет читать любой потребитель.
checksums.txt рождается в том же job, который уже ждёт существования Release
(build-runner-binaries.yml:213-221, цикл с потолком 600 с), а матрица
остаётся fail-fast: false: цель, которая не собралась, не мешает
публиковаться остальным, но манифест объявляет, какие цели он покрывает, —
checksums, перечисляющий четыре бинарника и умалчивающий о пятом, был бы хуже,
чем никакой.
3. Кто проверяет и что происходит при отказе
| потребитель | проверяет | при отказе |
|---|---|---|
install.sh (ADR 0150) | подпись образа по digest; подписанный checksums.txt до использования бинарника | названный отказ — говорит, какой артефакт, какая проверка, и останавливается |
GET /runner-releases/binary | только SHA-256 бинарника против checksums.txt той же Release — не его подпись (см. ниже) | 502 с motivo; никогда не отдаёт непроверенные байты |
| локальная сборка (ADR 0150) | тег git и чистое дерево | отказ до сборки |
Отказ проверки — это отказ, никогда не предупреждение. Предупреждение,
которое можно принять кликом, — это проверка, которой не существует, — и
продукт уже применяет эту мерку в другом месте, когда
consentir-base.mjs:246-258 отказывается записывать и выходит с 1, если
совместное использование не доказано. Отсутствие того, что проверялось бы, —
тоже отказ: Release без checksums.txt заставляет прокси отказать, а не
отдавать, объявляя, что проверить не удалось, — иначе удаление 400 байт из
Release выключило бы проверку для всех.
Прокси api проверяет ЦЕЛОСТНОСТЬ, а не происхождение — и это решение, а не упущение
Вторая строка таблицы родилась написанной так, будто две половины — одно. В сессии 3 их разделили, измерив два способа, которыми api могла бы проверять подпись:
| путь | измерено | почему нет |
|---|---|---|
cosign внутри образа api | 155 МБ (cosign-linux-amd64 v2.6.1) | почти удваивает рантайм-образ на Alpine + Node, публикуемый по digest в GHCR (ADR 0119), ради проверки необязательной загрузки |
@sigstore/verify + @sigstore/tuf | 2,5 МБ, 12 пакетов, без advisory | размер — не цена. getTrustedRoot() обновляет метаданные TUF против tuf-repo-cdn.sigstore.dev (seeds.json пакета засевает только root.json), так что маршрут @Public(), который сегодня зависит от ОДНОГО стороннего хоста, стал бы зависеть от двух — а это точка входа onboarding из ADR 0118. Хуже: по мерке ADR 0041/0042 — capability объявляется только тогда, когда доказана, — путь сегодня доказать нельзя, потому что ни у одной Release нет checksums.txt.bundle: job checksums выполняется только на финальном теге, и пробел уже объявлен в RN-524 со стороны подписывающего. Встроить недоказуемую проверку перед onboarding значило бы обменять известную слабость на 502 на каждой платформе, в каждой установке, обнаруженный только в продакшене |
Поэтому прокси сверяет хеш и говорит, что это только хеш, — в docblock, в
описании OpenAPI, в сообщении отказа и в
RN-525. Подпись проверяет install.sh, у
которого есть настоящий cosign путём решения 4. Последствие зафиксировано, а
не спрятано: BRB-005 закрывается для производства подписи и остаётся
открытым для половины происхождения этого маршрута — заново открыть его —
решение последующей сессии, с реальной подписанной Release, против которой
можно доказывать.
4. cosign, который проверяет, тоже нуждается в происхождении
Проверка требует бинарника cosign на машине того, кто устанавливает, а его
загрузка без проверки воссоздала бы проблему уровнем выше. install.sh решает
это путём, которым CI уже пользуется для каждого стороннего бинарника:
скачивает закреплённую версию и сверяет через sha256sum -c с хешем,
записанным в самом версионируемом скрипте (ci.yml:201 — прецедент). Кто
доверяет install.sh настолько, чтобы его выполнить, доверяет хешу, который он
несёт; цепочка не становится слабее звена, которое её начинает.
Что этот ADR явно отвергает
- Ключ, управляемый вручную (собственная пара, KMS, GPG): хранение и ротация секрета CI ради выигрыша, которого keyless здесь не даёт.
- «Подписать сейчас, проверить потом». Подпись без потребителя — лишний
файл в Release. Три потребителя из таблицы выше входят в этой фазе, и
потребитель
install.shпридаёт ей смысл. - Подпись образа по тегу. Тег — подвижный указатель; digest — то, что уже
фиксирует
.release/images.jsonи что уже применяет overlay. - Аттестация SBOM и полное происхождение SLSA. Объявленно вне рамок: это другая ось, с другим потребителем, и ни одна из них не снимает BRB-005.
Последствия
- BRB-005 закрывается для образов и бинарников — и только для них. Реестр (BRB register) получает доказательство, когда сессия 2 будет слита.
- И не закрывается для половины происхождения прокси api. По решению 3
GET /runner-releases/binaryсверяет целостность с манифестом и отказывает в том, что не может сверить, но не проверяет подпись манифеста — оба способа сделать это были измерены и отвергнуты (155 МБcosignв образе; второй сторонний хост в публичном маршруте, проверяющий то, чего ещё не существует, чтобы быть доказанным). Реестр BRB говорит это, а не объявляет пункт закрытым. - Broker остаётся без подписи, потому что остаётся без публикации.
У
docker-bake.hcl:81-83четыре цели, аscripts/ci/images-manifest.ts:54принимает четыре;brabo-broker:prodсуществует только собранным локально. Последствие относится к ADR 0150 и объявлено там: в установке через GHCR profilecontainer-brokerнедостижим — а режимmountedподнимает контейнер через broker (ADR 0144). release.ymlполучает ещё одно разрешение.id-token: writeограничено workflow и не расширяет того, что он уже мог делать сpackages: write.- Кто устанавливает без сети до Sigstore, не может проверить — и, по решению 3, не устанавливает. Это принятая цена того, что проверка не является необязательной.