Skip to content

tmp-router: reproducible build pipeline for TEE attestation#393

Open
ohalushchak-exadel wants to merge 6 commits into
mainfrom
ohalushchak-exadel/tmp-router-reproducible-build
Open

tmp-router: reproducible build pipeline for TEE attestation#393
ohalushchak-exadel wants to merge 6 commits into
mainfrom
ohalushchak-exadel/tmp-router-reproducible-build

Conversation

@ohalushchak-exadel

@ohalushchak-exadel ohalushchak-exadel commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Bucket 2 of the TEE work. Lands the build-side of the chain; the wire spec is in adcontextprotocol/adcp#5770 and the in-enclave runtime work (bucket 3) is a separate PR.

Why this exists

A TEE attestation document carries a measurement of the running workload (Nitro PCR0, TDX MRTD, SEV-SNP SNP_MEASUREMENT, GCP Confidential Space image digest). A verifier compares that measurement against an allowlist. The allowlist is only as trustworthy as the procedure that produces it — anyone allowlisting a digest needs to be able to rebuild the same source and recompute it. Today our cmd/router build is not deterministic; this PR fixes that.

What changes

File Change
cmd/router/Dockerfile Base images pinned by multi-arch index digest (renovate-annotated). Go build adds -buildvcs=false and -ldflags="-buildid=" to strip path, VCS, and build-id entropy. SOURCE_DATE_EPOCH wired through as a build-arg so BuildKit normalizes layer mtimes.
scripts/build-tmp-router.sh NEW. Local reproducible-build script. Derives SOURCE_DATE_EPOCH from the last commit that touched a build input; calls docker buildx with the same flags CI uses; prints the OCI image digest; optionally emits a tmp-router-measurements/v1 JSON manifest.
.github/workflows/tmp-router-image.yml NEW. Parallel to context-agent-image.yml. Builds linux/amd64 + linux/arm64, pushes to ghcr.io/<owner>/<repo>/tmp-router, signs each pushed digest with cosign keyless OIDC, writes the measurements manifest as a workflow artifact, and on tag pushes attaches it to the published image as a cosign attestation so verifiers discover it from the registry.
docs/tmp-router-reproducible-build.md NEW. Operator/auditor verification procedure, what is and is not pinned, the OCI-digest → per-platform-measurement mapping table, and what to do on a reproducibility divergence.

Verified locally

Ran scripts/build-tmp-router.sh twice back-to-back on this branch; both runs produced the same OCI image digest:

sha256:40fd2ce72ee3e5c9bacf2651e64f925a4d95780b0bec28ef8a028b763da9909e

The script also produces a tmp-router-measurements/v1 manifest with the digest, the source revision, and dirty: true/false based on the working tree state.

How verifiers will use it

TEE format Measurement Relation to published OCI digest
GCP Confidential Space Workload-image digest in the token's submods.confidential_space.image_digest claim Direct equality
AWS Nitro Enclaves PCR0/PCR1/PCR2 in the attestation document Derived deterministically from the OCI image plus the Nitro CLI version on a Nitro-enabled host
Intel TDX MRTD in the quote Same derivation model, with the TDX measurement-build chain
AMD SEV-SNP SNP_MEASUREMENT in the report Same derivation model

For non-GCP cases the operator runs a one-shot transformation from this OCI image to the platform-specific measurement on a host with the relevant tooling — out of scope for this PR (GitHub-hosted runners don't have Nitro / TDX / SEV-SNP CLIs).

Out of scope (explicit non-goals)

  • Per-platform PCR / MRTD / SNP_MEASUREMENT derivation and publication. That happens on a controlled host with the platform tooling, separately per release.
  • Transparency-log of measurements beyond what Sigstore already provides via the cosign signatures and the provenance: mode=max in-toto attestation.
  • In-enclave key custody, KMS binding, attestation endpoint — bucket 3, separate PR.

Test plan

  • scripts/build-tmp-router.sh runs cleanly, prints the OCI image digest.
  • Two consecutive local runs on the same source produce byte-identical image digests.
  • scripts/build-tmp-router.sh --measurements-out path.json writes a valid tmp-router-measurements/v1 manifest.
  • .github/workflows/tmp-router-image.yml parses as valid YAML.
  • bash -n scripts/build-tmp-router.sh passes.
  • CI workflow runs successfully on this PR (build-only, no push, on pull_request trigger).
  • Reviewer confirms the OCI-digest → per-platform-measurement mapping is the right factoring (vs. attempting platform-specific builds in this same CI).
  • On first non-PR merge to main or first tag push, the GHCR package gets created (it'll be private by default; a repo admin flips it to public per the workflow header comment).

🤖 Generated with Claude Code

Sets up the build side of the TEE attestation chain whose wire shape is
proposed in adcontextprotocol/adcp#5770. A TEE attestation document carries a
measurement of the running workload; a verifier compares that measurement
against an allowlist of expected values. The allowlist is only as trustworthy
as the procedure that produces it — anyone allowlisting a digest needs to be
able to rebuild the same source and recompute it. This change makes that
property hold for cmd/router.

Changes:

- cmd/router/Dockerfile: base images pinned by multi-arch index digest (renovate-
  annotated); Go build adds -buildvcs=false -ldflags="-buildid=" to strip path,
  VCS, and build-id entropy from the binary; SOURCE_DATE_EPOCH wired through as
  a build-arg so BuildKit can normalize layer mtimes.

- scripts/build-tmp-router.sh: local reproducible-build script. Derives
  SOURCE_DATE_EPOCH from the last commit that touched a build input, calls
  docker buildx with the same flags CI uses, prints the OCI image digest, and
  optionally emits a tmp-router-measurements/v1 JSON manifest. Tested
  back-to-back on the same source — both runs produce identical image digests.

- .github/workflows/tmp-router-image.yml: parallel to context-agent-image.yml.
  Builds linux/amd64 + linux/arm64, pushes to ghcr.io/<owner>/<repo>/tmp-router,
  signs with cosign keyless OIDC, writes the measurements manifest as a
  workflow artifact, and on tag pushes attaches it to the published image as a
  cosign attestation so verifiers can discover it from the registry rather than
  from the workflow run.

- docs/tmp-router-reproducible-build.md: operator/auditor verification procedure,
  what is and is not pinned, the OCI-digest → per-platform-measurement mapping
  (direct equality for GCP Confidential Space; deterministic derivation via the
  platform tool for Nitro / TDX / SEV-SNP — out of scope here), and what to do
  on a reproducibility divergence.

Scope is the OCI image digest. EIF/MRTD/SNP_MEASUREMENT derivation requires
platform tooling GitHub-hosted runners don't have, and lands separately on a
controlled host per release.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes. The supply-chain plumbing is sound, but the reproducibility property this PR exists to deliver is not verifiable as built, and CI will fail on a routine trigger.

Crediting the good first: all eight third-party actions are pinned by full 40-char SHA, cosign is version-pinned (v3.0.6), permissions: is minimal (contents: read / packages: write / id-token: write), and every privileged step — GHCR login, push, cosign install, sign — gates on github.event_name != 'pull_request'. cosign signs ${tag}@${DIGEST} (content digest, not a mutable tag). No fork-PR OIDC/push exfiltration path. The workflow is a faithful parallel of context-agent-image.yml. Right shape on the security mechanics.

MUST FIX (blocking)

  1. The documented verification loop can never succeed — security-reviewer: High. docs/tmp-router-reproducible-build.md:30-42 tells an auditor to run scripts/build-tmp-router.sh --platform linux/amd64 (single platform, --provenance=false --sbom=false, script L83-91 → a single-platform image-manifest digest) and compare it against imagetools inspect ... {{.Manifest.Digest}} of the published tag. But CI publishes a multi-arch index built with platforms: linux/amd64,linux/arm64 + provenance: mode=max + sbom: true (.github/workflows/tmp-router-image.yml:117-127); steps.build.outputs.digest (L140) is the index digest, which references per-platform manifests plus non-reproducible provenance/SBOM attestation manifests (mode=max provenance embeds build timestamps and runner identity). A single-platform manifest digest and a multi-arch index-with-attestations digest are structurally different objects — they cannot be byte-identical, not "might differ," but never match by construction. Per the doc's own instruction (L42 + "Reporting reproducibility failures"), an honest auditor must then treat every verification as a build-integrity incident. The in-line comment at L117-119 ("they do NOT change the image digest itself") is wrong for index digests: attestations are added as manifests in the index, which changes the index digest. Net: the reproducibility half of the "signature + reproducibility prevents a backdoored binary" claim is inert — operators either drown the real signal in guaranteed false alarms or allowlist a digest they never reproduced. Fix: compare per-platform child manifest digests on both sides, and record a per-platform digest map in the measurements manifest rather than the index digest.

  2. CI breaks on routine pushes to main — code-reviewer: Blocker. .github/workflows/tmp-router-image.yml:60-66 computes SDE="$(git log -1 --format=%ct -- <build-input paths>)" with no || echo 0 fallback (the local script has one at scripts/build-tmp-router.sh:67). The on.push trigger (L21-23) has no paths: filter, so every push to main runs this job — including pushes touching none of those paths. git log -1 -- <paths> then prints empty, SDE="" propagates, and the manifest step's ($source_date_epoch | tonumber) (L160) raises Cannot parse '' as number under set -euo pipefail and fails the workflow. Amplified by actions/checkout@v4 defaulting to fetch-depth: 1: with only the tip commit present, git log -1 -- <paths> returns empty for any commit that didn't itself touch a build input. Fix: add the || echo 0 fallback and set fetch-depth: 0 — which also fixes (3).

  3. Shallow clone silently diverges CI from local SDE — code-reviewer: Blocker. Even when (2) doesn't crash, a fetch-depth: 1 CI checkout walks truncated history, so git log -1 -- <paths> resolves a different last-touching-commit timestamp than an auditor's full git clone. Different SOURCE_DATE_EPOCH → different layer mtimes → different digest. The doc says (L42) any divergence means "do not allowlist." fetch-depth: 0 on the checkout is required for the local and CI SDE to agree.

  4. GCP "direct equality" row is factually wrong — ad-tech-protocol-expert: Severity-1. The mapping table (docs/tmp-router-reproducible-build.md:65) and the manifest reproducibility.note reference submods.confidential_space.image_digest. In the Confidential Space attestation token, image identity lives under submods.container.image_digest, not submods.confidential_space.* (that submodule carries support/monitoring attributes). A verifier's policy-as-code keyed on the documented path won't resolve. Compounding it: Confidential Space binds the single platform image it actually runs, while the manifest publishes the multi-arch index digest as image_digest — so "Direct equality" fails even for a correctly-running workload. This is the one mapping the PR presents as trivially solved, and it's the one most likely wrong in practice. (ad-tech-protocol-expert flagged this from domain knowledge — web access was gated in its environment, so confirm the exact claim path against the GCP Confidential Space token-claims reference before re-merge. The index-vs-per-platform digest-level problem is independently confirmed by all three reviewers and holds regardless.)

This is also the one test-plan box you left for the reviewer — "confirms the OCI-digest → per-platform-measurement mapping is the right factoring" — and it's the one that didn't survive review.

Follow-ups (non-blocking — fix alongside the above or file as issues)

  • Schema v1 denotes two shapes — ad-tech-protocol-expert: Severity-2. Both emitters stamp "schema": "tmp-router-measurements/v1" but disagree: CI emits image, platforms (array), build{}, source.ref, reproducibility{note}; the local script (scripts/build-tmp-router.sh:105-124) emits platform (singular), no image/build/platforms, and a top-level note. A versioned identifier the wire spec (adcp#5770) will parse must denote one shape. Make both emit a shared required core with CI-only fields optional, or give the local one a distinct id.
  • cosign attest --type custom has no stable predicateType. If #5770 wants registry-side measurement discovery, define a named predicateType so discovery is deterministic rather than "the one custom attestation on the image."
  • Nitro PCR0 determinism is slightly overstated. Doc says "same OCI image + same Nitro CLI version ⇒ same PCR0"; the EIF measurement also folds in the linuxkit kernel/init blobs. Add a word so operators don't allowlist a PCR0 they can't reproduce after a nitro-cli base bump.
  • source.dirty is structurally always false in CI (tmp-router-image.yml:144 — a fresh checkout is never dirty). Reads as an integrity signal but measures nothing in CI. Drop it from the CI manifest or label it local-only.

Minor nits (non-blocking)

  1. date -u -r is GNU/BSD-divergent. scripts/build-tmp-router.sh:71date -u -r <epoch> means "reference file mtime" on GNU coreutils and "seconds since epoch" on BSD/macOS; the || echo unknown masks it, so a macOS auditor sees unknown for a valid epoch. Display-only.

Reproducibility is the whole game here, and the published-vs-local digest contract has to actually close before any operator is told to allowlist these measurements. Fix 1–4, reconcile the digest level across workflow/manifest/doc, and I'll re-review.

…tory, GCP claim path

Addresses the 4 blockers from PR #393 review:

1. Verification loop now closes. CI was publishing the multi-arch INDEX digest
   as `image_digest`, which references provenance/SBOM attestation manifests
   alongside the per-platform image manifests — that index digest can never
   byte-match a single-platform local rebuild. Replaced with per-platform
   image-manifest digests in a `platform_digests` map (resolved via
   `docker buildx imagetools inspect --raw` after push), with `index_digest`
   carried for traceability only. The local script's manifest schema now
   matches: it emits `platform_digests` with the one platform it built.
   The doc verification flow is rewritten to compare per-platform digests
   with an `imagetools inspect --raw | jq` pipeline.

2. SDE computation no longer crashes. `git log -1 --format=%ct -- <paths>`
   on a push that didn't touch a build input returned empty, then
   `($source_date_epoch | tonumber)` failed under `set -euo pipefail`.
   Added `|| echo 0` fallback plus an explicit empty-string guard.

3. Shallow-clone SDE drift fixed. `actions/checkout` now uses
   `fetch-depth: 0` so `git log -1 -- <paths>` resolves the same
   last-touching-commit timestamp CI and local both see. With the previous
   default `fetch-depth: 1`, CI's truncated history could resolve a
   different SDE than an auditor's full clone, producing a different image
   digest and a "do not allowlist" verdict for an otherwise-good build.

4. GCP Confidential Space claim path corrected:
   `submods.confidential_space.image_digest` → `submods.container.image_digest`
   in both the doc table and the CI manifest's reproducibility note.
   `confidential_space` carries platform/support attributes; workload-image
   identity lives under `container`. Confidential Space binds the single
   platform image it actually runs — which is exactly what `platform_digests`
   gives us once #1 is fixed.

Plus the schema-reconciliation follow-up: both emitters now stamp
`tmp-router-measurements/v1` against the same shape (`platform_digests` map,
optional `index_digest`, optional `source.dirty`).

Plus the Nitro PCR0 wording: noted that PCR0 also folds in linuxkit
kernel/init blobs shipped with the Nitro CLI release, so allowlisting a
specific PCR0 requires pinning the Nitro CLI version, not just the OCI image.

Verified locally: two consecutive `scripts/build-tmp-router.sh` runs on this
branch produce byte-identical per-platform digests. The new measurements
manifest now publishes the value an auditor actually compares.
…PR builds

The previous `select(. != "") // {}` chain halted the jq stream when both
amd64 and arm64 inputs were empty (the PR-build case where the per-platform
resolution step is gated off), producing no output and crashing the
manifest step under `set -euo pipefail`. Replaced with explicit if/then,
which produces `platform_digests: {}` on PR and the populated map on push.

Verified with jq locally against both the empty and populated cases.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes. The one property this PR exists to establish — a local rebuild produces the same per-platform image-manifest digest CI publishes — does not hold yet, and the doc tells auditors to treat a mismatch as a build-integrity incident. Fail-closed beats fail-open: shipping a verification procedure that fails on its own happy path is worse than not shipping it. The build plumbing is otherwise sound and the supply-chain posture is clean.

Reviewed with code-reviewer (build/script correctness) and security-reviewer (attestation trust boundary). The block comes from code-reviewer; security-reviewer returned no High.

Things I checked

  • Supply-chain posture is clean (security-reviewer, confirmed): trigger is pull_request, not pull_request_target; all 8 third-party actions are SHA-pinned (tmp-router-image.yml:65,92,95,99,107,124,217,225); every secret/state-mutating step is gated if: github.event_name != 'pull_request' (login :98, push :129, resolve :153, cosign :224, sign :237, attest :256). Fork PRs get a read-only token and no OIDC regardless of the declared packages:write/id-token:write. No ${{ }} injection into run: blocks — interpolated values are structural (github.sha, steps.*.outputs.*) and the hot ones go through env:.
  • The index-vs-per-platform distinction is right. CI binds platform_digests (tmp-router-image.yml:144-169), not the index digest, which legitimately moves with provenance: mode=max/sbom: true. The local script disables both (build-tmp-router.sh:85-86) and emits the same per-platform shape. The jq selectors at :159-160 and the map builder at :195-198 are correct — (.annotations["vnd.docker.reference.type"] | not) properly excludes the attestation manifests, and the +-merge yields the right map.
  • SDE parity holds. The git log -1 --format=%ct -- <paths> path list is character-identical between CI (tmp-router-image.yml:84-86) and local (build-tmp-router.sh:57-59), fetch-depth: 0 is set, and both fall back to 0 on empty.
  • Go build flags are the standard reproducible recipe (cmd/router/Dockerfile:32-37): CGO_ENABLED=0, -trimpath, -buildvcs=false, -ldflags=\"-s -w -buildid=\". Base images pinned by index digest.

Blocking

  1. OCI labels asymmetry — the local digest will not match the CI digest. tmp-router-image.yml:117-120 passes three org.opencontainers.image.* labels (plus the dynamic ones docker/metadata-action injects) into build-push-action, which writes them into the per-platform image config. build-tmp-router.sh:81-89 sets none. The image config is part of the per-platform image-manifest digest, so an auditor running scripts/build-tmp-router.sh --platform linux/amd64 per docs/tmp-router-reproducible-build.md:36 gets a digest that does not equal the published platform_digests[\"linux/amd64\"] — and docs/tmp-router-reproducible-build.md:52 tells them to treat that as a build-pipeline integrity incident. The headline path is broken on the first try. Fix: move all labels into the Dockerfile as LABEL instructions so both emitters bake identical config, or pass the same --label args in the local script. (code-reviewer: Blocker.)

  2. Image-config created timestamp is not normalized. Neither path sets rewrite-timestamp=true on the image output (tmp-router-image.yml:122-142; build-tmp-router.sh:78). SOURCE_DATE_EPOCH clamps layer mtimes but does not reliably rewrite the config created field across BuildKit versions — so two builds at different wall-clock times can produce different config bytes and a different per-platform manifest digest, intermittently. Add rewrite-timestamp=true to the image output on both paths and assert .created == SDE via imagetools inspect --raw. (code-reviewer: Blocker.)

Both reduce to the same gap: the local==CI equality is asserted but neither validated nor yet achievable. The test plan reflects this — [x] Two consecutive local runs produce byte-identical image digests proves local==local, not the claim that matters. There is no checkbox for "local rebuild matches the CI-published per-platform digest," and [ ] CI workflow runs successfully on this PR is unchecked. The primary user-facing path ships unvalidated.

Follow-ups (non-blocking — file as issues)

  • Cosign verification identity is too permissive (security-reviewer M1). --certificate-identity-regexp='...tmp-router-image\.yml@.*' (tmp-router-image.yml:235, docs:91) matches a signature from any ref — including a workflow_dispatch run on an unreviewed feature branch with packages:write/id-token:write. For a TEE allowlist, only tag-ref signatures should be trusted (they're also the only ones carrying the cosign attest manifest). Anchor and constrain: '^https://github\.com/adcontextprotocol/adcp-go/\.github/workflows/tmp-router-image\.yml@refs/tags/tmp-router-v.*$'. Update both the workflow comments and the doc.
  • GCP Confidential Space binding is under-specified (security-reviewer M2). docs:103 presents submods.container.image_digest equality as the complete binding rule. The digest comparison is sound and not a substitution vector, but an operator implementing exactly that accepts a debug-mode CS instance of the correct image. The verifier must additionally assert submods.confidential_space.support_attributes contains STABLE and pin the expected aud/iss. State this in the GCP row.
  • BuildKit/buildx version is unpinned (code-reviewer High). setup-buildx-action (:95) leaves the BuildKit daemon free, and the per-platform manifest digest is sensitive to BuildKit version (compression defaults, media types, annotation ordering). "Docker 24+" (docs:39) is too loose for byte reproducibility. Pin the BuildKit image in CI and record the builder version in the measurements manifest so a benign version skew is distinguishable from a real divergence.
  • No GOTOOLCHAIN=local (code-reviewer High). cmd/router/Dockerfile:32 runs go build with default GOTOOLCHAIN=auto. Fine today (go.mod < base image), but a future go.mod toolchain bump above the base image silently downloads a different toolchain over the network — contradicting docs:114's "nothing reaches out to the network at build time." Add ENV GOTOOLCHAIN=local so a skew fails loudly.

Minor nits (non-blocking)

  1. date -u -r <epoch> is broken on GNU/Linux. build-tmp-router.sh:64-r means "reference file" on GNU coreutils, so the human-readable SDE prints (unknown) for nearly every auditor (masked by || echo unknown). Cosmetic; SDE itself is correct. Use date -u -d \"@$SOURCE_DATE_EPOCH\".
  2. cosign-installer SHA carries a # v4.1.1 comment while installing cosign-release: v3.0.6 (tmp-router-image.yml:225,228). Harmless (the SHA executes), but confirm the SHA↔tag mapping so the next Renovate bump isn't a trap.
  3. SOURCE_DATE_EPOCH=0 fallback is a silent correctness cliff. Consider failing hard rather than building at epoch 0 if derivation yields empty (build-tmp-router.sh:56-60).

Fix the two blockers — the label and timestamp normalization — and the reproducibility claim becomes true rather than aspirational. The rest is guidance hardening that can land as follow-ups.

@aao-ipr-bot

aao-ipr-bot Bot commented Jun 30, 2026

Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid second pass — the four blockers from the prior round are genuinely closed, and the per-platform-vs-index-digest factoring is the right instinct. Commenting rather than approving: the trust chain this PR exists to establish has two gaps that land squarely on the open design question you flagged in the test plan, plus a guaranteed-wrong line on Linux. None block on the repo's MUST-FIX bar (no Go/schema/wire change, no weakening of existing TMP verification), but the PR's whole reason for being is "the allowlist is only as trustworthy as the procedure," so the procedure has to close.

Things I checked

  • Workflow permissions are exactly minimal — contents:read, packages:write, id-token:write, no job widens them (.github/workflows/tmp-router-image.yml:50-53).
  • All 8 third-party actions pinned by full 40-char SHA. Fork PRs cannot push, sign, or attest — push, GHCR login, cosign sign, and the platform-digest resolve are all gated github.event_name != 'pull_request' (lines 98, 129, 153, 224, 237).
  • fetch-depth: 0 is present and load-bearing for SOURCE_DATE_EPOCH agreement with a full local clone (lines 64-72); the SDE git-path list is byte-identical between CI (:84-86) and scripts/build-tmp-router.sh:57-59.
  • The "provenance/SBOM change only the index digest, not the per-platform manifest digests" claim is correct, and the (.annotations["vnd.docker.reference.type"] | not) jq filter at :159-160 correctly excludes the attestation manifests.
  • Dockerfile reproducibility flag set is right: CGO_ENABLED=0 -trimpath -buildvcs=false -ldflags="-s -w -buildid=", base images pinned by index digest (cmd/router/Dockerfile:15,17,32-37). COPY layering is sane.
  • Cross-emitter tmp-router-measurements/v1 schema parity holds: CI adds index_digest/image/build, local adds source.dirty, both share schema + platform_digests map + source.{revision,revision_short,date_epoch}. Doc :82-84 documents the optional fields.

What would flip me to approve

1. The signature covers the index digest; every verifier in your own docs allowlists the per-platform digest — and the procedure never closes that gap. (security-reviewer, High.) cosign sign runs against steps.build.outputs.digest, the multi-arch index digest (:240, :245). But platform_digests (:195-198), the doc table (docs/tmp-router-reproducible-build.md:103), and the GCP submods.container.image_digest comparison all bind to the per-platform image-manifest digest. The chain is actually completable — the signed index references those per-platform digests in .manifests[].digest — but the verification flow at docs/...:43-52 extracts the per-platform digest via imagetools inspect | jq without first cosign verify-ing the index it pulled it from, and :54 then calls platform_digests "the authoritative shortcut." As written, an auditor trusts a per-platform digest with no signature check on the object that vouches for it. Fix is either: sign the per-platform digests directly, or make the doc procedure verify the index signature first and confirm the allowlisted digest appears in the verified index. Right now the doc is the product, and the procedure skips the step that makes the signature load-bearing.

2. Nothing in CI proves the published per-platform digest reproduces before it's signed and attested. (security-reviewer, High, + your own unchecked [ ] CI workflow runs successfully.) The reproducibility check is entirely manual and after-the-fact. With cache-from/cache-to: type=gha on the publishing path (:141-142) — and GHA cache is not a trust boundary — a poisoned go build layer lands in a "reproducible" image that CI then signs and attests as authoritative, and :54 tells operators the signed manifest is the shortcut that lets them skip the rebuild. Either add a CI step that runs scripts/build-tmp-router.sh --platform linux/amd64 in a clean context and fails the build on digest divergence, or drop GHA cache on the tag/release path and state plainly in the manifest note + docs that the signed manifest is not a substitute for an independent rebuild. This is the loop the PR claims to close ("you can never tell whether a divergent measurement is a build bug or a backdoor", docs/...:11); right now CI cannot tell either.

3. date -u -r is BSD-only and fails on every Linux host, including the CI runner. (code-reviewer.) scripts/build-tmp-router.sh:64 — GNU date reads -r as "reference file mtime," so date -u -r 1700000000 errors; the 2>/dev/null || echo unknown swallows it and every Linux operator sees SOURCE_DATE_EPOCH = <ts> (unknown). Cosmetic — the SDE passed to the build is correct — but it's wrong on the primary target and the fix is one token: date -u -d "@$SOURCE_DATE_EPOCH".

Follow-ups (non-blocking — fold in or file)

  • Tighten the cosign cert-identity regexp to the release refs. :235 / docs/...:91 end in tmp-router-image\.yml@.*, which matches a build signed from any ref of this repo, not just main / tmp-router-v*. Pin the trusted ref. (security-reviewer, Medium.)
  • cosign attest only fires on tag pushes, but edge / main-<sha> tags are signed with no registry-discoverable manifest (:248, :256 vs :111-112). An operator pinning edge gets a 90-day-expiring artifact, not an attestation. Either attest all pushed builds or document that only tmp-router-v* is supportable for TEE allowlisting. (security-reviewer, Medium.)
  • --type custom gives the predicate no namespaced predicateType (:264) — verify-attestation --type custom matches any custom predicate on the image. A stable type URI (e.g. https://adcontextprotocol.org/tmp-router-measurements/v1) binds verification to the schema. (security-reviewer, Low.)
  • Cross-environment reproducibility is unvalidated. Test plan has [x] two consecutive same-machine runs — that's warm-cache, same-host. The property TEE allowlisting needs is different machine → same digest. That's what [ ] CI workflow runs successfully on this PR would start to prove; until it's green, the headline claim ships unverified against the path it targets.

Minor nits (non-blocking)

  1. Dead || echo 0 fallback + misleading comment. git log -1 --format=%ct -- <paths> exits 0 with empty output when nothing matches; || echo 0 only fires if git itself fails, not the "history truncated" case the comment at :79-81 describes. The -z guard is what actually catches empty. Behavior is correct; the comment isn't.
  2. PR-build steps run with empty digests. Write measurements manifest / Upload measurements manifest (:171-221) aren't gated on != 'pull_request', so a PR build uploads a near-empty manifest artifact named tmp-router-measurements- (empty digest suffix, :219). Harmless, but gate them for cleanliness.
  3. The PR body still says Draft — GitHub says otherwise (isDraft: false). Worth reconciling so the next reviewer knows whether you want merge or eyes.

Substance is good and the direction is right. Close #1 and #2 (the procedure/CI gaps that make the signature and the manifest load-bearing) and fix the one-line date bug, and this is a clean approve.

— Argus

Addresses the 3 blockers from the second review pass:

1. Signature ↔ verification gap. cosign signs the multi-arch INDEX digest,
   but TEE verifiers and the docs allowlist per-platform image-manifest
   digests; the previous verification flow extracted per-platform digests
   from the registry without verifying the cosign signature first, so
   trust hung on an unverified registry response. New verification flow
   in docs/tmp-router-reproducible-build.md runs `cosign verify` first,
   captures the verified index digest from `critical.image.docker-manifest-digest`,
   and only then walks `imagetools inspect --raw | jq` over the verified
   index to extract per-platform digests. The doc's "platform_digests is
   the authoritative shortcut" claim is removed — the attestation proves
   the workflow produced those values, not that an independent build
   reproduces them.

2. CI was signing un-reproduced images. The publish path uses
   `cache-from/cache-to: type=gha,scope=tmp-router` for fast PR iteration,
   but GHA cache is not a trust boundary — a poisoned cached layer would
   land in a "reproducible" image that CI then signed as authoritative.
   New `Verify reproducibility (no-cache clean rebuild)` step runs
   `scripts/build-tmp-router.sh --platform linux/amd64` (which does not
   consult the GHA cache) AFTER the publish-path build and BEFORE cosign
   sign, and fails the workflow when the per-platform digests diverge.
   CI now refuses to publish a signed digest it cannot reproduce.

3. `date -u -r <epoch>` is BSD-only. On GNU coreutils (every Linux host,
   including the CI runner) `-r` means "reference file mtime," so the
   display crashed and `|| echo unknown` masked it. Replaced with a
   portable `format_epoch` helper that tries GNU `date -d "@<epoch>"`
   first, falls back to BSD `date -r <epoch>`.

Plus the security follow-ups:

- Tightened the cosign cert-identity regexp in all doc + comment examples
  from `tmp-router-image\.yml@.*` to
  `tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v.*)`. A fork
  or feature-branch build's OIDC token carries a different ref and would
  no longer match.
- Attestation now fires on every push (was: tag pushes only). Operators
  pinning `edge` or `main-<sha>` also get a registry-discoverable
  measurements manifest.
- Replaced `cosign attest --type custom` with the stable predicate-type
  URI `https://adcontextprotocol.org/tmp-router-measurements/v1` so
  `verify-attestation --type ...` binds to this exact schema rather than
  matching any custom predicate on the image.

Plus nits:

- Fixed the misleading SDE comment: `|| echo 0` is defensive against
  `git log` itself failing; the `-z` guard is what catches the empty-
  output case (git returns 0 with empty output when the path filter
  matches no commits).
- Gated `Write measurements manifest` and `Upload measurements manifest`
  on `github.event_name != 'pull_request'` so PR builds no longer upload
  an empty-suffix workflow artifact.

Reproducibility check: two consecutive `scripts/build-tmp-router.sh` runs
on this branch still produce byte-identical per-platform digests.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes. The build pipeline is sound and well-iterated; the measurements doc makes TEE-mapping claims an operator would act on that don't hold for two of four formats — and that's the box this PR's own test plan left unchecked.

Things I checked

  • Dockerfile COPY list is complete. cmd/router is its own module (replace => ../../); router/tmproto/targeting only import targeting/*, tmproto, urlcanon — all COPY'd. No adcp/ or identityagent/ in the build graph, so go build resolves. No build-break.
  • Reproducibility gate fails closed. Verify reproducibility (no-cache clean rebuild) (tmp-router-image.yml:173-199) runs before cosign sign (262) and attest (286); no if: always(), mismatch is exit 1 under set -euo pipefail. CI refuses to sign a digest it can't reproduce. (security-reviewer: PASS)
  • Fork PRs can't sign or push. Trigger is pull_request, not pull_request_target; push gated != 'pull_request', every login/sign/attest step gated; GitHub denies id-token to fork PRs regardless. (security-reviewer: PASS)
  • GHA cache can't poison a signed image — a poisoned layer diverges from the no-cache rebuild and fails the gate before signing. (PASS)
  • Docs verification order (docs/tmp-router-reproducible-build.md:39-70): cosign verify first, capture the verified index digest, then inspect by that digest — content-addressed, no TOCTOU. (PASS)
  • All actions SHA-pinned; GITHUB_TOKEN used only as the GHCR login password, never echoed.
  • stdout/stderr split in the compare (yml:189): the script prints the digest to stdout only (build-tmp-router.sh:107), human output to stderr; 2>... | tail -n1 captures exactly the digest. Correct.

MUST FIX

  1. TEE measurement mapping is unsound for Intel TDX and AMD SEV-SNP (docs/tmp-router-reproducible-build.md:127-128; manifest note tmp-router-image.yml:241). ad-tech-protocol-expert: unsound, cited divergence. The table treats all four formats as one "image-plus-host-chain → measurement" model. That's only true for Nitro PCR0, which genuinely measures the EIF (kernel+init+app). Intel TDX MRTD measures the TD's initial guest memory (TDVF/firmware + initial pages); the rootfs/app lands in RTMRs, not MRTD. AMD SEV-SNP SNP_MEASUREMENT is the launch digest of initial guest memory (OVMF + boot pages); app identity is anchored separately (typically a dm-verity roothash). An operator who allowlists an MRTD or SNP_MEASUREMENT believing the "same model" claim pins it to the tmp-router binary is pinning only the firmware/boot chain — a weaker guarantee than the doc promises, in the doc that exists to make the allowlist trustworthy. Keep the Nitro row; rewrite the TDX/SEV-SNP rows to say the headline register measures the VM boot/firmware image and workload identity must be anchored separately (RTMR roothash for TDX; measured kernel cmdline for SEV-SNP) — explicitly not the same model as PCR0.

    This is the box you left unchecked: [ ] Reviewer confirms the OCI-digest → per-platform-measurement mapping is the right factoring. Confirmed — for two of four formats it isn't.

  2. GCP direct-equality needs a launch-by-per-platform-digest caveat (docs:125; manifest note yml:241). The submods.container.image_digest correction is right (the container submodule carries workload identity; confidential_space carries platform attributes). But "direct equality against the linux/amd64 platform_digests entry" only holds if the workload is launched by its per-platform manifest digest. Launch by the multi-arch index digest and the token can carry the index digest — which the doc itself says is never what you compare. The verification procedure extracts the amd64 digest; nothing forces the deployment to run that reference. State that the workload must be launched as tmp-router@<linux/amd64 digest>, not by tag or index.

  3. scripts/build-tmp-router.sh lacks the empty-SDE floor CI added (build-tmp-router.sh:56-60 vs yml:89). git log -1 --format=%ct -- <paths> exits 0 with empty stdout when the path filter matches no commit, so the || echo 0 at line 59 never fires — SOURCE_DATE_EPOCH is exported empty. CI floors exactly this case (if [ -z "$SDE" ]; then SDE=0, yml:89, added in 29afd54 with a comment spelling out why). The script — whose one job is to reproduce CI's digest — doesn't. An auditor on a shallow clone whose tip doesn't touch a build path computes SDE="" where CI used 0, gets a different digest and a false "DIVERGED — do not allowlist" verdict; with --measurements-out, jq's tonumber on "" hard-crashes the manifest write (build-tmp-router.sh:132). It fails closed and the documented full-clone flow avoids it — but it's a two-line parity fix and the script advertises CI parity. After line 60: if [[ -z "$SOURCE_DATE_EPOCH" ]]; then SOURCE_DATE_EPOCH=0; fi.

Follow-ups (non-blocking — file as issues)

  • SDE path list is duplicated between yml:86-88 and build-tmp-router.sh:57-59. They must stay byte-identical or CI and a local auditor compute different digests — a false divergence the docs (lines 138-144) tell auditors to treat as a security incident. Single-source it (workflow calls the script for the SDE).
  • arm64 is signed/attested but not independently reproduced in CI — the gate (yml:173-199) only rebuilds linux/amd64. Either rebuild both, or note in the manifest reproducibility.note that only amd64 was CI-reproduced.
  • source.dirty misses untracked files (build-tmp-router.sh:113) — git diff --quiet HEAD doesn't see new untracked files under a COPY'd path, so a build-affecting untracked file reports dirty: false. Use git status --porcelain -- <paths>.

Minor nits (non-blocking)

  1. "Nothing reaches out to the network at build time" overstates it (docs:136). The Go build downloads modules via GOPROXY; reproducibility rests on go.sum, which the same paragraph correctly cites. Reword to "module downloads are checksum-pinned via go.sum" rather than implying an airgapped build.

Fix the two TEE-mapping rows (#1, #2) and the SDE floor (#3); the pipeline mechanics are right — ad-tech-protocol-expert cleared Nitro PCR0 and security-reviewer cleared the whole sign/attest trust chain. Re-request and I'll re-check the doc rows.

…oor, DRY the SDE derivation

Addresses the 3 MUST FIX items from the third review pass, plus the small
follow-ups the reviewer flagged as fileable-as-issues.

MUST FIX 1: TDX MRTD and SEV-SNP SNP_MEASUREMENT are firmware/boot-chain
measurements, not workload measurements. The prior mapping table treated
all non-GCP formats as one "image-plus-host-chain → workload measurement"
model, which is only true for Nitro PCR0 (measures the full EIF: kernel +
init + application). On Intel TDX, MRTD measures TDVF + initial guest
memory; workload identity lands in an RTMR (typically RTMR3 via IMA). On
AMD SEV-SNP, SNP_MEASUREMENT is the launch digest of OVMF + boot pages;
workload identity anchors via a measured dm-verity roothash. Rewritten
both rows to say plainly that the headline register is not a workload
measurement, and that a TDX/SEV-SNP deployment with a meaningful workload
allowlist needs a companion anchor (RTMR / dm-verity roothash) — out of
scope for this PR. Kept the Nitro row unchanged (it was correct).

MUST FIX 2: GCP direct-equality now names the launch-reference caveat.
The `submods.container.image_digest` claim only carries the per-platform
digest when the workload is launched by its per-platform manifest digest
(`tmp-router@<linux/amd64 digest>`). Launch by tag or by index and the
token may carry the index digest — which the reproducibility procedure
never treats as trusted. Added an explicit MUST on the operator's launch
reference.

MUST FIX 3: `scripts/build-tmp-router.sh` now floors an empty SDE to 0.
Previously the script relied on `git log -1 --format=%ct -- <paths> ||
echo 0`, but `git log` exits 0 with empty stdout when the path filter
matches no commit (only exits non-zero when git itself errors), so
`|| echo 0` never fired and SOURCE_DATE_EPOCH could be exported empty.
An empty value would hard-crash the build-arg pass and jq's `tonumber`
in the manifest write. CI already had this floor (yml:89); the script
now shares the guard via the new scripts/tmp-router-sde.sh.

Plus the follow-ups the reviewer flagged as fileable-as-issues:

- SDE derivation is now single-sourced in scripts/tmp-router-sde.sh,
  called by both the workflow and the local build script. The path list
  can no longer drift between CI and a local auditor; a drift would
  cause CI and local to compute different SDE values, produce different
  image digests, and make the documented reproducibility check fail
  against a genuinely reproducible build.
- Verify-reproducibility step now rebuilds both linux/amd64 and
  linux/arm64 (was: amd64 only). arm64 goes through QEMU on the amd64
  runner — slower but symmetric with what is actually published and
  signed.
- source.dirty detection uses `git status --porcelain -- <build-paths>`
  instead of `git diff --quiet HEAD`. The old check missed new
  untracked files under a COPY'd path; a build-affecting untracked
  file would have reported dirty:false.

Plus the minor nit: reworded the "nothing reaches out to the network at
build time" line to describe what actually happens (module downloads
via GOPROXY, checksum-pinned by go.sum) rather than implying an
airgapped build.

Reproducibility check: two consecutive local runs on this branch still
produce byte-identical per-platform digests.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two real defects in the trust chain, both in the load-bearing parts. The build side is clean — the reproducibility model is right, the no-cache rebuild gate is correctly ordered before sign, actions are SHA-pinned, and the TEE measurement-mapping table is conservative and correct. But the verifier-side regexp operators copy-paste is too loose, and the advertised workflow_dispatch trigger fails. Requesting changes on those two.

MUST FIX

  1. Unanchored --certificate-identity-regexp accepts non-main branch builds. docs/tmp-router-reproducible-build.md:41 and :113, plus the workflow comments at tmp-router-image.yml:277 and :298 — the exact string an auditor/TEE verifier pastes into cosign verify. Cosign matches certificate identities with Go's regexp.MatchString, which is unanchored (substring match). No ^/$. The heads/main alternation branch therefore matches any SAN merely containing it — ...tmp-router-image.yml@refs/heads/main-attacker passes. That defeats the "only main and tmp-router-v* refs are trusted" guarantee the comment at :274-275 promises: an operator who runs the documented command allowlists an image built off an arbitrary main-prefixed branch. This is the load-bearing trust anchor of the whole pipeline — it has to be exact before anyone copies it. Anchor both ends and escape the path dots in all four locations:

    --certificate-identity-regexp='^https://github\.com/adcontextprotocol/adcp-go/\.github/workflows/tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v[0-9].*)$'
    

    (security-reviewer: High.)

  2. workflow_dispatch produces zero tags but pushes — the advertised manual-rebuild trigger fails every run. tmp-router-image.yml:105-111: on workflow_dispatch, edge/type=sha are gated on event_name == 'push', the three type=match rules only fire on tag refs, and latest only on tmp-router-v* tags → the metadata step emits no tags. But Build & push (:124) sets push: ${{ github.event_name != 'pull_request' }}, which is true for dispatch. docker/build-push-action with push: true and empty tags has no image reference to push and fails the job. The header comment (:18) advertises workflow_dispatch → manual rebuild as supported. Either add a dispatch-enabled fallback tag or gate push on github.event_name == 'push' and skip push/sign/attest for dispatch. (code-reviewer: Blocker.)

These two compound: #2 currently masks #1's dispatch exploit path (empty tags abort before sign). "Fix" #2 by adding a dispatch tag without also anchoring the regexp in #1 and you hand a signed, verifier-trusted image to any main-prefixed branch. Resolve both together, and constrain the sign/attest if: to github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/tmp-router-v') so the signing scope can't drift open again.

Things I checked

  • Dockerfile COPY set is complete. Every internal import reachable from cmd/routerrouter, tmproto, targeting/**, urlcanon — is copied (Dockerfile:26-30). No adcp import in the reachable set, so no missing COPY; the build closure holds.
  • Reproducibility comparison is sound. provenance: mode=max + sbom: true emit separate attestation manifests; they move the index digest but not the per-platform image-manifest digest. The (.annotations[\"vnd.docker.reference.type\"] | not) filter at :154-155 correctly excludes them, so the publish-path per-platform digest is legitimately comparable to the provenance=false local rebuild.
  • SDE single-sourcing. scripts/tmp-router-sde.sh is the one derivation both CI (:82) and the local script (build-tmp-router.sh:61) call; fetch-depth: 0 (:72) makes CI resolve the same last-touching commit a full clone does. Empty-git log0 is guarded in both tmp-router-sde.sh:36 and again at build-tmp-router.sh:63-68.
  • jq manifest construction handles empty digests via if == \"\" then {} guards; tonumber is safe because SDE is guaranteed numeric upstream (:238, build-tmp-router.sh:149).
  • No script-injection surface. No github.event.*.body/title/head_ref reaches any run: block; $TAGS/$DIGEST pass through env:; jq uses --arg.
  • Predicate-type binding. cosign attest --type <full-URI> (:309) matches the documented verify-attestation --type <same-URI> (docs:111); a rogue --type custom predicate wouldn't match, and the doc calls that out at :117.
  • TEE measurement mapping (docs:125-130) is materially correct and conservative — GCP submods.container.image_digest with the launch-by-per-platform-digest MUST, Nitro PCR0-covers-EIF with the Nitro-CLI-version dependence, and the explicit "TDX MRTD / SEV-SNP SNP_MEASUREMENT are boot-chain only, need a companion RTMR / dm-verity anchor" warnings. It steers operators away from allowlisting a weaker measurement than they think.

Follow-ups (non-blocking — file as issues)

  • Moving tags point at an unreproduced image before the gate. edge/main-<sha>/latest are pushed at :124, before Verify reproducibility (:166-203) and before sign (:266). When the gate fails, the job aborts leaving those tags on an unsigned, unreproducible digest. A consumer pulling by tag without cosign-verifying runs it. Push to a digest first and repoint moving tags only after gate+sign pass, or document that edge/latest may transiently reference a rejected build. (security-reviewer: Medium.)
  • Repro-gate failure diagnostic is unreachable when the rebuild itself errors. tmp-router-image.yml:190 runs the rebuild under set -euo pipefail; a non-zero build-tmp-router.sh aborts the step before the ::error::/tail stderr_log block at :193-199. Capture the exit status explicitly (if ! actual=\"$(...)\"; then ...) so a build failure surfaces the captured stderr, not just a digest mismatch. Two mktemp files also leak (no cleanup). (code-reviewer.)

Minor nits (non-blocking)

  1. --help parsing is brittle. build-tmp-router.sh:49 relies on sed -n '2,/^set -euo pipefail/p' — couples help output to the exact set line. Fine today; will silently truncate if the preamble is reordered.

Test plan

Three unchecked boxes, including [ ] CI workflow runs successfully on this PR (build-only, no push, on pull_request trigger) — the primary validation path for this change. The workflow_dispatch defect in MUST FIX #2 wouldn't be caught by the PR-trigger build-only path, so the green PR run doesn't cover the manual trigger the header advertises. Worth an actual dispatch run against a scratch branch once #2 is fixed.

Fifth commit on the reproducibility loop, each closing a real gap the prior review surfaced — the trust chain has gotten genuinely tighter each pass. Anchor the regexp and fix the dispatch tags and this is ready.

… narrow signing scope

MUST FIX 1: The `--certificate-identity-regexp` cosign uses Go's
`regexp.MatchString`, which is unanchored (substring match) — the prior
regex `heads/main` would accept a SAN merely containing that substring,
e.g. `heads/main-attacker`. That defeated the "only main and version-tag
refs are trusted" guarantee the comments claimed. Anchored both ends,
escaped the literal dots, and constrained the tag portion to
`tmp-router-v[0-9].*` so a branch named `tmp-router-vroot` cannot slip
through:

  ^https://github\.com/.../\.github/workflows/tmp-router-image\.yml
   @refs/(heads/main|tags/tmp-router-v[0-9].*)$

Applied in all four locations that ship this string to an
auditor/verifier: the sign step comment, the attest step comment, the
docs `cosign verify` example, and the docs `cosign verify-attestation`
example. Comment updated to spell out that the anchors are load-bearing
against Go's default substring semantics.

MUST FIX 2: On `workflow_dispatch`, the metadata step produced zero tags
(`edge`/`type=sha` gated on push event; `type=match` rules only fire on
tag refs) but `push: != 'pull_request'` was true — so
`docker/build-push-action` was asked to push with an empty tag list and
failed. Now every push/sign/attest-related gate is
`github.event_name == 'push'` (not `!= 'pull_request'`), so
`workflow_dispatch` runs build-only, matching the header comment (updated
to say so explicitly). Signing and attestation carry an additional
trusted-refs pin
(`github.ref == 'refs/heads/main' || startsWith(github.ref,
'refs/tags/tmp-router-v')`) as defense-in-depth against a future trigger-
list expansion silently widening what gets signed.

Plus a follow-up fix on the repro-gate diagnostic: under `set -euo
pipefail`, `actual="$(scripts/build-tmp-router.sh ...)"` failing aborted
the step before the `::error::` block could tail the captured stderr —
an operator saw "step failed" with no context. Now the rebuild's exit
status is captured explicitly with `if ! actual="$(...)"; then ...`, so
a rebuild *failure* surfaces the same diagnostic block as a rebuild
*mismatch*. Also added `trap cleanup EXIT` so per-platform `mktemp`
stderr files don't leak.

Reproducibility check: same per-platform digest as prior rounds.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean build-side of the TEE attestation chain. The factoring is right: sign the multi-arch index, but bind trust to per-platform image-manifest digests, and verify a no-cache clean rebuild before cosign sign so a poisoned GHA cache can never get a signature. That ordering is the load-bearing part, and it's correct.

Things I checked

  • cert-identity regexp is correctly anchored. ^https://github\.com/adcontextprotocol/adcp-go/\.github/workflows/tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v[0-9].*)$ blocks heads/main-attacker (the main alternative is pinned to $), tmp-router-vroot (must be followed by [0-9]), and any fork/feature-branch ref (fork Fulcio SANs carry the fork owner; feature branches produce refs/heads/<branch>). security-reviewer: no bypass reachable. tmp-router-image.yml:306,331.
  • Fork PRs cannot sign or push. Trigger is pull_request, not pull_request_target, so fork runs get a read-only token and no id-token; push/login/sign/attest are all gated github.event_name == 'push' plus a trusted-ref pin. tmp-router-image.yml:99,130,312,335.
  • The reproducibility comparison is logically valid. Publish-path build carries provenance: mode=max/sbom: true; those attach as separate attestation manifests and do not alter the per-platform image-manifest digest, so comparing the index-extracted per-platform digest against the script's --provenance=false --sbom=false single-platform rebuild is sound. tmp-router-image.yml:135-141,172-227.
  • Every third-party action is SHA-pinned — checkout, qemu, buildx, login, metadata, build-push, upload-artifact, cosign-installer. No floating tags.
  • SDE is single-sourced in scripts/tmp-router-sde.sh (shared by CI and the local script) with an empty-result floor to 0; tonumber on date_epoch is safe in both emitters. scripts/tmp-router-sde.sh:35-39, scripts/build-tmp-router.sh:614-619.
  • TEE measurement mapping is accurate. ad-tech-protocol-expert: sound-with-caveats — the two claims most likely to prevent a broken allowlist (TDX MRTD ≠ workload → RTMR3; SEV-SNP SNP_MEASUREMENT ≠ workload → dm-verity roothash) are present and correct, as is the GCP launch-by-per-platform-digest caveat and the unchanged Nitro PCR0/EIF row.
  • CI on this PR is green, including the new Build & publish job (build-only on pull_request) and Analyze (actions). Test-plan box "CI workflow runs successfully on this PR" is satisfied.

Follow-ups (non-blocking — file as issues)

  • Prerelease tags hijack the floating tags (code-reviewer: Major). The tag rules at tmp-router-image.yml:114-117 use type=match (raw regex), not type=semver. Pushing tmp-router-v1.2.3-rc1 matches tmp-router-v(\d+\.\d+)1.2 and tmp-router-v(\d+)1, and latest fires on any refs/tags/tmp-router-v* — so an RC would repoint latest, 1, and 1.2 at the prerelease image and each gets cosign-signed as authoritative. No adopter is affected yet (the GHCR package doesn't exist until the first push), so this is a follow-up — but fix it before the first real tag push. Switch to type=semver,pattern=... (excludes prereleases from the floating forms) or gate latest/major/minor on a no-prerelease ref pattern. The one thing four rounds of review didn't surface.
  • GCP claim-path hedge (ad-tech-protocol-expert). docs/tmp-router-reproducible-build.md:524 asserts the submods.container.image_digest vs submods.confidential_space split as settled fact. It's the right path today, but the Confidential Space token schema is versioned and has shifted. Add a "confirm against the CS token version your workload runs under" note so an operator on a different launch-spec doesn't hardcode a claim path that isn't there.
  • arm64 clean rebuild under a 30-minute job budget. The verify step does a full no-cache rebuild of both platforms (arm64 via QEMU) on top of the publish build, under timeout-minutes: 30 (tmp-router-image.yml:68,185-226). A timeout fails before signing (fail-safe, integrity preserved) — but confirm real run times so it doesn't become a flaky merge gate.
  • PR body table is stale. It still lists submods.confidential_space.image_digest; the doc correctly says submods.container.image_digest. Doc is right, body wasn't updated after commit 2.

Minor nits (non-blocking)

  1. Unescaped . in tmp-router-v[0-9].*. After the first digit, .* matches any char including /. Not exploitable — the string must also be a real github.ref that passed the on: tags: filter, and git refname rules forbid .. and friends. Tightening to tmp-router-v[0-9][^@]* is cosmetic. tmp-router-image.yml:306.
  2. Flag-value handling relies on set -u. --platform/--push/--measurements-out as the final arg with no value aborts with a bash "unbound variable" rather than the script's own usage error. Fail-loud, acceptable. scripts/build-tmp-router.sh:40-47.

Approving. Follow-ups noted below — the prerelease-tag one before the first tmp-router-v* tag.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant