docs: unwrap hand-wrapped prose repo-wide
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s

This commit is contained in:
2026-08-15 16:27:36 +02:00
parent 50b8fd5c61
commit 5b3d1cd65c
48 changed files with 2691 additions and 11699 deletions
+44 -149
View File
@@ -1,12 +1,8 @@
# 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.
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.
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`
@@ -17,8 +13,7 @@ tarball and a matching checksum at the same time.
## What a release contains
Five assets, on the release page at
`https://git.mial.net/mokhtar/nxdns/releases`:
Five assets, on the release page at `https://git.mial.net/mokhtar/nxdns/releases`:
| Asset | What it is |
| --- | --- |
@@ -28,28 +23,15 @@ Five assets, on the release page at
| `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.
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.
`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.
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.
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
@@ -58,8 +40,7 @@ VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
echo "$VERSION"
```
To take a particular version instead, set it yourself — substitute the one you
want for the placeholder:
To take a particular version instead, set it yourself — substitute the one you want for the placeholder:
```sh
VERSION=<version>
@@ -68,8 +49,7 @@ 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.
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
@@ -81,8 +61,7 @@ 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.
`-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:
@@ -90,8 +69,7 @@ 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:
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"
@@ -99,10 +77,7 @@ 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.
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
@@ -110,9 +85,7 @@ version, so this alias never saves you from knowing it for those two.
## 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`:
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
@@ -143,32 +116,18 @@ 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.
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.
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:
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.
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:
@@ -197,9 +156,7 @@ 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:
`--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
@@ -209,8 +166,7 @@ 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`:
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
@@ -218,9 +174,7 @@ 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.
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
@@ -234,12 +188,7 @@ thing in the set they cannot forge.
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.
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:
@@ -248,9 +197,7 @@ 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.
`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,
@@ -260,16 +207,13 @@ has to match the commit the tag points at.
## 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
`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:
`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
@@ -277,8 +221,7 @@ 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:
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)"
@@ -291,12 +234,9 @@ 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.
`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:
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
@@ -317,35 +257,18 @@ sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
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.
- 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.
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.
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.
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.
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
@@ -358,30 +281,13 @@ zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)"
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.
`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.
`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.
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.
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
@@ -398,25 +304,14 @@ it and the Node version are pinned to exact patch releases at the top of
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.
- `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 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.
- [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.