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
423 lines
18 KiB
Markdown
423 lines
18 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 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:
|
|
|
|
```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 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.
|
|
|
|
```sh
|
|
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 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.
|