Verifying releases¶
Every release from v1.1.0 onward is signed and attested with Sigstore. Nothing here is required to use the plugin. It is here so you can establish that what you downloaded is what this repository's release workflow built, and not something that acquired the same filename along the way.
This page is the single copy of these instructions. Each GitHub Release links here instead of repeating them.
What is published¶
| artifact | what it is |
|---|---|
net-dhcp-plugin-vX.Y.Z-linux-amd64.tar.gz |
the plugin rootfs + config.json, linux/amd64 |
checksums.txt |
SHA-256 of every attached amd64 artifact, and of the plugin binary inside the tarball |
checksums.txt.sigstore.json |
the cosign bundle signing checksums.txt |
sbom.spdx.json |
SBOM, SPDX format |
sbom.cdx.json |
SBOM, CycloneDX format |
net-dhcp-plugin-vX.Y.Z-linux-arm64.tar.gz |
the same for linux/arm64 (v1.7.0 onward) |
checksums-arm64.txt, checksums-arm64.txt.sigstore.json |
the arm64 checksum manifest, in the same shape, and its cosign bundle |
sbom-arm64.spdx.json, sbom-arm64.cdx.json |
the arm64 SBOMs |
provenance.intoto.jsonl, provenance-arm64.intoto.jsonl |
SLSA build provenance for the tarball and the SBOMs of each architecture, as a Sigstore bundle (v2.2.1 onward) |
The plugin image itself lives at
ghcr.io/claymore666/docker-net-dhcp:vX.Y.Z, mirrored to Docker Hub
under two names, claymore666/net-dhcp and
claymore666/docker-net-dhcp. The second carries every release from
v2.0.0: v2.0.0 and v2.1.0 were copied by hand and the release workflow
publishes it from v2.1.1 onward. It is
cosign-signed on both registries, and carries SLSA build provenance on
GHCR only. The Docker Hub mirror is signed but not
provenance-attested, and GHCR and Docker Hub carry different digests.
The two Docker Hub names carry the same digest and the same signature,
so a cosign verify against either one is the same check. Verify
provenance against the ghcr.io reference.
The arm64 image is the same at :vX.Y.Z-arm64; every verification step
below applies to it with the -arm64 tag and the -arm64 artifact
names substituted, in a directory of its own. See One architecture per
directory.
One signature covers every attached file: checksums.txt is signed, and
the checksums inside it cover the artifacts, and the plugin binary
packed inside the tarball, under the path it has once extracted. So
verifying the manifest and then checking the files against it is enough,
and there is no per-file signature to chase.
Verifying the release artifacts¶
Replace VERSION with the release tag (for example v1.9.0). You need
cosign and, for the
provenance step, the GitHub CLI.
You need cosign v3 or newer. Check with
cosign version, and install withgo install github.com/sigstore/cosign/v3/cmd/cosign@latest.
checksums.txt.sigstore.jsonis a Sigstore bundle, which is the v3 format. Run the command below with cosign v2 and it fails like this:That message names the bundle, so it reads as though the release were signed wrong. It isn't: v2 simply cannot read the format. If you see it, upgrade cosign and run the command again.
# In a directory holding ONE architecture's assets: the tarballs both
# unpack a binary to the same path. See "One architecture per directory".
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github.com/claymore666/docker-net-dhcp/.github/workflows/release.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums.txt
tar -xzf net-dhcp-plugin-VERSION-linux-amd64.tar.gz
sha256sum --ignore-missing -c checksums.txt
checksums.txt covers four files: the plugin tarball, both SBOMs,
and the plugin binary from inside the tarball, listed as
rootfs/usr/sbin/net-dhcp. One signature over one manifest is what
attests all of them at once. That fourth entry is why the tar -xzf is
in the block above and not an afterthought: it is a path and never an
attached asset, and it exists only once you have unpacked the tarball
into the directory you run the check from.
--ignore-missing is not a weakening: without it, a reader who
downloaded only the tarball and did not unpack it gets
net-dhcp-plugin-vX.Y.Z-linux-amd64.tar.gz: OK
sha256sum: sbom.spdx.json: No such file or directory
sbom.spdx.json: FAILED open or read
sha256sum: sbom.cdx.json: No such file or directory
sbom.cdx.json: FAILED open or read
sha256sum: rootfs/usr/sbin/net-dhcp: No such file or directory
rootfs/usr/sbin/net-dhcp: FAILED open or read
sha256sum: WARNING: 3 listed files could not be read
and has every reason to conclude the release was tampered with. With it,
every file you actually have is verified and the rest are skipped. It
does not soften the check that matters: a file that IS present and does
not match still fails with FAILED and exit status 1.
What the flag costs you is the binary, and only the binary. The three
attached assets are either downloaded or obviously absent; the binary is
the one entry a reader can be missing without noticing, because skipping
it looks exactly like not having downloaded an SBOM. Unpack the tarball
first, as the block above does, and rootfs/usr/sbin/net-dhcp: OK
appears in the output. That line is the signed statement about the
binary you are going to run. For an exhaustive check, download the
tarball and both SBOMs, unpack the tarball, and drop the flag.
The identity regexp is the point of the exercise: it pins the signature
to this repository's release.yml, so a valid Sigstore signature made by
anything else fails.
One architecture per directory¶
The arm64 release ships its own checksums-arm64.txt and bundle.
checksums-arm64.txt covers four files in the same shape: the arm64
tarball, its two SBOMs, and rootfs/usr/sbin/net-dhcp from inside that
tarball. Verify it exactly the same way, unpacking included, in a
directory that holds the arm64 assets and nothing else.
The attached files are named apart; the packed binary is not. A plugin
tarball has the same layout whichever architecture it was built for, so
checksums.txt and checksums-arm64.txt both record
rootfs/usr/sbin/net-dhcp, the same name, for two different files.
Unpack both tarballs in one directory and the second extraction
overwrites the first architecture's binary. Checking the first manifest
there then prints
on a release that is perfectly good, and --ignore-missing does not
soften that one: the file is present, it is simply the other
architecture's. FAILED is the line the worked example above tells you
to read as tampering, so this is worth avoiding instead of explaining
away. Two directories, one architecture each, and both manifests verify
four files.
Verifying the image¶
Also cosign v3 or newer.
cosign verify ghcr.io/claymore666/docker-net-dhcp:VERSION \
--certificate-identity-regexp '^https://github.com/claymore666/docker-net-dhcp/.github/workflows/release.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
If you installed from Docker Hub, put the reference you installed from
in place of the ghcr.io one: claymore666/net-dhcp:VERSION or
claymore666/docker-net-dhcp:VERSION. Those two resolve to one digest
and one signature, so verifying either verifies the image you have.
Verifying build provenance¶
SLSA provenance covers both the image and the release artifacts. The
image half is GHCR only: no provenance attestation exists for the
Docker Hub bytes, in any store, so gh attestation verify against a
docker.io reference finds nothing. Use the ghcr.io reference below
even if you installed from Docker Hub.
gh attestation verify oci://ghcr.io/claymore666/docker-net-dhcp:VERSION \
--repo claymore666/docker-net-dhcp
gh attestation verify net-dhcp-plugin-VERSION-linux-amd64.tar.gz \
--repo claymore666/docker-net-dhcp
Both commands ask GitHub's attestation store for the provenance. From
v2.2.1 the same attestation is attached to the release page as
provenance.intoto.jsonl (provenance-arm64.intoto.jsonl for arm64), so
the provenance travels with the files you downloaded and is read from
disk:
gh attestation verify net-dhcp-plugin-VERSION-linux-amd64.tar.gz \
--bundle provenance.intoto.jsonl \
--repo claymore666/docker-net-dhcp
One bundle covers the tarball and both SBOMs of that architecture, so the
same file verifies sbom.spdx.json and sbom.cdx.json as well. It is not
listed in checksums.txt: the attestation is produced after that manifest
is signed, and it carries its own certificate chain and transparency-log
entry, so it authenticates itself.
--bundle needs no GitHub credentials and makes no call to the
attestation store. It is not an offline command: gh still refreshes
Sigstore's trusted root over the network, from a cache a machine that has
verified before already holds. On a host with no egress and no such
cache, the command fails on that refresh and not on your artifact.
Rebuilding the binaries yourself¶
A signature tells you who built an artifact. Rebuilding tells you what they built. The build here is reproducible: the same commit, built for the same tag, produces byte-identical binaries on any machine, so you can check that a published release contains the code in this repository, without trusting the release workflow at all.
Since 2.0 the binary carries its own build identity (version, commit
and the DHCP library revision, all three readable from /Plugin.Health
and from the net_dhcp_build_info metric). version and commit reach
it as build arguments, so the rebuild has to pass the same two values,
and step 2 below does. The library revision is read out of the source
tree and needs no argument.
You need Docker with buildx, and the release tarball you already
downloaded above.
# 1. The source, exactly as tagged.
git clone https://github.com/claymore666/docker-net-dhcp
cd docker-net-dhcp
git checkout VERSION
# 2. Build on a builder with no caches. --no-cache alone is not enough:
# the Dockerfile mounts BuildKit caches for Go's module and build
# caches, and those survive it. A fresh docker-container builder has
# neither.
#
# VERSION is the tag you checked out; COMMIT is the FULL revision of
# it, never the abbreviated one. Git shortens to a length that
# depends on the size of your clone, and a different string is a
# different binary.
docker buildx create --name repro --driver docker-container
docker buildx build --builder repro --no-cache --target builder \
--build-arg VERSION=VERSION \
--build-arg COMMIT="$(git rev-parse HEAD)" \
--output type=local,dest=out .
docker buildx rm repro
# 3. Your binary.
sha256sum out/usr/local/src/docker-net-dhcp/bin/net-dhcp
# 4. The published one, from the release tarball.
tar -xzf net-dhcp-plugin-VERSION-linux-amd64.tar.gz
sha256sum rootfs/usr/sbin/net-dhcp
One binary is the 2.0 shape; a 1.x tarball carries two. The second
was dhcp-handler, the helper the external DHCP client execed for each
lease event. 2.0 leases in-process and builds net-dhcp alone.
Verifying a 1.x release means adding
out/usr/local/src/docker-net-dhcp/bin/dhcp-handler to step 3 and
rootfs/usr/lib/net-dhcp/dhcp-handler to step 4, and comparing both
pairs. Everything else in the recipe is the same for either.
The digests must match: one pair for a 2.0 release, two for a 1.x one. That is the whole check: you built it, we built it, the bytes are the same.
Where the published digest comes from¶
This repository does not carry a list of per-release digests, and it cannot. From 2.0 the release tag and the commit are compiled into the binary, so a commit that recorded the digest of a binary built from itself would change that binary by being made. Through 1.x such a list lived in this section; it was removed with 2.0 instead of being left to describe releases it can no longer describe.
Instead the digest is produced by the build that makes it true and
signed with everything else. checksums.txt (checksums-arm64.txt for
arm64) covers the tarball, both SBOMs, and the binary inside the
tarball, recorded under the path it has once extracted, the fourth
entry described above. So step 4 has a signed reference without needing
one from a commit:
# In the directory holding the downloaded assets:
cosign verify-blob checksums.txt \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github.com/claymore666/docker-net-dhcp/.github/workflows/release.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
tar -xzf net-dhcp-plugin-VERSION-linux-amd64.tar.gz
sha256sum --ignore-missing -c checksums.txt
--ignore-missing is there so the manifest still checks whichever
assets you downloaded; drop it once you have all of them and have
unpacked the tarball, and it becomes an exhaustive check. Without the
tar -xzf the binary's entry has no file to read, so dropping the flag
turns a good release into an exit 1 and keeping it skips the entry in
silence. rootfs/usr/sbin/net-dhcp: OK is the line that says the binary
inside the tarball is the one this release signed, and it is the same
number your rebuild in step 3 produces.
Since v1.7.0 each release also ships arm64 binaries under the -arm64
tags; rebuild them the same way on an arm64 host (the build follows the
host architecture), unpack net-dhcp-plugin-VERSION-linux-arm64.tar.gz
in step 4, and check against checksums-arm64.txt, whose fourth entry
is the arm64 binary at the same path. That is why that unpacking, and
this whole block, belong in a directory holding only the arm64 assets.
See One architecture per directory.
What the determinism rests on¶
None of this is accidental, and it is worth knowing what to suspect if a rebuild ever fails to match:
- base images pinned by digest and never by tag, both the Go builder and the Alpine runtime;
- Alpine packages pinned to exact versions;
- a fixed build path inside the container, so no host path is embedded;
- no timestamp and no build host stamped into the binaries. The version
and the commit are stamped in (
-ldflags -X, since 2.0), which is why step 2 passes them: identical inputs include those two. Pass a differentCOMMITand you get a different, equally deterministic binary. That is a correct result and never a reproducibility failure, and it is the reason the published digests live in the signed manifest and not in a commit; - Go's own compiler output, which is deterministic given identical inputs.
A run of the Reproducible build workflow builds the same commit twice
on two cold builders and fails if the binaries differ, so this is
checked and not merely asserted. It runs weekly, on demand, and on any
pull request touching the Dockerfile, the Go module files, or the
workflow and gate script that implement the check.
A note on :latest¶
:latest exists and tracks the newest release. It is a retag, sharing
one digest and therefore one signature with the vX.Y.Z tag it points
at. It is still worth pinning a version in anything you keep: a Docker
network remembers the exact driver string it was created with, so a
network created against :latest breaks the moment latest moves.