Files
nxdns/docs/how-to/verify-a-release.md
mokhtar 5c89acf337
Gates / test (push) Successful in 1m19s
Gates / package (push) Successful in 5m10s
CI / gates (push) Successful in 14m28s
Gates / test-aarch64 (push) Successful in 4m55s
Gates / frontend (push) Successful in 42s
Gates / container (push) Successful in 2m20s
milestone 14: verify-a-release walkthrough run against v0.0.1, acceptance closed
2026-08-09 01:35:43 +02:00

18 KiB

Verify a release

Checks that a downloaded nxdns release is the one the project published and that it arrived intact. It also gives the recipe for rebuilding the same version from source, and says plainly what that does and does not settle.

Do this before you run the binary, not after. The whole point of the checksum file is that it is signed, so a tampered mirror cannot hand you a matching tarball and a matching checksum at the same time.

Verification: every command on this page was run on 2026-08-09 against the published v0.0.1 release, from a clean directory, with a clean GNUPGHOME holding only the key fetched from keys.openpgp.org. Every transcript below is that run's output. Where a block shows a failure — a BAD signature, a FAILED hash — the failure was produced deliberately by tampering with a copy of the real file, and the surrounding text says how.

What a release contains

Five assets, on the release page at https://git.mial.net/mokhtar/nxdns/releases:

Asset What it is
nxdns-<version>-x86_64-linux-musl.tar.gz The x86_64 tarball
nxdns-<version>-aarch64-linux-musl.tar.gz The aarch64 tarball, for a Raspberry Pi 5
SHA256SUMS.txt One sha256sum line each for the two tarballs and for IMAGE-DIGEST.txt
SHA256SUMS.txt.asc A detached OpenPGP signature over SHA256SUMS.txt
IMAGE-DIGEST.txt The container image reference this version pushed, pinned by digest

Each tarball holds one top-level directory, nxdns-<version>-<triple>/, with six files in it: the nxdns binary at mode 0755, and nxdns.service, nxdns.conf, LICENSE, THIRD-PARTY-NOTICES and INSTALL.md at 0644.

SHA256SUMS.txt covers IMAGE-DIGEST.txt rather than the image, because the image digest does not exist until the push has happened and cannot be computed by the build. Signing the file that names the digest gets you the same guarantee in one signature.

The .txt on three of the five names is not decoration. Gitea decides what an attachment may be by its file extension, and whether it accepts an extensionless upload at all is untested against this instance, so the release uses names it is known to accept. On disk, zig build dist still writes a file called SHA256SUMS; the release job copies it to SHA256SUMS.txt and appends the image line before signing.

1. Pick a version

Every URL below takes the version from one shell variable. Ask the server rather than typing a number that goes stale: Gitea redirects releases/latest to the tag page of the newest published release — newest by publication time, and drafts and pre-releases are excluded.

BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
  sed 's#.*/releases/tag/v##')
echo "$VERSION"

To take a particular version instead, set it yourself — substitute the one you want for the placeholder:

VERSION=<version>

Verified: the two-command form, run against this repository, printed 0.0.1 with v0.0.1 published.

Pin the version in anything you script or automate. latest is convenient for a person at a terminal and a liability in a machine that upgrades itself.

2. Download

mkdir -p ~/nxdns-release && cd ~/nxdns-release
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-x86_64-linux-musl.tar.gz"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
curl -fLO "$BASE/releases/download/v$VERSION/IMAGE-DIGEST.txt"

-L is not optional: Gitea answers an asset URL with a 303 to wherever the attachment is actually stored.

For the aarch64 tarball, or for both, swap or add the filename:

curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-aarch64-linux-musl.tar.gz"

Gitea also accepts the literal word latest in place of the tag, so the three assets whose names carry no version can be fetched without one:

curl -fLO "$BASE/releases/download/latest/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/latest/SHA256SUMS.txt.asc"
curl -fLO "$BASE/releases/download/latest/IMAGE-DIGEST.txt"

That is the Gitea spelling, and it is not GitHub's. releases/latest/download/ — the form GitHub uses — is a 404 on Gitea; the alias goes in the tag position, as releases/download/latest/. The tarball filenames contain the version, so this alias never saves you from knowing it for those two.

Verified against v0.0.1: all five assets downloaded through the versioned path, SHA256SUMS.txt downloaded again through the latest alias and hashed identical, and GitHub's spelling answered 404.

3. Check the signature over SHA256SUMS.txt

Get the public key first. It is a signing subkey of the key that signs every commit in this repository, so you can confirm the fingerprint against a clone you already have with git log --show-signature or git verify-tag v$VERSION:

A2061F6AB24DF2C0E92346FD1509B54946D08A95
curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346FD1509B54946D08A95 |
  gpg --import

Verified: the key is published, and that exact curl | gpg --import reported key 1509B54946D08A95: public key "Mokhtar Mial (pc) <mokhtar@mial.net>" imported into a clean GNUPGHOME.

Then verify:

gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
gpg:                using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
gpg: Good signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: A206 1F6A B24D F2C0 E923  46FD 1509 B549 46D0 8A95
     Subkey fingerprint: 019D 00DF 8417 EBFD A547  1E5E F731 9CC0 24FB 5A96

The structure is what to read: three lines, not one. using EDDSA key and Subkey fingerprint name the signing subkey that actually made the signature; Primary key fingerprint names the certificate it hangs off, and that is the one published above. The subkey fingerprint can change — a signing subkey is revoked and replaced on its own — but the primary fingerprint is the project's identity and stays.

Exit status 0, and Good signature. That warning is normal and is not a failure: it says you have not told GnuPG you believe the key belongs to the person it claims to.

Now compare the Primary key fingerprint line with the fingerprint in this page. GnuPG prints it as ten space-separated groups of four hex digits, with a double space in the middle, while the fingerprint above is the same 40 characters unspaced — so compare the hex digits in order and ignore the spacing, or strip it and let the shell do it:

gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt 2>&1 |
  sed -n 's/^Primary key fingerprint: //p' | tr -d ' '

That prints the 40-character form, ready to compare with A2061F6AB24DF2C0E92346FD1509B54946D08A95. Do not skip the comparison — gpg --verify exits 0 for a good signature from any key in your keyring, including one an attacker talked you into importing.

A tampered SHA256SUMS.txt looks like this, and exits 1:

gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
gpg:                using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
gpg: BAD signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]

Verified against v0.0.1, from a clean GNUPGHOME holding only the imported public key — which is why the [unknown] trust marker and the warning are there rather than being written in by hand. The good-signature transcript is the real release's; the BAD signature transcript is the same command against a copy of SHA256SUMS.txt with one newline appended, and it exited

  1. The sed/tr pipeline printed A2061F6AB24DF2C0E92346FD1509B54946D08A95, matching the fingerprint above.

4. Check the hashes

sha256sum -c --ignore-missing SHA256SUMS.txt
nxdns-<version>-x86_64-linux-musl.tar.gz: OK
IMAGE-DIGEST.txt: OK

--ignore-missing is what makes this work when you downloaded one tarball out of the two. Without it, sha256sum treats every line it cannot read as a failure and exits 1:

nxdns-<version>-x86_64-linux-musl.tar.gz: OK
sha256sum: nxdns-<version>-aarch64-linux-musl.tar.gz: No such file or directory
nxdns-<version>-aarch64-linux-musl.tar.gz: FAILED open or read
IMAGE-DIGEST.txt: OK
sha256sum: WARNING: 1 listed file could not be read

A file that is present but does not match is the case that matters, and it says FAILED with no open or read:

nxdns-<version>-x86_64-linux-musl.tar.gz: FAILED
IMAGE-DIGEST.txt: OK
sha256sum: WARNING: 1 computed checksum did NOT match

Check the signature before the hashes, not after. An attacker who can replace the tarball can replace SHA256SUMS.txt next to it; the signature is the only thing in the set they cannot forge.

Verified against v0.0.1: with both tarballs present, sha256sum -c printed three OK lines. The three transcripts above are the same command over copies of the real assets, with the aarch64 tarball absent for the first two and one byte appended to the x86_64 tarball for the third. Only the version in the filenames was replaced with <version>.

5. Look inside before extracting

tar -tvzf nxdns-$VERSION-x86_64-linux-musl.tar.gz

Expect exactly one top-level directory and the six files listed above, with mode -rwxr-xr-x on nxdns and -rw-r--r-- on the rest, no symlinks, and no path that begins with / or contains ... zig build verify-dist asserts all of that on the extracted archive before a release is ever published, so this is a second opinion rather than the only check — but it costs nothing and it is the step that catches a tarball that is not the one you think it is.

Then extract:

tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
./nxdns-$VERSION-x86_64-linux-musl/nxdns version

version prints the version and the git commit it was built from, then the Zig version. The version has to match the tag you downloaded, and the commit has to match the commit the tag points at.

Verified against v0.0.1: both tarballs listed exactly the one directory and six files with the stated modes, no symlinks and no absolute or .. paths, and the extracted binary printed nxdns 0.0.1 (3c2d0d41f04570038e805b759da4541e198eae17) — the commit v0.0.1 points at — then zig 0.16.0.

6. Verify the container image

IMAGE-DIGEST.txt holds one line: the image reference this version pushed, pinned by the digest of its index, in the form

git.mial.net/mokhtar/nxdns:<version>@sha256:<64 hex digits>

SHA256SUMS.txt covers IMAGE-DIGEST.txt, so the signature you already checked covers that line too. Confirm the tag in the registry still resolves to that digest:

cut -d@ -f2 IMAGE-DIGEST.txt
docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
  --format '{{.Manifest.Digest}}'

The two have to be the same string. A registry tag is mutable; the digest is not, so pull the whole pinned reference rather than the tag when you care:

docker pull "$(cat IMAGE-DIGEST.txt)"

The index should carry exactly two platforms and nothing else:

docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
  --format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}'

linux/amd64 linux/arm64. The build passes --provenance=false --sbom=false, so there are no unknown/unknown attestation entries in the list; seeing any means the image did not come from this pipeline.

The binary inside the image is the same file as the one in the matching tarball, and the release checks that before publishing. To check it yourself:

docker create --name nxdns-verify git.mial.net/mokhtar/nxdns:$VERSION
docker cp nxdns-verify:/nxdns ./nxdns-from-image
docker rm nxdns-verify
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns

Verified against v0.0.1: the digest in IMAGE-DIGEST.txt and the digest the :0.0.1 tag resolves to were the same string (sha256:f2945fbf6c1e16509f0e33e3d62da9a9cd7dc706718d333ce4edf95c80dbb00e, and :latest resolved to it too), the platform form printed exactly linux/amd64 linux/arm64 with no attestation entries, docker pull of the pinned reference succeeded, and the binary copied out of that pulled image hashed identical to the nxdns in the x86_64 tarball.

What the signature proves, and what it does not

It proves two things:

  • The release was produced by this project's release pipeline, using a key only that pipeline holds.
  • What you have on disk is byte for byte what that pipeline uploaded. A corrupted download, a modified mirror or a tampered proxy all break the check.

It does not prove that the binary in the tarball was built from the source in this repository. The machine that ran the build also held the signing key, so a compromise of that machine produces an artifact that is signed, verifies cleanly, and contains whatever the attacker put in it. The signature is a statement about origin and integrity in transit. It is not a statement about provenance from source.

Closing that gap needs a reproducibility gate — an independent build, run somewhere else, that lands on the same bytes — and this project does not have one. It is a recorded deferral, not an oversight: see specs/milestone-14.md ruling 12. Until it exists, nothing here claims the build is reproducible, because nobody has measured whether it is.

The signing key is a subkey rather than the primary key, which limits the damage of the case above: a leaked release subkey is revoked on its own and the identity, the commit signatures and everyone's existing trust in the key survive.

Rebuild it yourself

You can still build the same version from source and compare. That gets you a binary whose provenance you know, and the comparison is worth making — read the paragraph after the recipe before you draw a conclusion from it.

git clone https://git.mial.net/mokhtar/nxdns
cd nxdns
git checkout "v$VERSION"
git verify-tag "v$VERSION"
(cd web && npm ci && npm run build)
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \
  -Dweb-dist=web/dist -Doptimize=ReleaseSafe
sha256sum zig-out/dist/nxdns-"$VERSION"-*.tar.gz

git verify-tag is the check that the tag itself is signed by the key from step 3, and it is the one part of this section that stands on its own: it ties the source you just checked out to the same identity that signed the release.

zig build dist writes zig-out/dist/: the two tarballs, a staging directory per target under stage/, the stripped binaries under bin/<triple>/, and a SHA256SUMS covering the two tarballs. The published SHA256SUMS.txt is that file with a third line for IMAGE-DIGEST.txt appended by the release job, so the two tarball lines should match and the local file has no third line to compare.

Now the caveat, and it is the whole reason this section is last. A hash that differs does not mean the release was tampered with. Nothing in this project measures whether two builds of the same commit on two different machines produce the same bytes, and there are several ordinary reasons they would not: a different Zig patch release, a different Node version, a different path to the build directory, a different npm lockfile resolution. A hash that matches is real evidence. A hash that does not match tells you only that something about the two builds differed, and finding out what is on you.

If you want the comparison to mean as much as it can, match the toolchain the release used. The Zig version is the second line of nxdns version, and both it and the Node version are pinned to exact patch releases at the top of .gitea/workflows/gates.yml, which is the workflow the release runs.

Verified against v0.0.1, and the result is the caveat above in action. The whole recipe ran from a fresh clone: git verify-tag v0.0.1 printed Good signature under the same signing subkey as the release, and zig build dist produced both tarballs. The hashes did not match the published SHA256SUMS.txt — the binaries themselves already differ. The Zig version matched the pin exactly; the Node version did not (24.14.1 against the pinned 24.19.0) and the build path differed, two of the ordinary causes listed above. That is a measurement of what an unpinned rebuild gives you, not evidence of tampering: the signature, checksum and image checks earlier on this page all passed against the same release.

If a check fails

Stop and do not run the binary.

  • sha256sum says FAILED but the signature was good — you have a damaged or substituted download. Delete it and fetch it again over a different network before assuming anything worse.
  • gpg says BAD signatureSHA256SUMS.txt and SHA256SUMS.txt.asc do not belong together. Re-download both from the release page; a stale SHA256SUMS.txt.asc left over from a previous version is the boring explanation.
  • gpg says Can't check signature: No public key — you have not imported the key, or you imported a different one.
  • The fingerprint does not match the one in step 3 — that is the case to take seriously. Do not extract the tarball, and do not import more keys trying to make it pass.