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

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 внутри образа api155 МБ (cosign-linux-amd64 v2.6.1)почти удваивает рантайм-образ на Alpine + Node, публикуемый по digest в GHCR (ADR 0119), ради проверки необязательной загрузки
@sigstore/verify + @sigstore/tuf2,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 profile container-broker недостижим — а режим mounted поднимает контейнер через broker (ADR 0144).
  • release.yml получает ещё одно разрешение. id-token: write ограничено workflow и не расширяет того, что он уже мог делать с packages: write.
  • Кто устанавливает без сети до Sigstore, не может проверить — и, по решению 3, не устанавливает. Это принятая цена того, что проверка не является необязательной.