Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions .github/workflows/tmp-router-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
name: tmp-router image

# Reproducibly builds the cmd/router OCI image for linux/amd64 and
# linux/arm64, pushes to ghcr.io/<owner>/<repo>/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-<short_sha>
# - push of tmp-router-v* → tags: <semver>, <major>.<minor>, <major>, latest
# - pull_request → build only, no push (verifies the build stays green)
# - workflow_dispatch → manual rebuild
#
# 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

- name: Compute SOURCE_DATE_EPOCH
id: sde
# Use the timestamp of the last commit that touched a build input.
# This is what scripts/build-tmp-router.sh computes for local rebuilds,
# so CI and local produce the same layer mtimes.
run: |
set -euo pipefail
SDE="$(git log -1 --format=%ct -- \
cmd/router/Dockerfile cmd/router/ router/ tmproto/ targeting/ urlcanon/ \
go.mod go.sum)"
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 != 'pull_request'
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 != 'pull_request' }}
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 separate manifest attestations
# alongside the image — they do NOT change the image digest itself,
# so reproducibility holds. 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: Write measurements manifest
id: manifest
run: |
set -euo pipefail
mkdir -p artifacts
MANIFEST="artifacts/tmp-router-measurements.json"
SOURCE_DIRTY="false"
if ! git diff --quiet HEAD --; then SOURCE_DIRTY="true"; fi
jq -n \
--arg digest "${{ steps.build.outputs.digest }}" \
--arg image "${{ env.IMAGE }}" \
--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 }}" \
--argjson source_dirty "$SOURCE_DIRTY" \
--argjson platforms '["linux/amd64","linux/arm64"]' \
'{
schema: "tmp-router-measurements/v1",
image: $image,
image_digest: $digest,
platforms: $platforms,
source: {
revision: $source_rev,
revision_short: $source_rev_short,
ref: $source_ref,
dirty: $source_dirty,
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 on the named revision and a BuildKit-compatible Docker (24+). image_digest is what a TEE attestation verifier compares against the bound workload-image measurement for formats where the OCI digest IS the measurement (e.g., GCP Confidential Space). For Nitro / TDX / SEV-SNP, derive the platform-specific measurement (EIF PCR0 / quote MRTD / SNP_MEASUREMENT) from this 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)
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 != 'pull_request'
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, so all
# tags that resolve to the same image share a single transparency-log
# entry per digest. Verify with:
# cosign verify ghcr.io/<owner>/<repo>/tmp-router:<tag> \
# --certificate-identity-regexp='https://github.com/<owner>/<repo>/.github/workflows/tmp-router-image\.yml@.*' \
# --certificate-oidc-issuer='https://token.actions.githubusercontent.com'
if: github.event_name != 'pull_request'
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, tag pushes only)
# 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. Verify with:
# cosign verify-attestation --type custom \
# ghcr.io/<owner>/<repo>/tmp-router@<digest> \
# --certificate-identity-regexp='...' \
# --certificate-oidc-issuer='https://token.actions.githubusercontent.com'
if: 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 custom \
"${IMAGE}@${DIGEST}"
33 changes: 30 additions & 3 deletions cmd/router/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
Expand All @@ -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"]
104 changes: 104 additions & 0 deletions docs/tmp-router-reproducible-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# 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 published image is a multi-arch manifest pointing at platform-specific digests.

## Verifying a published image

Anyone — auditor, CISO, regulator, paranoid operator — can verify reproducibility with:

```bash
# 1. Clone at the same revision as the published image.
git clone https://github.com/adcontextprotocol/adcp-go
cd adcp-go
git checkout <commit-or-tag>

# 2. Rebuild locally. The script prints the OCI image digest.
scripts/build-tmp-router.sh --platform linux/amd64

# 3. Compare against the published digest.
docker buildx imagetools inspect ghcr.io/adcontextprotocol/adcp-go/tmp-router:<tag> \
--format '{{.Manifest.Digest}}'
```

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 Sigstore signature on the published image is independent of reproducibility — it tells you "GitHub Actions for this repo 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",
"image_digest": "sha256:...",
"platforms": ["linux/amd64", "linux/arm64"],
"source": {
"revision": "<git sha>",
"revision_short": "<short sha>",
"ref": "refs/tags/tmp-router-v0.1.0",
"dirty": false,
"date_epoch": 1782825869
},
"build": { "workflow_run": "...", "runner": "github-hosted ubuntu-latest" },
"reproducibility": { "note": "..." }
}
```

For tag pushes the manifest is also attached to the published image as a Sigstore attestation (`cosign attest --type custom`). Verifiers retrieve it from the registry with:

```bash
cosign verify-attestation --type custom \
ghcr.io/adcontextprotocol/adcp-go/tmp-router@sha256:<digest> \
--certificate-identity-regexp='https://github.com/adcontextprotocol/adcp-go/.github/workflows/tmp-router-image\.yml@.*' \
--certificate-oidc-issuer='https://token.actions.githubusercontent.com'
```

For non-tag pushes the manifest is only available as a workflow-run artifact.

## How `image_digest` maps to platform measurements

The OCI image digest is the *workload identity* a TEE verifier needs, but the format-specific measurement value differs:

| TEE format | Measurement | Relation to `image_digest` |
|---|---|---|
| GCP Confidential Space | Workload-image digest in the token's `submods.confidential_space.image_digest` claim | **Direct equality** — the verifier compares `image_digest` to the token claim. |
| AWS Nitro Enclaves | PCR0/PCR1/PCR2 in the attestation document | **Derived deterministically** from the OCI image plus the Nitro CLI version. Build the EIF with `nitro-cli build-enclave --docker-uri <image>@<digest>` on a Nitro-enabled host; the PCR values fall out of the build. The published manifest declares the OCI digest; the operator's Nitro-build step produces the EIF measurement. |
| Intel TDX | MRTD in the quote | Same model as Nitro: derived from the image plus the TDX measurement-build chain. |
| AMD SEV-SNP | SNP_MEASUREMENT in the report | Same model: image-plus-host-chain derivation. |

In every non-GCP case, the operator runs a one-shot transformation from the OCI image to the platform-specific measurement on a host with the platform tooling. That transformation is itself deterministic — same OCI image plus same tool version equals same measurement value — but it is *not run in this CI* because GitHub-hosted runners don't have Nitro / TDX / SEV-SNP tooling. Each platform's measurement value should be derived once per release on a controlled host and 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; the Go module proxy plus `go.sum` checksum verification covers source deps; the Alpine base image is pinned by digest. Beyond these, nothing in the build pipeline reaches out to the network at build time.

## 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.
Loading
Loading