diff --git a/.github/workflows/tmp-router-image.yml b/.github/workflows/tmp-router-image.yml new file mode 100644 index 00000000..77ab3097 --- /dev/null +++ b/.github/workflows/tmp-router-image.yml @@ -0,0 +1,339 @@ +name: tmp-router image + +# Reproducibly builds the cmd/router OCI image for linux/amd64 and +# linux/arm64, pushes to ghcr.io///tmp-router, signs each pushed +# digest with cosign keyless (OIDC against Sigstore), and publishes a signed +# measurements manifest as both a workflow artifact and (on tag) a cosign +# attestation attached to the image. +# +# The reproducibility property: an auditor cloning this repo at the same +# revision and running scripts/build-tmp-router.sh produces the same image +# digest published here. That digest is what a TEE attestation verifier +# allowlists against the bound workload measurement. +# +# Triggers: +# - push to main → tags: edge, main- +# - push of tmp-router-v* → tags: , ., , latest +# - pull_request → build only, no push (verifies the build stays green) +# - workflow_dispatch → manual rebuild, build-only (no push, no sign) +# +# Permissions: +# - packages: write → push to GHCR +# - id-token: write → cosign keyless signing +# +# First-push note: GHCR creates the package as private. After the first +# successful push, a repo admin must flip visibility to public via +# GitHub → Packages → tmp-router → Package settings → Change visibility. + +on: + push: + branches: [main] + tags: ['tmp-router-v*'] + pull_request: + branches: [main] + paths: + - cmd/router/** + - router/** + - tmproto/** + - targeting/** + - urlcanon/** + - go.mod + - go.sum + - scripts/build-tmp-router.sh + - .github/workflows/tmp-router-image.yml + workflow_dispatch: + +concurrency: + group: tmp-router-image-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + id-token: write + +env: + IMAGE: ghcr.io/${{ github.repository }}/tmp-router + +jobs: + build: + name: Build & publish + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + # Full history is required for the SOURCE_DATE_EPOCH derivation to + # agree with a local clone: a shallow CI checkout (default depth 1) + # truncates history, so `git log -1 -- ` resolves a + # different last-touching-commit timestamp than an auditor's full + # clone, producing a different SDE and a different image digest. + fetch-depth: 0 + + - name: Compute SOURCE_DATE_EPOCH + id: sde + # Delegated to scripts/tmp-router-sde.sh so the CI and local-rebuild + # SDE derivations can never drift — a drift would cause a genuinely + # reproducible build to fail the documented verification check + # because CI and an auditor would compute different layer mtimes. + run: | + set -euo pipefail + SDE="$(scripts/tmp-router-sde.sh)" + echo "source_date_epoch=${SDE}" >> "$GITHUB_OUTPUT" + echo "SOURCE_DATE_EPOCH=${SDE}" + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3 + + - name: Log in to GHCR + if: github.event_name == 'push' + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute tags & labels + id: meta + uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=edge,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + type=sha,prefix=main-,format=short,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + type=match,pattern=tmp-router-v(.*),group=1 + type=match,pattern=tmp-router-v(\d+\.\d+),group=1 + type=match,pattern=tmp-router-v(\d+),group=1 + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/tmp-router-v') }} + labels: | + org.opencontainers.image.title=tmp-router + org.opencontainers.image.description=Reproducibly-built TMP Router (cmd/router) for TEE-attested operation + org.opencontainers.image.vendor=Ad Context Protocol + + - name: Build & push + id: build + uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6 + with: + context: . + file: cmd/router/Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + SOURCE_DATE_EPOCH=${{ steps.sde.outputs.source_date_epoch }} + # Provenance and SBOM are emitted as additional manifests inside + # the image index. They DO change the index digest (the index is + # the set of manifests it carries), but they do NOT change the + # per-platform image-manifest digests this workflow records below. + # mode=max captures full build provenance. + provenance: mode=max + sbom: true + cache-from: type=gha,scope=tmp-router + cache-to: type=gha,mode=max,scope=tmp-router + + - name: Resolve per-platform image-manifest digests + id: platforms + # The build's `digest` output is the multi-arch INDEX digest, which + # is not directly comparable to a single-platform local rebuild + # (the index also references provenance/SBOM attestation manifests + # that change the index hash). Verifiers and TEE attestation chains + # bind to a specific platform's image-manifest digest. Resolve those + # here so the measurements manifest publishes the values an auditor + # actually compares. + if: github.event_name == 'push' + env: + INDEX_REF: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + RAW="$(docker buildx imagetools inspect --raw "$INDEX_REF")" + AMD64="$(printf '%s' "$RAW" | jq -r '.manifests[] | select(.platform.architecture=="amd64" and .platform.os=="linux" and (.annotations["vnd.docker.reference.type"] | not)) | .digest')" + ARM64="$(printf '%s' "$RAW" | jq -r '.manifests[] | select(.platform.architecture=="arm64" and .platform.os=="linux" and (.annotations["vnd.docker.reference.type"] | not)) | .digest')" + if [ -z "$AMD64" ] || [ -z "$ARM64" ]; then + echo "failed to resolve per-platform digests from index $INDEX_REF" >&2 + printf '%s\n' "$RAW" >&2 + exit 1 + fi + echo "amd64=${AMD64}" >> "$GITHUB_OUTPUT" + echo "arm64=${ARM64}" >> "$GITHUB_OUTPUT" + echo "linux/amd64 = ${AMD64}" + echo "linux/arm64 = ${ARM64}" + + - name: Verify reproducibility (no-cache clean rebuild) + # The publish path uses `cache-from/cache-to: type=gha,scope=tmp-router` + # so iterating PRs is fast. GHA cache is not a trust boundary: a + # poisoned cached layer would land in a "reproducible" image that + # CI then signs and attests as authoritative. To close that gap, we + # rebuild each published platform here using scripts/build-tmp-router.sh + # — which does NOT consult the GHA cache — and assert the resulting + # per-platform image-manifest digests equal what the publish-path + # build produced. A mismatch means the cached path diverged from a + # clean build; fail before cosign sign so we never publish a signed + # digest we could not reproduce. Both amd64 and arm64 are verified + # (arm64 via QEMU on the amd64 runner — slower but symmetric with + # what's actually published and signed). + if: github.event_name == 'push' + env: + EXPECTED_AMD64: ${{ steps.platforms.outputs.amd64 }} + EXPECTED_ARM64: ${{ steps.platforms.outputs.arm64 }} + run: | + set -euo pipefail + STDERR_LOGS=() + cleanup() { for f in "${STDERR_LOGS[@]}"; do rm -f "$f"; done; } + trap cleanup EXIT + + verify_platform() { + local platform="$1" expected="$2" + local stderr_log + stderr_log="$(mktemp)" + STDERR_LOGS+=("$stderr_log") + + # Capture the rebuild's exit status explicitly. Under + # `set -euo pipefail`, `actual="$(script ...)"` would abort + # the step on script failure before the diagnostic block + # below could run — the operator would see only "step + # failed" with no stderr context. Explicit capture surfaces + # the build failure too, not just the digest mismatch. + local actual + if ! actual="$(scripts/build-tmp-router.sh --platform "$platform" 2>"$stderr_log" | tail -n1)"; then + echo "::error::Reproducibility check FAILED for $platform — the no-cache clean rebuild itself errored." + echo "--- clean-build stderr (last 60 lines) ---" + tail -n 60 "$stderr_log" || true + return 1 + fi + + echo "expected ($platform, publish-path build) : $expected" + echo "actual ($platform, no-cache clean rebuild): $actual" + if [ "$expected" != "$actual" ]; then + echo "::error::Reproducibility check FAILED for $platform — the publish-path digest does not match a clean rebuild." + echo "This typically indicates a poisoned GHA cache or non-deterministic input." + echo "--- clean-build stderr (last 40 lines) ---" + tail -n 40 "$stderr_log" || true + return 1 + fi + } + verify_platform "linux/amd64" "$EXPECTED_AMD64" + verify_platform "linux/arm64" "$EXPECTED_ARM64" + echo "Reproducibility verified: both linux/amd64 and linux/arm64 publish-path digests match clean rebuilds." + + - name: Write measurements manifest + id: manifest + # The manifest publishes per-platform image-manifest digests, not + # the index digest. The index digest is recorded as `index_digest` + # for traceability but is NOT what a TEE attestation verifier (or a + # `scripts/build-tmp-router.sh --platform

` auditor) compares. + if: github.event_name == 'push' + run: | + set -euo pipefail + mkdir -p artifacts + MANIFEST="artifacts/tmp-router-measurements.json" + jq -n \ + --arg index_digest "${{ steps.build.outputs.digest }}" \ + --arg image "${{ env.IMAGE }}" \ + --arg amd64_digest "${{ steps.platforms.outputs.amd64 }}" \ + --arg arm64_digest "${{ steps.platforms.outputs.arm64 }}" \ + --arg source_date_epoch "${{ steps.sde.outputs.source_date_epoch }}" \ + --arg source_rev "${{ github.sha }}" \ + --arg source_rev_short "$(git rev-parse --short HEAD)" \ + --arg source_ref "${{ github.ref }}" \ + --arg workflow_run "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + '{ + schema: "tmp-router-measurements/v1", + image: $image, + index_digest: (if $index_digest == "" then null else $index_digest end), + platform_digests: ( + (if $amd64_digest == "" then {} else {"linux/amd64": $amd64_digest} end) + + (if $arm64_digest == "" then {} else {"linux/arm64": $arm64_digest} end) + ), + source: { + revision: $source_rev, + revision_short: $source_rev_short, + ref: $source_ref, + date_epoch: ($source_date_epoch | tonumber) + }, + build: { + workflow_run: $workflow_run, + runner: "github-hosted ubuntu-latest" + }, + reproducibility: { + note: "Reproduce with `scripts/build-tmp-router.sh --platform

` on the named revision and a BuildKit-compatible Docker (24+); compare the resulting digest against the matching entry in `platform_digests`. The `index_digest` is the multi-arch index pushed to the registry and includes provenance/SBOM attestation manifests; it is NOT what an auditor or TEE attestation verifier compares. For GCP Confidential Space the verifier compares the `linux/amd64` entry against `submods.container.image_digest` in the attestation token. For Nitro / TDX / SEV-SNP, derive the platform-specific measurement (EIF PCR0 / quote MRTD / SNP_MEASUREMENT) from the per-platform image with the documented platform-tool version; see docs/tmp-router-reproducible-build.md." + } + }' > "$MANIFEST" + echo "manifest=$MANIFEST" >> "$GITHUB_OUTPUT" + cat "$MANIFEST" + + - name: Upload measurements manifest (workflow artifact) + if: github.event_name == 'push' + uses: actions/upload-artifact@de65e23aa2b7e23d713bb51fbfcb6d502f8667d8 # v4 + with: + name: tmp-router-measurements-${{ steps.build.outputs.digest }} + path: ${{ steps.manifest.outputs.manifest }} + if-no-files-found: error + + - name: Install cosign + if: github.event_name == 'push' + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + with: + # renovate: datasource=github-releases depName=sigstore/cosign versioning=semver + cosign-release: v3.0.6 + + - name: Sign image (keyless OIDC) + # Signs every pushed tag against the build's content digest (the + # multi-arch index), so all tags that resolve to the same image + # share a single transparency-log entry per digest. Verifiers MUST + # cosign verify the index BEFORE trusting any per-platform manifest + # digest extracted from it — see docs/tmp-router-reproducible-build.md + # for the canonical verification flow. The cert-identity regexp + # below is anchored (^...$) because cosign uses Go's + # regexp.MatchString, which is unanchored (substring match): without + # the anchors, `heads/main` would match `heads/main-attacker`. It + # also requires the tag portion to start with a digit + # (`tmp-router-v[0-9].*`) so a branch named `tmp-router-vroot` can + # not slip through. Only `main` and `tmp-router-v...` refs are + # trusted; a fork or branch build carries a different ref in the + # OIDC token and fails this match. + # cosign verify ghcr.io///tmp-router: \ + # --certificate-identity-regexp='^https://github\.com///\.github/workflows/tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v[0-9].*)$' \ + # --certificate-oidc-issuer='https://token.actions.githubusercontent.com' + # Signing scope is intentionally pinned to `main` and version tags so + # a future trigger-list expansion cannot silently widen what gets + # signed — the `on:` filter already narrows to these refs, but the + # explicit `if:` here is defense-in-depth against drift. + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/tmp-router-v')) + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + echo "$TAGS" | while IFS= read -r tag; do + [ -z "$tag" ] && continue + cosign sign --yes "${tag}@${DIGEST}" + done + + - name: Attest measurements manifest (keyless OIDC) + # Attaches the measurements manifest as a cosign attestation against + # the image digest, so it is discoverable from the published image + # rather than only from this workflow run's artifacts. Fires on + # every push (main + tag), so operators pinning `edge` or + # `main-` also get a registry-discoverable manifest. Verify with: + # cosign verify-attestation --type 'https://adcontextprotocol.org/tmp-router-measurements/v1' \ + # ghcr.io///tmp-router@ \ + # --certificate-identity-regexp='^https://github\.com///\.github/workflows/tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v[0-9].*)$' \ + # --certificate-oidc-issuer='https://token.actions.githubusercontent.com' + # Same trusted-refs pin as the sign step — attestation and signature + # must have the same signing scope. + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/tmp-router-v')) + env: + IMAGE: ${{ env.IMAGE }} + DIGEST: ${{ steps.build.outputs.digest }} + MANIFEST: ${{ steps.manifest.outputs.manifest }} + run: | + set -euo pipefail + cosign attest --yes \ + --predicate "$MANIFEST" \ + --type "https://adcontextprotocol.org/tmp-router-measurements/v1" \ + "${IMAGE}@${DIGEST}" diff --git a/cmd/router/Dockerfile b/cmd/router/Dockerfile index 5a994741..6d5dcf70 100644 --- a/cmd/router/Dockerfile +++ b/cmd/router/Dockerfile @@ -1,4 +1,26 @@ -FROM golang:1.26-alpine AS build +# syntax=docker/dockerfile:1.7 +# +# Reproducible build for the TMP router. +# +# The image bytes are deterministic given the same source tree, the same +# Dockerfile, and a BuildKit-compatible builder (Docker 24+). Base images are +# pinned by digest; the Go build uses -trimpath / -buildvcs=false / -buildid= +# so the binary carries no path, VCS, or build-id entropy; SOURCE_DATE_EPOCH +# normalizes layer mtimes when set. +# +# See docs/tmp-router-reproducible-build.md for the verification procedure and +# how the resulting image digest relates to TEE attestation measurements. + +# renovate: datasource=docker depName=library/golang +ARG GO_IMAGE=golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 +# renovate: datasource=docker depName=distroless/static-debian13 +ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian13:nonroot@sha256:963fa6c544fe5ce420f1f54fb88b6fb01479f054c8056d0f74cc2c6000df5240 + +FROM ${GO_IMAGE} AS build +ARG TARGETOS +ARG TARGETARCH +ARG SOURCE_DATE_EPOCH=0 +ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} WORKDIR /src COPY go.mod go.sum* ./ COPY tmproto/ tmproto/ @@ -7,8 +29,13 @@ COPY urlcanon/ urlcanon/ COPY router/ router/ COPY cmd/router/ cmd/router/ WORKDIR /src/cmd/router -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /router . +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build \ + -trimpath \ + -buildvcs=false \ + -ldflags="-s -w -buildid=" \ + -o /router . -FROM gcr.io/distroless/static-debian13:nonroot +FROM ${RUNTIME_IMAGE} COPY --from=build /router /router ENTRYPOINT ["/router"] diff --git a/docs/tmp-router-reproducible-build.md b/docs/tmp-router-reproducible-build.md new file mode 100644 index 00000000..b4d5ba1c --- /dev/null +++ b/docs/tmp-router-reproducible-build.md @@ -0,0 +1,146 @@ +# Reproducible build of the TMP router + +This document explains how the TMP router (`cmd/router`) is built reproducibly, how to verify a published image matches the source, and how the resulting image digest relates to TEE attestation measurements. It is intended for operators, auditors, and verifiers running a TEE-attested TMP Router deployment. + +The wire-protocol side of TEE attestation (the envelope, the binding rule, the `/.well-known/` endpoint) is specified separately — see [adcontextprotocol/adcp PR #5770](https://github.com/adcontextprotocol/adcp/pull/5770) and `docs/trusted-match/router-attestation.mdx` in the spec repo. This page only covers the *build* side: how we produce the binary whose measurement the wire spec lets a verifier check. + +## Why reproducibility matters here + +A TEE attestation document carries a cryptographic measurement of the running workload — for Nitro that's the PCR0 hash of the EIF; for Intel TDX it's MRTD inside the quote; for AMD SEV-SNP it's SNP_MEASUREMENT; for GCP Confidential Space the workload-image digest is one of the bound claims. A verifier compares that measurement against an *allowlist* of expected values. The allowlist is only as trustworthy as the procedure that produces the expected values. + +If two operators build the same source tree and get different image digests, the allowlist mechanism breaks: you can never tell whether a divergent measurement is "a bug in the build" or "a backdoored binary." Reproducibility is the property that closes that loop — anyone can rebuild from source and confirm the published measurement. + +## What's pinned + +The Dockerfile at [`cmd/router/Dockerfile`](../cmd/router/Dockerfile) pins: + +- **Base images by digest.** `golang:1.26-alpine` and `gcr.io/distroless/static-debian13:nonroot` are referenced by their multi-arch index digests. Renovate keeps the digests fresh; bumps land as commits and CI republishes a new measurement. +- **Go toolchain version** is pinned by the base image (`golang:1.26-alpine`). +- **Build flags.** `CGO_ENABLED=0`, `-trimpath`, `-buildvcs=false`, and `-ldflags="-s -w -buildid="` strip all entropy from the produced binary — file paths, VCS metadata, Go's build-id, and the debug/symbol tables. Without these, the binary varies per build environment even with the same source. +- **`SOURCE_DATE_EPOCH`** is passed in as a build arg from the timestamp of the last commit that touched a build input. BuildKit uses it to normalize layer mtimes. + +Multi-platform builds (`linux/amd64`, `linux/arm64`) are deterministic per platform. The CI workflow pushes a multi-arch *index* that references per-platform image manifests **plus** provenance and SBOM attestation manifests; the per-platform image manifests are what an auditor reproduces and what a TEE attestation chain binds against. The index digest itself is not reproducible by a local single-platform build (and not expected to be) — the relevant comparison is always at the per-platform level. + +## Verifying a published image + +Anyone — auditor, CISO, regulator, paranoid operator — can verify reproducibility with the following procedure. The order matters: the cosign signature must be verified *before* any digest extracted from the registry is trusted, otherwise the procedure is anchoring trust in an unverified registry response. + +```bash +# 0. Clone at the same revision as the published image. +git clone https://github.com/adcontextprotocol/adcp-go +cd adcp-go +git checkout + +# 1. Verify the cosign keyless signature on the published tag. This binds +# trust to the GitHub Actions workflow that built and signed the image: +# only signatures whose OIDC token names this exact workflow on `main` +# or a `tmp-router-v*` tag are accepted. Capture the verified index +# digest for the next step. +VERIFIED_INDEX_DIGEST="$(cosign verify \ + ghcr.io/adcontextprotocol/adcp-go/tmp-router: \ + --certificate-identity-regexp='^https://github\.com/adcontextprotocol/adcp-go/\.github/workflows/tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v[0-9].*)$' \ + --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \ + --output json \ + | jq -r '.[0].critical.image."docker-manifest-digest"')" +echo "verified index digest: $VERIFIED_INDEX_DIGEST" + +# 2. Extract the per-platform image-manifest digest from the verified +# index — NOT from the registry tag. The index references one +# image-manifest per platform plus provenance/SBOM attestation +# manifests; the filter on `vnd.docker.reference.type` excludes the +# attestations and keeps the actual workload manifests. The digest +# you read here inherits its trust from the cosign verify in step 1. +PUBLISHED_AMD64_DIGEST="$(docker buildx imagetools inspect \ + "ghcr.io/adcontextprotocol/adcp-go/tmp-router@${VERIFIED_INDEX_DIGEST}" \ + --raw \ +| jq -r '.manifests[] + | select(.platform.architecture == "amd64" and .platform.os == "linux" + and (.annotations["vnd.docker.reference.type"] | not)) + | .digest')" +echo "published linux/amd64 digest: $PUBLISHED_AMD64_DIGEST" + +# 3. Rebuild locally for the same platform. The script prints the per-platform +# image-manifest digest as its last line of stdout. +LOCAL_AMD64_DIGEST="$(scripts/build-tmp-router.sh --platform linux/amd64 | tail -n1)" +echo "local rebuild linux/amd64 digest: $LOCAL_AMD64_DIGEST" + +# 4. Compare. +test "$PUBLISHED_AMD64_DIGEST" = "$LOCAL_AMD64_DIGEST" \ + && echo "OK — reproducibility verified." \ + || { echo "DIVERGED — do not allowlist this image." >&2; exit 1; } +``` + +The two digests must be identical. If they are not, do not allowlist the published image — open an issue and treat the divergence as a build-pipeline integrity incident until explained. + +The `platform_digests` map in the published `tmp-router-measurements.json` records the same per-platform digests for convenience, but it is **not** a substitute for the rebuild — the cosign attestation that carries the manifest proves the workflow produced those values, not that an independent build reproduces them. CI itself performs an independent no-cache rebuild before signing (`Verify reproducibility (no-cache clean rebuild)` in the workflow), but operators with a higher bar than "trust our CI" run the procedure above themselves. + +The Sigstore signature on the published image is independent of reproducibility — it tells you "this GitHub Actions workflow built and signed this digest." Reproducibility tells you "this source tree produces this digest." Both are needed: signature without reproducibility means a malicious workflow could publish a backdoored binary; reproducibility without signature means anyone could publish look-alike binaries. + +## The measurements manifest + +Every CI build produces a `tmp-router-measurements.json` artifact with this shape: + +```json +{ + "schema": "tmp-router-measurements/v1", + "image": "ghcr.io/adcontextprotocol/adcp-go/tmp-router", + "index_digest": "sha256:...", + "platform_digests": { + "linux/amd64": "sha256:...", + "linux/arm64": "sha256:..." + }, + "source": { + "revision": "", + "revision_short": "", + "ref": "refs/tags/tmp-router-v0.1.0", + "date_epoch": 1782825869 + }, + "build": { "workflow_run": "...", "runner": "github-hosted ubuntu-latest" }, + "reproducibility": { "note": "..." } +} +``` + +`platform_digests` is the value a verifier or auditor compares against. `index_digest` is the multi-arch index pushed to the registry (the same digest cosign signs) and is recorded for traceability, but it includes the `provenance: mode=max` and SBOM attestation manifests — so it is not byte-stable across CI runs that recompute provenance, and it is not what a TEE attestation chain binds against. Local reproducible builds (single-platform) cannot reproduce an index digest by construction; they reproduce a per-platform image-manifest digest, which is what `platform_digests` records. + +The local script's manifest shares the same schema (`tmp-router-measurements/v1`) with `platform_digests` carrying the one platform built, and `index_digest` omitted; an additional local-only `source.dirty` flag is included so an auditor's running build state is visible. A schema validator that wants strict cross-emitter compatibility should treat both `index_digest` and `source.dirty` as optional. + +For every push (both `main` and `tmp-router-v*` tags) the CI manifest is also attached to the published image as a Sigstore attestation under the stable predicate type `https://adcontextprotocol.org/tmp-router-measurements/v1`. Verifiers retrieve it from the registry with: + +```bash +cosign verify-attestation \ + --type 'https://adcontextprotocol.org/tmp-router-measurements/v1' \ + ghcr.io/adcontextprotocol/adcp-go/tmp-router@sha256: \ + --certificate-identity-regexp='^https://github\.com/adcontextprotocol/adcp-go/\.github/workflows/tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v[0-9].*)$' \ + --certificate-oidc-issuer='https://token.actions.githubusercontent.com' +``` + +The `--type` URI pins verification to the schema this workflow produces — `--type custom` would match any custom predicate attached to the image. + +## How per-platform digests map to TEE measurements + +A per-platform image-manifest digest from `platform_digests` is the *workload identity* the verifier needs, but the format-specific measurement value differs: + +| TEE format | Headline measurement | What the measurement actually covers, and how to bind it to `platform_digests` | +|---|---|---| +| GCP Confidential Space | `submods.container.image_digest` claim in the attestation token (the `container` submodule carries workload-image identity; `confidential_space` carries platform/support attributes). | **Direct equality**, with a launch-reference caveat. The token's `submods.container.image_digest` compares byte-for-byte against the `platform_digests` entry for the platform the workload runs on (a Confidential Space VM runs a single platform image, not a multi-arch index, so the comparison is per-platform). The caveat: this only holds if the deployment is launched by the per-platform manifest digest, i.e. `tmp-router@`. Launch by tag or by the multi-arch index digest and `submods.container.image_digest` may carry the index digest — which the reproducibility procedure never treats as trusted. **Operator MUST launch the workload as `tmp-router@`**, sourced from the `platform_digests` entry validated in the verification procedure above. | +| AWS Nitro Enclaves | PCR0 (matches EIF image), plus PCR1/PCR2 | **Full workload measurement.** PCR0 covers the entire EIF — kernel + init + application binary — so pinning it pins the tmp-router binary. Derived deterministically from the OCI image plus the Nitro CLI version *and* the linuxkit kernel/init blobs that ship with that Nitro CLI release — bumping the Nitro CLI base layer changes PCR0 even when the OCI image is unchanged. Build the EIF with `nitro-cli build-enclave --docker-uri @` on a Nitro-enabled host; the PCR values fall out of the build. The published `tmp-router-measurements.json` declares the OCI per-platform digest; the operator's Nitro-build step produces the EIF measurement separately, pinned to a specific Nitro CLI version. | +| Intel TDX | MRTD in the quote | **Partial — MRTD alone is not a workload measurement.** MRTD covers the TD's initial guest memory (TDVF firmware plus the pages loaded at TD build time) — the boot chain, not the running workload. The tmp-router binary lands in the guest via the boot chain and is measured into an **RTMR** (Runtime Measurement Register, typically RTMR3) by an IMA-style measured-boot chain, not into MRTD. An operator who allowlists an MRTD believing it identifies the tmp-router binary pins only the firmware/boot chain — a strictly weaker guarantee than PCR0. Deploying tmp-router on TDX with a meaningful workload allowlist requires (i) publishing the expected RTMR value(s) for the built rootfs/image alongside the per-platform digest, and (ii) verifying both MRTD (firmware) and the relevant RTMR (workload) in the attestation policy. | +| AMD SEV-SNP | LAUNCH_MEASUREMENT / SNP_MEASUREMENT in the attestation report | **Partial — SNP_MEASUREMENT alone is not a workload measurement.** Same shape as TDX MRTD: SNP_MEASUREMENT is the launch digest of the initial guest memory (OVMF firmware + boot pages), not the workload. Workload identity is anchored separately — most commonly a dm-verity roothash embedded in a measured kernel command line, or a measured initrd that dm-verity-mounts the rootfs. An operator who allowlists SNP_MEASUREMENT believing it pins the tmp-router binary pins only the firmware/boot chain. Deploying tmp-router on SEV-SNP with a meaningful workload allowlist requires publishing the expected dm-verity roothash (or equivalent workload anchor) alongside the per-platform digest, and verifying both SNP_MEASUREMENT (firmware) and the workload anchor in the attestation policy. | + +**Bottom line for allowlist policy.** For GCP Confidential Space and AWS Nitro Enclaves, the headline measurement pins the tmp-router binary and a `platform_digests` entry (or a derived PCR0) is sufficient. For Intel TDX and AMD SEV-SNP, the headline register measures the VM boot chain only — the OCI image the operator built is the identity of the *guest workload*, but the guest workload measurement lives in a *separate* register (RTMR / dm-verity roothash) that this CI does not produce. TDX / SEV-SNP operators need a companion workload-anchor derivation step, out of scope for this PR. + +For Nitro, TDX, and SEV-SNP the platform-specific measurement is derived once per release on a controlled host with the platform tooling — GitHub-hosted runners don't have Nitro / TDX / SEV-SNP CLIs. That derivation step is deterministic given a fixed per-platform image and fixed tool versions; the outputs should be published alongside the AdCP `tmp-router-measurements.json` manifest. + +## What this does NOT cover + +- **Per-platform PCR / MRTD / SNP_MEASUREMENT publication.** Build-side responsibility is up to and including the OCI image digest. The platform-specific measurement is derived elsewhere (see table above). A follow-up will add the operator-side procedure for publishing those per-platform values once we have at least one Nitro deployment in the loop. +- **Transparency-log of build provenance.** The Sigstore signature and the in-toto provenance (`provenance: mode=max` in the workflow) cover this for the OCI layer; we have not yet hooked a separate Rekor-style measurement registry. +- **Reproducibility of dependencies the Go toolchain does not pin.** `CGO_ENABLED=0` removes the cgo / libc concern; module downloads (via `GOPROXY`, which fetches from the Go module ecosystem during `go build`) are checksum-pinned by `go.sum` so the download source doesn't affect the produced bytes; the Alpine base image is pinned by digest. + +## Reporting reproducibility failures + +If `scripts/build-tmp-router.sh` produces a different digest than the published image at the same revision, the divergence is either a bug in this pipeline or a compromise in CI. Treat it as a security incident: + +1. Capture the local build's `tmp-router-measurements.json`. +2. Open an issue tagged `security/build-integrity` with the local manifest, the published manifest, your platform/Docker versions, and any toolchain mismatches. +3. Do not allowlist the published digest in any TEE attestation policy until the divergence is explained. diff --git a/scripts/build-tmp-router.sh b/scripts/build-tmp-router.sh new file mode 100755 index 00000000..a60bcfcf --- /dev/null +++ b/scripts/build-tmp-router.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# Reproducible build of the TMP router OCI image. +# +# This script produces the same image digest as the CI workflow at +# .github/workflows/tmp-router-image.yml given the same source tree and the +# same Docker / BuildKit version. Use it to: +# +# - rebuild a release locally and confirm the digest matches what was +# published (auditor / verifier-side reproducibility check) +# - produce the digest a TEE attestation verifier needs to allowlist +# +# Usage: +# scripts/build-tmp-router.sh [--platform ] [--load|--push ] [--measurements-out ] +# +# Examples: +# scripts/build-tmp-router.sh # build linux/amd64 to BuildKit cache, print digest +# scripts/build-tmp-router.sh --platform linux/arm64 # arm64 instead +# scripts/build-tmp-router.sh --measurements-out out.json # also write the measurements manifest +# +# Requirements: Docker 24+ with BuildKit, jq (for --measurements-out). +# +# SOURCE_DATE_EPOCH is derived from the last commit that touched the build +# inputs (Dockerfile, Go sources under router/, cmd/router/, tmproto/, +# targeting/, urlcanon/, plus go.mod/go.sum). Pass SOURCE_DATE_EPOCH= +# in the environment to override. + +set -euo pipefail + +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +PLATFORM="linux/amd64" +ACTION="" # one of: "" (build only, no output), "load", "push" +PUSH_TAG="" +MEASUREMENTS_OUT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --platform) + PLATFORM="$2"; shift 2;; + --load) + ACTION="load"; shift;; + --push) + ACTION="push"; PUSH_TAG="$2"; shift 2;; + --measurements-out) + MEASUREMENTS_OUT="$2"; shift 2;; + -h|--help) + sed -n '2,/^set -euo pipefail/p' "$0" | sed 's/^# \{0,1\}//' + exit 0;; + *) + echo "unknown flag: $1" >&2; exit 2;; + esac +done + +if [[ -z "${SOURCE_DATE_EPOCH:-}" ]]; then + # scripts/tmp-router-sde.sh is the single source of truth for the SDE + # derivation, shared with .github/workflows/tmp-router-image.yml. It + # already handles the empty-result-from-`git log` case (shallow clone + # or path filter matches no commit) and returns "0". + SOURCE_DATE_EPOCH="$("$REPO_ROOT/scripts/tmp-router-sde.sh")" +fi +if [[ -z "$SOURCE_DATE_EPOCH" ]]; then + # Defensive: SDE must be a number for BuildKit and for the manifest's + # jq `tonumber`. An empty string would hard-crash the build-arg pass + # and the manifest write. + SOURCE_DATE_EPOCH=0 +fi +export SOURCE_DATE_EPOCH + +format_epoch() { + # `date -u -d "@$epoch"` is GNU (Linux); `date -u -r "$epoch"` is BSD (macOS). + # Try both rather than guess; the local script and CI both go through here. + local epoch="$1" + date -u -d "@$epoch" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ + || date -u -r "$epoch" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ + || echo unknown +} + +echo "==> TMP router reproducible build" >&2 +echo " SOURCE_DATE_EPOCH = $SOURCE_DATE_EPOCH ($(format_epoch "$SOURCE_DATE_EPOCH"))" >&2 +echo " platform = $PLATFORM" >&2 + +METADATA_FILE="$(mktemp -t tmp-router-metadata.XXXXXX.json)" +trap 'rm -f "$METADATA_FILE"' EXIT + +OUTPUT_FLAGS=() +case "$ACTION" in + load) + OUTPUT_FLAGS=(--load);; + push) + if [[ -z "$PUSH_TAG" ]]; then echo "--push requires a tag argument" >&2; exit 2; fi + OUTPUT_FLAGS=(--push --tag "$PUSH_TAG");; + "") + OUTPUT_FLAGS=(--output=type=image,push=false);; +esac + +docker buildx build \ + --file cmd/router/Dockerfile \ + --platform "$PLATFORM" \ + --build-arg "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" \ + --provenance=false \ + --sbom=false \ + --metadata-file "$METADATA_FILE" \ + "${OUTPUT_FLAGS[@]}" \ + . + +DIGEST="$(jq -r '."containerimage.digest" // empty' "$METADATA_FILE")" +if [[ -z "$DIGEST" ]]; then + echo "build succeeded but BuildKit did not report a digest (metadata: $(cat "$METADATA_FILE"))" >&2 + exit 1 +fi + +echo "==> image digest: $DIGEST" >&2 +echo "$DIGEST" + +if [[ -n "$MEASUREMENTS_OUT" ]]; then + SOURCE_REV="$(git rev-parse --verify HEAD 2>/dev/null || echo unknown)" + SOURCE_REV_SHORT="$(git rev-parse --short --verify HEAD 2>/dev/null || echo unknown)" + # `git diff --quiet HEAD` misses untracked files under a COPY'd path; + # `git status --porcelain` covers both modifications and untracked files. + # Scoped to the same build paths tmp-router-sde.sh uses so unrelated + # working-tree changes don't taint the manifest. + if [[ -n "$(git status --porcelain -- \ + cmd/router/Dockerfile cmd/router/ router/ tmproto/ targeting/ \ + urlcanon/ go.mod go.sum 2>/dev/null)" ]]; then + SOURCE_DIRTY="true" + else + SOURCE_DIRTY="false" + fi + # Schema matches .github/workflows/tmp-router-image.yml — `platform_digests` + # is a {: } map (the local build records only the one + # platform it produced); `index_digest` is omitted since a local build does + # not produce a multi-arch index. `source.dirty` is local-only. + jq -n \ + --arg digest "$DIGEST" \ + --arg platform "$PLATFORM" \ + --arg source_date_epoch "$SOURCE_DATE_EPOCH" \ + --arg source_rev "$SOURCE_REV" \ + --arg source_rev_short "$SOURCE_REV_SHORT" \ + --argjson source_dirty "$SOURCE_DIRTY" \ + '{ + schema: "tmp-router-measurements/v1", + platform_digests: { ($platform): $digest }, + source: { + revision: $source_rev, + revision_short: $source_rev_short, + dirty: $source_dirty, + date_epoch: ($source_date_epoch | tonumber) + }, + reproducibility: { + note: "Local single-platform reproducible build. Compare `platform_digests.\"\($platform)\"` to the matching entry under `platform_digests` in the CI-published manifest at the same revision. The CI manifest also records an `index_digest`; that is NOT the value an auditor or a TEE attestation verifier compares — index digests change with provenance/SBOM attestation manifests. See docs/tmp-router-reproducible-build.md." + } + }' > "$MEASUREMENTS_OUT" + echo "==> measurements manifest written to: $MEASUREMENTS_OUT" >&2 +fi diff --git a/scripts/tmp-router-sde.sh b/scripts/tmp-router-sde.sh new file mode 100755 index 00000000..48c4825c --- /dev/null +++ b/scripts/tmp-router-sde.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Print the SOURCE_DATE_EPOCH the tmp-router reproducible build uses: +# the commit time of the last commit that touched a build input. +# +# Called by both scripts/build-tmp-router.sh (local rebuilds) and +# .github/workflows/tmp-router-image.yml (CI) so the path list can never +# drift between them — a drift would cause CI and a local auditor to +# compute different SDE values, produce different image digests, and +# make the documented reproducibility check fail against a genuinely- +# reproducible build. +# +# Emits 0 when no matching commit is found (e.g., a shallow clone whose +# tip does not touch a build path). CI's Verify-reproducibility step +# treats both sides identically because both go through this script. + +set -euo pipefail + +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +SDE="$(git log -1 --format=%ct -- \ + cmd/router/Dockerfile \ + cmd/router/ \ + router/ \ + tmproto/ \ + targeting/ \ + urlcanon/ \ + go.mod \ + go.sum \ + 2>/dev/null || echo 0)" + +# `git log -1 -- ` exits 0 with empty stdout when the path filter +# matches no commit — the `|| echo 0` only fires on git itself failing. +# The empty-string guard below is the one that catches "no commit found." +if [[ -z "$SDE" ]]; then + SDE=0 +fi + +echo "$SDE"