Skip to content

Image Attestations and Signatures

This document explains the supply-chain security metadata attached to every image built by this project, and how to verify it.

Overview

Each mirrored image carries four independent pieces of supply-chain metadata, attached to the image digest in GitHub Container Registry (GHCR):

ArtifactProduced byPredicate / typeVerify with
Signaturecosign sign (keyless)https://sigstore.dev/cosign/sign/v1cosign verify
SBOMcosign attest (Trivy SPDX)https://spdx.dev/Documentcosign verify-attestation
SLSA provenanceactions/attest-build-provenancehttps://slsa.dev/provenance/v1gh attestation verify
Mirror metadataactions/attest (custom predicate)https://this-is-tobi.github.io/multiarch-mirror/mirror/v1gh attestation verify

All four are generated by the shared reusable workflow attest-docker.yml called from each application's attest job.

Where the attestations live — and why the verification tool differs. They are produced by two different mechanisms, and this determines which command finds them:

  • Provenance and mirror metadata use the actions/attest* actions. They are stored in two places: GitHub's attestations database and GHCR as OCI referrers (push-to-registry: true). So gh attestation verify works against the GitHub API, and --bundle-from-oci reads the same bundle straight from the registry as an offline alternative.
  • Signature and SBOM use cosign, which writes only to GHCR, under its tag-based scheme (sha256-<digest>.sig and sha256-<digest>.att). They do not appear in the Attestations tab and are not found by gh attestation verify — use cosign verify / cosign verify-attestation instead.

The SBOM is attested with cosign rather than actions/attest because that action refuses payloads over 16 MiB, a cap ordinary images reach; trimming an SBOM to fit would drop exactly the dependency inventory it exists to carry. It is used for every SBOM, not as a size-triggered fallback, so the verification command never depends on how large the image happens to be.

The create-storage-record: false setting only skips GitHub's artifact metadata storage record (a linked-artifacts feature restricted to organization-owned repositories) — it does not affect attestation storage.

What each attestation contains

Signature (cosign)

Keyless signature proving the image digest was produced by this project's CI. No long-lived keys — identity comes from GitHub OIDC, certificates from Fulcio, transparency from Rekor.

SBOM

An SPDX Software Bill of Materials generated by Trivy: every package/dependency in the image with version information, for vulnerability scanning and supply-chain transparency.

Attached with cosign attest --type spdxjson, which records the predicate type as https://spdx.dev/Document. Verified with cosign verify-attestation, not gh attestation verify — see the note under Overview.

SLSA provenance

GitHub's standard SLSA v1 build provenance (buildType https://actions.github.io/buildtypes/workflow/v1), auto-generated from the Actions run context — the building workflow, repository, commit and run. Verifiable by slsa-verifier and gh attestation verify.

Mirror metadata (custom predicate)

Records which upstream artifact this image mirrors — information the auto-generated provenance has no field for:

json
{
  "mirror": {
    "registry": "ghcr.io",
    "namespace": "this-is-tobi/mirror",
    "image": "mattermost",
    "architectures": ["amd64", "arm64"],
    "multiArch": true,
    "buildMode": "multi-version"
  },
  "upstream": {
    "repository": "mattermost/mattermost",
    "source": "https://github.com/mattermost/mattermost",
    "version": "10.5.0",
    "ref": "v10.5.0"
  }
}

Identities

Signing and attesting run inside the shared reusable workflow, so the signing certificate identity (SAN) is that workflow, while the source repository is this repo:

  • Signer identity (SAN): https://github.com/this-is-tobi/github-workflows/.github/workflows/attest-docker.yml@refs/tags/v0
  • Source repository: https://github.com/this-is-tobi/multiarch-mirror
  • OIDC issuer: https://token.actions.githubusercontent.com

This is why verification scopes to --owner this-is-tobi (or --signer-repo this-is-tobi/github-workflows) rather than --repo this-is-tobi/multiarch-mirror alone.

Verifying images

Prerequisites

bash
brew install cosign gh
# or download from the cosign / GitHub CLI release pages

Pick an image to verify:

bash
IMAGE=ghcr.io/this-is-tobi/mirror/mattermost:latest

Signature

bash
cosign verify \
  --certificate-identity-regexp "^https://github.com/this-is-tobi/github-workflows/\.github/workflows/attest-docker\.yml@" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$IMAGE"

SLSA provenance

bash
gh attestation verify "oci://$IMAGE" --owner this-is-tobi
# default --predicate-type is https://slsa.dev/provenance/v1

SBOM

The SBOM is attested with cosign, so it is verified with cosign rather than gh attestation verify:

bash
cosign verify-attestation --type spdxjson \
  --certificate-identity-regexp "^https://github.com/this-is-tobi/github-workflows/\.github/workflows/attest-docker\.yml@" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$IMAGE"

Extract the package list (the command prints a DSSE envelope whose payload is the base64-encoded in-toto statement):

bash
cosign verify-attestation --type spdxjson \
  --certificate-identity-regexp "^https://github.com/this-is-tobi/github-workflows/\.github/workflows/attest-docker\.yml@" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$IMAGE" 2>/dev/null \
  | jq -r '.payload | @base64d | fromjson | .predicate.packages[] | "\(.name) \(.versionInfo // "-")"'

Mirror metadata

bash
gh attestation verify "oci://$IMAGE" \
  --owner this-is-tobi \
  --predicate-type "https://this-is-tobi.github.io/multiarch-mirror/mirror/v1" --format json \
  | jq '.[0].verificationResult.statement.predicate'

Offline / registry-only verification (optional)

The provenance and mirror bundles are also pushed to GHCR, so verifying them works without access to the GitHub API — useful in air-gapped environments or registry-only policy engines. Add --bundle-from-oci to either of the gh attestation verify commands above:

bash
gh attestation verify "oci://$IMAGE" \
  --owner this-is-tobi \
  --bundle-from-oci

The signature and SBOM commands need no equivalent flag: cosign already reads them from the registry, and only contacts Rekor to check the transparency log.

Stricter scoping (optional)

To assert both the source repository and the signing workflow explicitly:

bash
gh attestation verify "oci://$IMAGE" \
  --repo this-is-tobi/multiarch-mirror \
  --signer-repo this-is-tobi/github-workflows \
  --predicate-type "https://slsa.dev/provenance/v1"

Implementation

Attestations are produced by the reusable attest-docker.yml workflow, called from each application's attest job after the image manifest is pushed. For each image it:

  1. Signs the digest with cosign sign (keyless).
  2. Generates and attaches an SBOM — Trivy produces SPDX, attached via cosign attest --type spdxjson.
  3. Attaches standard SLSA build provenance via actions/attest-build-provenance. This step is deliberately not gated on the SBOM succeeding, so an SBOM problem can never be the reason an image ships without provenance.
  4. Attaches the mirror predicate (upstream source/version + architectures) via actions/attest with a custom predicate type.

Callers pass SIGN: true, SBOM: true, PROVENANCE: true, and the PREDICATE_TYPE/PREDICATE inputs carrying the mirror metadata.

Permissions

The attest job requires:

yaml
permissions:
  contents: read # read repository content
  packages: write # push image + attestations to GHCR
  id-token: write # OIDC token for keyless signing
  attestations: write # create attestations

Trust model

Verifying an image establishes that:

  1. It was built by the shared CI workflow (this-is-tobi/github-workflows/.github/workflows/attest-docker.yml) on behalf of this-is-tobi/multiarch-mirror.
  2. Its exact contents (SBOM) and build origin (SLSA provenance) are as attested.
  3. It mirrors the stated upstream release (mirror predicate).

All of this is anchored in GitHub OIDC (token.actions.githubusercontent.com), Fulcio (certificate authority) and Rekor (public transparency log) — with no long-lived secrets to manage.

Troubleshooting

cosign verify fails on identity

The signer is the shared reusable workflow, not this repository. Use the github-workflows/.github/workflows/attest-docker.yml@… identity shown under Identities, not a multiarch-mirror identity.

gh attestation verify finds no attestations

  1. Check you are not looking for the SBOM or the signature. Those are attested with cosign and never appear in gh attestation verify or the Attestations tab — use cosign verify-attestation --type spdxjson / cosign verify. gh attestation verify covers provenance and mirror metadata only.
  2. Match the exact --predicate-type (e.g. https://slsa.dev/provenance/v1).
  3. Scope with --owner this-is-tobi (or --signer-repo this-is-tobi/github-workflows), since the signer is the reusable workflow.
  4. If the GitHub API is unreachable (offline, air-gapped), add --bundle-from-oci to read the bundle directly from GHCR instead.

Attestation not found on an older image

Attestations bind to a specific image digest. Images built before this attestation system was introduced may lack some attestations; always verify by digest or a current tag.

References