Handbook · Chapter 8 of 12 · 14 min read
Supply chain: cosign signing, host verification, and pinning
A bootc distro is a pipeline that turns a Git push into a root filesystem on someone's laptop. Every hop in that pipeline — GitHub Actions runners, third-party actions, the base image, the registry, the pull on the client — is an injection point. This chapter covers how Margine signs what it publishes, how a host verifies what it pulls, and how the CI itself is hardened against tampered dependencies.
The split of responsibilities:
| Layer | Mechanism | Where |
|---|---|---|
| Image authenticity | cosign keypair, signed by digest | sign job in build.yml |
| Pull-time verification | containers-policy + sigstore attachments | /etc/containers/policy.json + registries.d on the host |
| Boot chain | MOK-signed kernel + modules (chapter on Secure Boot) | build-time sbsign/sign-file |
| CI integrity | SHA-pinned actions, ephemeral secrets | every workflow |
| Inventory | SPDX SBOM as OCI 1.1 referrer, itself signed | build_push + sign jobs |
8.1 The cosign keypair
Margine uses key-based cosign signing: an ECDSA P-256 keypair generated once with cosign generate-key-pair. Private material never enters Git:
# margine-image/.gitignore
secrets/MOK.key
secrets/cosign.key
secrets/*.pem
The public half is committed (secrets/cosign.pub) and is the only thing a consumer needs:
# margine-image/secrets/cosign.pub
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqOUib+6SVxWdP5wKCEBkJZEZTmza
rwTaC+nUx1VQmoRmEl9ZwNH4fL46VHhTHfpQukTinXKSkaDWafXupCRygw==
-----END PUBLIC KEY-----
The private key lives in two places: a GitHub Actions repository secret (COSIGN_PRIVATE_KEY) and an offline local backup. Same dual-custody model as the MOK key. The 2026-06-05 stack audit explicitly weighed this against keyless OIDC and chose to stay key-based for now (see §8.7 for the trade-off table).
8.2 Sign by digest, in a separate CI job
build.yml is split into build_push → sign → notify. The header comment in the workflow states the rationale:
# margine-image/.github/workflows/build.yml (header comment)
# build_push does the heavy work (buildah + rechunk + skopeo push,
# ~25 min). sign is a separate cheap job (~1 min) that signs the
# pushed manifest *by digest* instead of by tag — cosign warns
# against by-tag signing as it's racy.
#
# On a failed sign step, `gh run rerun --failed <run-id>` re-runs
# only the sign job (~1 min) instead of redoing the whole build.
Two design decisions here, both worth copying:
1. Capture the digest at push time. skopeo copy --digestfile records the manifest digest of exactly what was uploaded; the job exports it as an output for the sign job:
# margine-image/.github/workflows/build.yml — "Push rechunked image to GHCR"
for tag in ${{ steps.metadata.outputs.tags }}; do
sudo skopeo copy --retry-times 3 \
--dest-creds="${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" \
--digestfile=/tmp/digest.txt \
"${{ steps.rechunk.outputs.ref }}" \
"docker://${IMG_FULL}:${tag}"
if [[ -z "$DIGEST" ]]; then
DIGEST="$(cat /tmp/digest.txt)"
fi
done
...
echo "image_ref=${IMG_FULL}@${DIGEST}" >> "$GITHUB_OUTPUT"
All tags point at the same manifest, so one digest covers them all. Signing image@sha256:... instead of image:tag eliminates the TOCTOU window where someone retags between push and sign.
2. Sign in a minimal job with only the digest as input:
# margine-image/.github/workflows/build.yml — sign job
- name: Install Cosign
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
- name: Sign image by digest
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ""
run: |
set -euo pipefail
IMAGE_REF="${{ needs.build_push.outputs.image_ref }}"
...
cosign sign -y --key env://COSIGN_PRIVATE_KEY "${IMAGE_REF}"
env://COSIGN_PRIVATE_KEY keeps the key out of the filesystem and out of argv (visible in /proc). COSIGN_PASSWORD: "" declares the key is unencrypted — acceptable because the only at-rest copy is inside GitHub's secret store; encrypting it would just move the secret to a second variable. cosign sign pushes the signature as an OCI artifact to the same repository (the sha256-<digest>.sig tag), so no separate signature distribution channel is needed.
Signatures survive promotion because promotion preserves digests
Margine's :candidate → :stable promotion (after the QEMU smoke boot, chapter 7) is a skopeo copy --preserve-digests of the exact digest the gate booted — the manifest digest does not change, so the signature made against :candidate's digest is automatically valid for :stable:
# margine-image/.github/workflows/smoke-boot.yml — promote step
# ${PINNED} = ${BASE}@sha256:... resolved once (the digest just booted)
for promo_tag in stable "stable.${DATE_TAG}" "${DATE_TAG}"; do
sudo skopeo copy --retry-times 3 --preserve-digests \
"docker://${PINNED}" \
"docker://${REGISTRY_IMAGE}:${promo_tag}"
done
Sign-by-digest plus copy-by-digest means the chapter-7 promotion gate adds zero signing work: the bytes that were smoke-booted are the bytes that were signed are the bytes users pull. (Promoting ${PINNED} rather than re-resolving :candidate also closed a void-gate bug — see chapter 9 §9.5.)
8.3 SBOM as a signed OCI referrer
The image's package inventory ships as an SPDX SBOM attached to the manifest (OCI 1.1 referrer) and signed with the same key:
# margine-image/.github/workflows/build.yml — "Attach + cosign-sign SBOM"
ATTACH_JSON="$(oras attach \
--artifact-type application/vnd.spdx+json \
...
--format json \
"${IMAGE_REF}" \
sbom.spdx.json:application/spdx+json)"
ATTACH_DIGEST="$(jq -r '.reference | split("@")[1] // .digest // empty' <<<"$ATTACH_JSON")"
...
SBOM_REF="${IMG_BASE}@${ATTACH_DIGEST}"
cosign sign -y --key env://COSIGN_PRIVATE_KEY "${SBOM_REF}"
Consumers do oras discover → oras pull → cosign verify against the same cosign.pub. The SBOM is generated in build_push (not in sign) for a reason that cost six PRs to learn:
Lesson: syft OOM on large rechunked images Symptom: the
signjob was killed by a runner shutdown signal ~11-14 min intosyft, across PRs #49, #52 (timeout bump), #53 (free 30 GB disk), #58 (--scope squashed), #60 (syntax fix) — seemargine-image/docs/sbom-revisit-plan.mdfor the full table. Root cause:syfton a registry image reference always pulls every layer;--scope squashedchanges the SBOM representation, not the input. A 14 GB rechunked image's expanded in-memory layer tree exceeds the 16 GB RAM of a stockubuntu-24.04runner. Freeing disk didn't help because the bottleneck was RAM. Fix: generate the SBOM insidebuild_pushbefore rechunk, from a flat filesystem export instead of the layer model — peak RAM ~1 GB:# margine-image/.github/workflows/build.yml — SBOM step sudo podman container create --replace --name sbom-export \ --entrypoint /bin/true \ "localhost/${{ env.IMAGE_NAME }}:${{ steps.metadata.outputs.version }}" sudo podman export sbom-export | sudo tar -C "$ROOTFS" -xf - sudo "$(which syft)" --source-name "..." "$ROOTFS" -o spdx-json=sbom.spdx.jsonThe SBOM file is handed to the
signjob as a 1-day workflow artifact. Pre-rechunk is fine: rechunk repacks layer boundaries, it does not change the package set.
Lesson:
oras attachdigest extraction Symptom: build #27065187939 (2026-06-06) failed withSigning SBOM: ghcr.io/.../margine@<no value>. Root cause:oras2.x--format go-template='{{.Digest}}'resolves to<no value>— the JSON key is lowercasedigestand the documented template path doesn't match. Fix (PR #73): use--format jsonand parse withjq -r '.reference | split("@")[1] // .digest // empty', then hard-fail if empty (see snippet above).
8.4 Host-side verification: policy.json + registries.d
Signing is worthless unless the client checks. Container verification on a Fedora/bootc host is configured by two files, both consulted by everything that pulls through containers/image (podman, skopeo, bootc, rpm-ostree's container backend):
/etc/containers/policy.json — maps registry scopes to requirements. To require Margine's cosign signature:
{
"transports": {
"docker": {
"ghcr.io/daniel-g-carrasco/margine": [
{
"type": "sigstoreSigned",
"keyPath": "/etc/pki/containers/margine.pub",
"signedIdentity": { "type": "matchRepository" }
}
]
}
}
}
sigstoreSigned means "a cosign-style signature verifiable with this key must exist for the pulled digest". matchRepository accepts any tag in the repo (necessary, because the signature is made against :candidate's digest but pulled via :stable).
/etc/containers/registries.d/margine.yaml — tells the stack where signatures live. Cosign stores them as OCI artifacts in the same repository, which must be opted into:
docker:
ghcr.io/daniel-g-carrasco/margine:
use-sigstore-attachments: true
Without this, verification looks for a lookaside (web-server) sigstore and fails with "no signature found" even though the signature exists on GHCR.
Because Margine derives from Bluefin DX, the base image already ships a populated /etc/containers/policy.json and /etc/pki/containers/ for the ghcr.io/ublue-os scope; the Margine-specific scope and key are the distro's job to add at image build time, so every installed host verifies its own updates. The 2026-06-05 audit flags this as the load-bearing check (§6.5):
Verify
/etc/containers/policy.jsonallows your registry path withcosignverification, not justinsecureAcceptAnything. This is what makesbootc switch --enforce-container-sigpolicy ghcr.io/daniel-g-carrasco/margine:stableactually verify, not just succeed. —margine-fedora-atomic/docs/audits/2026-06-05-margine-stack-audit.md
The same audit section cites the cautionary upstream incident: ublue-os/bluefin#4197 (2026-02-12), where bluefin-dx:stable shipped without /etc/pki/containers/ublue-os.pub, breaking bootc upgrade for every downstream consumer enforcing signature policy. Policy enforcement cuts both ways — if the key file is missing from the image, verified updates brick themselves. Margine's end-to-end check of this path on a booted install is tracked as deferred in the audit status delta (2026-06-05-margine-stack-audit-status-delta.md: "Verify /etc/pki/containers/<key>.pub + policy.json (§6.5) … ⏸ Deferred — needs a running install"), and margine-fedora-atomic/docs/roadmap.md keeps the honest TODO:
- ⏳ Move the `:stable` redirect to a *signed cosign verification* on
the user side (today `bootc` trusts the registry; we could
configure rpm-ostree's `verify-by-key` to enforce cosign at the
client). Defense in depth.
The ostree-image-signed: transport
The documented rebase command selects the policy-enforcing transport explicitly:
# margine-image/README.md
rpm-ostree rebase ostree-image-signed:docker://ghcr.io/daniel-g-carrasco/margine:stable
rpm-ostree's container transports encode the trust decision in the ref itself:
| Transport | Behavior |
|---|---|
ostree-unverified-registry: / ostree-unverified-image: |
Pull, no signature check (TLS only) |
ostree-image-signed:docker://... |
Pull fails if the policy for that scope resolves to insecureAcceptAnything — i.e. it requires that a real verification policy exists and passes |
ostree-remote-image:<remote>:... |
Verify GPG against an ostree remote config (legacy commit-signing path) |
Putting ostree-image-signed: in the user-facing docs means the deployment origin file records the signed transport, and every subsequent rpm-ostree upgrade/bootc upgrade on that origin re-verifies. bootc switch --enforce-container-sigpolicy is the bootc-native equivalent. Per the SBOM revisit plan: "Consumer verification flow (bootc switch --enforce-container-sigpolicy) works on cosign-by-digest alone" — the SBOM is hygiene, the image signature is the actual trust gate.
8.5 SHA-pinning actions and base images
Every third-party action in Margine's workflows is pinned to a full 40-character commit SHA, with the human-readable version kept as a comment so Renovate can bump both in lockstep (Margine retired dependabot.yml for a renovate.json5):
# margine-image/.github/workflows/build.yml
- name: Checkout
# SHA-pinned for supply-chain safety. Comment is the human-readable
# version the bump bot (Renovate) uses to bump both fields in lockstep.
# See the tj-actions/changed-files incident (2025-03) for why @vN alone
# is unsafe.
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
A @v6 tag is a mutable pointer in someone else's repo; the tj-actions/changed-files compromise (March 2025) retroactively poisoned the floating tags of an action used by ~23k repos, exfiltrating CI secrets. A SHA cannot be moved. The same pattern covers docker/metadata-action, anchore/sbom-action, actions/upload-artifact/download-artifact, oras-project/setup-oras, ublue-os/remove-unwanted-software, osbuild/bootc-image-builder-action, and daniel-g-carrasco/titanoboa (a personal fork carrying the margine patch set — see the ISO chapter — SHA-pinned via the TITANOBOA_REF env in build-disk.yml) in the disk/ISO workflows.
Lesson: a floating action tag is also a floating tool version (CVE-2026-39395) Symptom: audit §6.2 flagged
sigstore/cosign-installer@v3(floating) in both build workflows as CRITICAL. Root cause:@v3doesn't pin which cosign binary gets installed. CVE-2026-39395 / GHSA-w6c6-c85g-mmv6 (April 2026):cosign verify-blob-attestationreturns false positives on malformed payloads; patched in cosign v3.0.6. A floating installer tag gives no guarantee of>= v3.0.6. Fix (margine-image #41):uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1v3.10.1 of the installer pulls cosign v3.0.6. Pinning the action SHA pins the toolchain version transitively.
One deliberate exception, worth stating because pinning is a policy, not a reflex (a former second one — hhd-dev/rechunk, once tag-pinned at @v1.2.4 — is now closed: it is SHA-pinned hhd-dev/rechunk@5fbe1d3a639615d2548d83bc888360de6267b1a2 # v1.2.4 like every other action):
FROM ghcr.io/ublue-os/bluefin-dx:stablein the Containerfile floats on purpose. Margine wants upstream drift: the weekly cron (schedule: '0 4 * * 0'inbuild.yml) rebuilds against whatever Bluefin DX currently is, and the QEMU smoke gate (chapter 7) catches breakage before:stablemoves. Digest-pinning the base would trade silent drift for a Renovate-style bump treadmill; the gate makes the float survivable. If you have no boot gate, pin the FROM digest.
A third, subtler pinning move: the spec repo (margine-fedora-atomic) is fetched at build time, and to keep that fetch reproducible build.yml resolves the ref to a commit SHA at build start, passes it into the build as --build-arg MARGINE_REF=<sha>, and also stamps it as an OCI label:
# margine-image/.github/workflows/build.yml — specref step + build-arg + label
SHA="$(gh api repos/${{ github.repository_owner }}/margine-fedora-atomic/commits/${REF} --jq .sha)"
...
buildah build --build-arg "MARGINE_REF=${{ steps.specref.outputs.sha }}" ...
place.the-empty.margine.spec-ref=${{ steps.specref.outputs.sha }}
The Containerfile declares ARG MARGINE_REF=main, which the fetch scripts consume — so the image pulls scripts/branding/declarations from the exact pinned SHA, not from a moving main that could change between the SHA resolution and the fetch. (This closed an earlier TOCTOU caveat: the ref was recorded as a SHA in the label but the fetch still followed main.) Any published image can be traced back to — and is byte-reproducible from — the exact spec-repo commit that produced it.
Linting the pipeline itself, and automating the bumps
Pinning is only half a policy; the other half is keeping the pins fresh and the glue scripts honest. Two pieces close that loop:
lint.ymlin each repo runsactionlint(workflow schema + shellcheck over everyrun:block), a shebang-awareshellcheckpass (tracked*.shplus the extensionlesssystem_filespayloads discovered by their#!line — the GUI probe and the seed scripts would otherwise be invisible to shellcheck), andruffover the Python build helpers. This is why the inline heredocs got extracted into real files: a script shellcheck can't see is a script nobody is checking.- Renovate replaced Dependabot:
dependabot.ymlwas retired for arenovate.json5that bumps the SHA pins (and their version comments) in lockstep, including the# Renovate disabledcarve-out for the personal Titanoboa fork that must not be auto-bumped.
8.6 Secrets handling in GHA
Margine's CI holds three secrets: MOK_KEY, MOK_CERT (kernel signing) and COSIGN_PRIVATE_KEY. (The mokutil enrollment passphrase is not a secret — it's a hardcoded constant MOK_PASSWORD="margine-os" in custom-kernel/install.sh, public by design; §4.6.) Handling rules visible in the workflow:
# margine-image/.github/workflows/build.yml
- name: Stage MOK secrets for BuildKit
env:
MOK_KEY: ${{ secrets.MOK_KEY }}
MOK_CERT: ${{ secrets.MOK_CERT }}
run: |
mkdir -p /tmp/margine-secrets
chmod 700 /tmp/margine-secrets
printf '%s' "$MOK_KEY" > /tmp/margine-secrets/MOK.key
...
chmod 600 /tmp/margine-secrets/*
...
- name: Wipe staged secrets
if: always()
run: rm -rf /tmp/margine-secrets
Secrets are passed via env: (never string-interpolated into run: script bodies, which would land them in the rendered script), staged with restrictive modes, and wiped in an if: always() step so a failed build doesn't leave key material on a runner that might persist for later steps. Inside the build they enter only as BuildKit secret mounts, which exist for the duration of one RUN and never become a layer:
# margine-image/Containerfile
RUN --mount=type=bind,from=ctx,source=/,target=/ctx \
...
--mount=type=secret,id=mok-key,target=/tmp/certs/MOK.key \
--mount=type=secret,id=mok-cert,target=/tmp/certs/MOK.pem \
/ctx/custom-kernel/install.sh
A COPY MOK.key + rm would leave the key recoverable in the layer history; a secret mount cannot.
Token scoping follows least privilege per job: GITHUB_TOKEN permissions are declared explicitly (contents: read, packages: write, id-token: write) instead of inheriting the repo default, and the cosign job authenticates with an explicit cosign login ghcr.io because cosign sign reads ~/.docker/config.json, which a fresh job hasn't populated. The notify job receives only job results, never secrets beyond the ntfy URL — and degrades to a no-op if that secret is absent.
8.7 Alternatives & other distros
Signing schemes:
- Key-based cosign (Margine, Bazzite, most ublue community images, the ublue image-template historically): one keypair,
cosign.pubcommitted in-repo and baked into/etc/pki/containers/. Pros/cons per Margine's audit: "Works in air-gapped CI; signature verifiable without sigstore trust root" vs "Key rotation is a maintenance task; private key in repo secrets." - Keyless sigstore — Fulcio + Rekor (Universal Blue's direction for first-party images):
id-token: write→cosign sign $IMAGEwith no--key; a short-lived cert from Fulcio binds the signature to the GHA workflow's OIDC identity, logged in Rekor. No key to leak or rotate, provenance is the workflow identity itself; but verification needs the sigstore trust root and an identity-matching policy (--certificate-identity-regexp), andpolicy.jsonsupport usesfulcio/rekorPublicKeystanzas — more moving parts on every client. Margine's audit verdict: migration is "a future improvement, not a fix." - GPG-signed ostree commits (stock Fedora Silverblue/Kinoite ostree remotes): the classic pre-OCI model — the compose server signs the ostree commit; clients verify via
gpg-verify=true+ keyring in the remote config (ostree-remote-imagetransport bridges this to containers). Solid, but ties you to ostree remotes rather than plain registries, and signs commits, not OCI manifests — useless forpodman pullconsumers. - Notation / Notary v2 (CNCF, Azure ecosystem): signs OCI manifests with X.509 chains. Fine for cluster admission controllers; effectively unsupported in
containers-policy.json, so wrong tool for a bootc host. - Sealed bootable images (systemd-boot + UKI + composefs fs-verity): moves integrity from pull time to every boot — Margine tracks this as ADR 0007 (
margine-fedora-atomic/docs/adr/0007-sealed-bootable-images-tracker.md, status Watching). Complementary, not alternative: cosign authenticates the download, fs-verity would authenticate the running tree.
Other distros' supply chains, for calibration:
- Bluefin / Aurora / Bazzite (Universal Blue): same shape as Margine (which copied it): cosign sign in GHA, key in
/etc/pki/containers/ublue-os.pub, policy.json scoped toghcr.io/ublue-os— and the #4197 incident shows the failure mode when the key file goes missing from the image. - Fedora Silverblue (registry path): Fedora's official bootc images are signed with Fedora's infrastructure (sigstore keys shipped in
fedora-repos); the legacy ostree remote path uses Fedora's GPG key. - openSUSE MicroOS / Aeon: no OCI signing — trust is RPM GPG signatures + signed repo metadata, applied through
transactional-updatesnapshots. Verification granularity is per-package, not per-image. - Vanilla OS (ABRoot v2): OCI-image-based A/B transactions; trust rests primarily on registry TLS + their build pipeline, no end-user signature policy comparable to containers-policy enforcement.
- NixOS: no image to sign — closures are verified via Ed25519 signatures on binary-cache narinfo (
cache.nixos.org-1:...trusted-public-keys), and full source reproducibility is the fallback. Strongest story on paper, completely different mechanism. - ChimeraOS:
frzrdeploys squashfs images from GitHub releases; integrity is HTTPS + release checksums, no client-side signature policy.
CI pinning alternatives: Renovate/Dependabot with pinDigests (automates the SHA+comment dance Margine does manually), Chainguard's frizbee/StepSecurity to mass-pin existing workflows, or GitHub's allowed-actions policy as an org-level backstop. For the base image, digest-pinned FROM + automated bump PRs (common in Renovate-managed ublue forks) trades Margine's "float + boot gate" for explicit review of every upstream change.
8.8 What this buys, and what it doesn't
End state: a Margine host that pulled via ostree-image-signed: with the margine key in /etc/pki/containers/ will refuse an update whose manifest wasn't signed by the Margine key — a compromised GHCR token alone can push a tag but cannot mint a valid signature. What it does not cover: a compromised GHA runner during the build (it holds the cosign key via secrets), a malicious upstream bluefin-dx:stable (floated by design, gated only behaviorally by the smoke boot), and the deferred §6.5 end-to-end verification on a booted install. Supply-chain work is a ratchet; the audit documents each remaining click.