flake.nix fetches the release tarballs and carries their SRI hashes in a generated block. The cut tool builds the release locally with the toolchain gates.yml pins, in a normalized nine-variable environment, writes the hashes into flake.nix, and commits it with build.zig.zon as the single bump commit. The package job verifies the pins on the bump commit and the publish job verifies them again on the tag, before anything is uploaded. The tarballs are written by dist_stage (std.tar.Writer, flate gzip) instead of the runner's tar and gzip, and -ffile-prefix-map keeps checkout paths out of the C objects; two checkouts at different absolute paths produce byte-identical archives. nxdns version, /api/version and the admin footer report the version only: the bump commit cannot know its own sha.
318 lines
19 KiB
Markdown
318 lines
19 KiB
Markdown
# 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.
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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
|
|
```
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
|
|
./nxdns-$VERSION-x86_64-linux-musl/nxdns version
|
|
```
|
|
|
|
`version` prints the version, then the Zig version the binary was built with. The version has to match the tag you downloaded. The binary carries no commit sha, so the check that ties a release to its source is the rebuild below rather than a string in this output.
|
|
|
|
> 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` 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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
docker pull "$(cat IMAGE-DIGEST.txt)"
|
|
```
|
|
|
|
The index should carry exactly two platforms and nothing else:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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 reproducible build — an independent build, run somewhere else, that lands on the same bytes. The release tarballs are reproducible: the binary carries no commit sha, the archive is written by the project's own tool with fixed modes, zero timestamps and sorted entries, and CI rebuilds the same bytes and compares them against the pinned hashes before it uploads anything. The rebuild below is how you check that for yourself.
|
|
|
|
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
|
|
|
|
Build the same version from source and compare the hashes. Match the toolchain first: `ZIG_VERSION`, `NODE_VERSION` and `NPM_VERSION` at the top of `.gitea/workflows/gates.yml` are the exact versions the release used, and a different patch release of any of them changes the bytes.
|
|
|
|
The host matters too: the admin bundle is built with host-native Rolldown and Lightning CSS bindings, which the lockfile ships per platform and libc, so the recipe reproduces the release only on x86_64 Linux with glibc, the runner CI uses. The two `npm_config_*` paths must not exist on your machine; the recipe relies on npm finding no config file there. The environment matters as much as the toolchain. The release cut and CI both build the bundle under exactly nine variables and a `022` umask, so the recipe below does the same: a locale, a time zone, a build timestamp or a file mode picked up from your shell each move the bytes. The Zig build gets a private `--cache-dir` so a stale local cache cannot leak into them; the global Zig cache stays shared, because it is content-addressed and a fresh one refetches every dependency (see `specs/release-cut.md`).
|
|
|
|
```sh
|
|
git clone https://git.mial.net/mokhtar/nxdns
|
|
cd nxdns
|
|
git checkout "v$VERSION"
|
|
git verify-tag "v$VERSION"
|
|
|
|
env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global \
|
|
sh -c 'cd admin && umask 022 && npm ci && npm run build'
|
|
|
|
env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global \
|
|
sh -c 'umask 022 && exec zig build dist \
|
|
-Dversion-string="'"$VERSION"'" \
|
|
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe \
|
|
--cache-dir "$(mktemp -d)"'
|
|
|
|
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.
|
|
|
|
The tarball is written by `zig build dist` itself rather than by the host's `tar` and `gzip`, so its layout is fixed and a rebuild on a matching toolchain reproduces it byte for byte. Entries are sorted by their full path as bytes, with the payload directory first. The payload directory and `nxdns` carry mode `0755`; every other file carries `0644`. Every entry has a zero modification time, uid 0, gid 0, and no user or group name. The gzip wrapper carries no original filename and a zero header timestamp, which is what makes two archives of the same tree compare equal.
|
|
|
|
Compare your two tarball hashes against two things: the published `SHA256SUMS.txt`, and the `hashes` block of `flake.nix` at the tag, which carries the same digests in SRI form. All three agree on a matching toolchain, and the release pipeline fails before it uploads anything if they do not.
|
|
|
|
A hash that differs is a signal to check the toolchain and the environment first. An unpinned Zig, Node or npm version is the ordinary explanation, and a build run outside the normalized environment above is the next one. Rule both out before you conclude anything about the release itself.
|
|
|
|
> Verified against `v0.0.1`, before the build was reproducible: the recipe ran from a fresh clone, `git verify-tag v0.0.1` printed `Good signature` under the release subkey, and the rebuilt tarball hashes did not match the published `SHA256SUMS.txt` (Node 24.14.1 against the pinned 24.19.0, a different build path, and an archive written by the host's `tar`). Releases from 0.0.17 on are built and checked by the pinned pipeline this page describes, and the cut records the local hashes in `flake.nix` before CI rebuilds them.
|
|
|
|
## 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 signature` — `SHA256SUMS.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.
|
|
|
|
## Related
|
|
|
|
- [Install with systemd](install-with-systemd.md) — where the verified tarball goes next.
|
|
- [Install with Docker](install-with-docker.md) — the published image.
|
|
- [Upgrade nxdns](upgrade.md) — the same verification, on the way to a newer version.
|
|
- [Performance targets and what the tests prove](../explanation/performance-and-testing.md) — the other place this project writes down what its checks do not cover.
|