From 5c89acf3375d7840e55915d3b843c87265f7bb58 Mon Sep 17 00:00:00 2001 From: m5r Date: Sun, 9 Aug 2026 01:35:43 +0200 Subject: [PATCH] milestone 14: verify-a-release walkthrough run against v0.0.1, acceptance closed --- docs/how-to/verify-a-release.md | 166 ++++++++++++-------------------- specs/milestone-14.md | 34 +++++-- 2 files changed, 84 insertions(+), 116 deletions(-) diff --git a/docs/how-to/verify-a-release.md b/docs/how-to/verify-a-release.md index 47410e2..efece58 100644 --- a/docs/how-to/verify-a-release.md +++ b/docs/how-to/verify-a-release.md @@ -8,41 +8,12 @@ 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: no nxdns release exists yet. The repository has no tags, no -> release page and no pushed image, so nothing on this page could be run against -> a real release asset and no command here was pointed at -> `git.mial.net/mokhtar/nxdns` with any expectation of success. Substitutes were -> used, and every block says which one applies to it. -> -> The URL shapes were probed against `gitea.com`, a public instance of the same -> Gitea series running `1.27.0+dev-652-g0571722545`, using `gitea/tea`, which -> does have releases. `git.mial.net` reports `1.27.1`, and its -> `/mokhtar/nxdns/releases/latest` answers 404 — no release to redirect to. On -> `gitea/tea`, `releases/latest` answered 303 to the tag page of `v0.15.1`; -> `releases/download/v0.15.1/checksums.txt` and -> `releases/download/latest/checksums.txt` both answered 303 to the same stored -> object and delivered the same 1,842-byte file under `-L`; -> `releases/latest/download/checksums.txt` — GitHub's spelling — answered 404. -> -> The `gpg --verify` and `sha256sum -c` blocks were run on this host against -> stand-in files: two random-byte files named like the release tarballs, an -> `IMAGE-DIGEST.txt` holding one image reference, and a `SHA256SUMS.txt` -> computed over the three, signed by a **throwaway demonstration key generated -> for this page**. That key has the shape the real one will have — an ed25519 -> primary key plus a separate ed25519 signing subkey, with the signature made by -> the subkey — so the `gpg --verify` output on this page has the two-fingerprint -> structure a subkey-signed release produces. The fingerprints printed in those -> transcripts are the throwaway key's, they are not the project's, and they will -> not match anything you download. The only edit to that run's output is the -> version in every filename, which became ``. -> -> The container blocks were not run against nxdns — there is no published image. -> The two `docker buildx imagetools inspect --format` shapes were run here -> against `alpine:3.22` on Docker Hub, the base this project's builder stage -> pins; the digest form printed -> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce` and -> the platform form printed a list. The `docker create`/`docker cp` comparison -> was run against an image built from this checkout rather than a pulled one. +> 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 @@ -94,11 +65,8 @@ want for the placeholder: VERSION= ``` -> Not verified against nxdns: there is no release to redirect to, so the first -> block prints an empty line here and every URL built from it is a 404. The -> exact two-command form was run against `gitea.com/gitea/tea`, a public -> repository on Gitea `1.27.0+dev` that does have releases, and printed -> `0.15.1`. +> 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. @@ -136,11 +104,9 @@ That is the Gitea spelling, and it is not GitHub's. `releases/latest/download/` position, as `releases/download/latest/`. The tarball filenames contain the version, so this alias never saves you from knowing it for those two. -> Not verified against nxdns: no release, so every URL above is a 404 today. -> Both URL forms, including the 404 for GitHub's spelling, were exercised -> against `gitea.com/gitea/tea` on Gitea `1.27.0+dev`; the versioned path and -> the `latest` alias each answered 303 to the same stored object and delivered -> the same 1,842-byte `checksums.txt` when the redirect was followed. +> 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` @@ -157,11 +123,9 @@ curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346 gpg --import ``` -> Not verified: the key is not published yet. Run on this host, that URL -> returned 404, and so did the `by-email` lookup for the same address. The -> endpoint itself is live: the same `by-fingerprint` path returned 200 for an -> unrelated key that is on keys.openpgp.org. Until this key is published there, -> get it from a source you can check some other way. +> Verified: the key is published, and that exact `curl | gpg --import` reported +> `key 1509B54946D08A95: public key "Mokhtar Mial (pc) " +> imported` into a clean `GNUPGHOME`. Then verify: @@ -170,22 +134,21 @@ gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt ``` ``` -gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST -gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6 -gpg: Good signature from "nxdns release signing (throwaway demonstration key) " [unknown] +gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST +gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96 +gpg: Good signature from "Mokhtar Mial (pc) " [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: 6643 13AA F527 DDAE 1C1E 516C A36F F8DA 4E6C 1C07 - Subkey fingerprint: 9D1E A241 DAEA 89E0 9381 A21B DC27 E8A3 D53C 32D6 +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 ``` -**Those two fingerprints and that user id belong to a throwaway key generated -to produce this transcript.** They are not the project's, and what you see will -carry the project's uid and the fingerprint in this page instead. The -*structure* is what to read: three lines, not one. `using EDDSA key` and +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. +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 @@ -210,22 +173,18 @@ including one an attacker talked you into importing. A tampered `SHA256SUMS.txt` looks like this, and exits 1: ``` -gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST -gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6 -gpg: BAD signature from "nxdns release signing (throwaway demonstration key) " [unknown] +gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST +gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96 +gpg: BAD signature from "Mokhtar Mial (pc) " [unknown] ``` -> Verified on this host. A throwaway ed25519 primary key was generated into a -> temporary `GNUPGHOME`, an ed25519 **signing subkey** was added to it, and the -> stand-in `SHA256SUMS.txt` was signed with `--local-user !` -> — the same construction the release workflow uses — so the transcripts above -> are what a subkey-signed release actually prints, rather than what a key -> signing with its primary would. The verification ran from a second -> `GNUPGHOME` holding only that key's public half, which is why the `[unknown]` -> trust marker and the warning are there rather than being written in by hand. -> The second transcript is the same command after one newline was appended to -> `SHA256SUMS.txt`. The `sed`/`tr` pipeline was run against that same output and -> printed `664313AAF527DDAE1C1E516CA36FF8DA4E6C1C07`, the throwaway primary. +> 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 @@ -263,12 +222,11 @@ 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 on this host against the stand-in files: all three transcripts are -> real `sha256sum` output over two random-byte files named like the release -> tarballs plus an `IMAGE-DIGEST.txt` holding one image reference, with one -> tarball deleted for the first two blocks and one byte appended to the other -> for the third. Only the version in the filenames was replaced with -> ``. +> 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 ``. ## 5. Look inside before extracting @@ -294,7 +252,11 @@ tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz Zig version. The version has to match the tag you downloaded, and the commit has to match the commit the tag points at. -> Not verified on this host: there is no release tarball to list or extract. +> 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 @@ -343,23 +305,13 @@ docker rm nxdns-verify sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns ``` -> Not verified against nxdns: no image is published, so no command here was run -> against `git.mial.net/mokhtar/nxdns`. The two -> `docker buildx imagetools inspect --format` shapes were run on this host -> against `alpine:3.22` on Docker Hub — the digest form printed -> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`, -> which is the digest this project's builder stage pins, and the platform form -> printed `linux/amd64 unknown/unknown linux/arm unknown/unknown ...`. That -> `unknown/unknown` is exactly what the paragraph above says nxdns's own index -> must not contain: Alpine's index carries attestation entries, and nxdns's -> build turns them off. Nothing was checked about how nxdns's index will -> actually look. -> -> The `docker create` / `docker cp` / `sha256sum` comparison at the end was run -> here against an image built from this checkout rather than a pulled one, and -> the two hashes matched: the binary copied out of the image and -> `zig-out/dist/stage/nxdns--x86_64-linux-musl/nxdns` were the same -> file. +> 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 @@ -431,14 +383,16 @@ 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. -> Partly verified on this host. `zig build dist` and `sha256sum` on its output -> were run to completion, with the version read out of `build.zig.zon`: `dist` -> exited 0 and wrote the two tarballs, `SHA256SUMS` and the staged payloads -> described above. `zig build verify-dist` was run on the result too and exited -> 0. What could not be run is everything that needs a release: the clone, the -> checkout and `git verify-tag` need a tag that does not exist, and there is no -> published `SHA256SUMS.txt` to compare a local build against, so the comparison -> this section is about has never been performed. +> 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 diff --git a/specs/milestone-14.md b/specs/milestone-14.md index 5fe4c66..8f2ff6f 100644 --- a/specs/milestone-14.md +++ b/specs/milestone-14.md @@ -695,13 +695,16 @@ was reproduced before it was fixed. - [x] Two runs of `zig build dist` on the same commit produce byte-identical tarballs **in the same directory**. (Cross-directory reproducibility is ruling 12 and is not claimed here.) -- [ ] The image builds for both platforms with no qemu, carries `/LICENSE` and +- [x] The image builds for both platforms with no qemu, carries `/LICENSE` and `/THIRD-PARTY-NOTICES` and the OCI labels, and its binaries are - byte-identical to the tarball binaries. Verified for the native amd64 - image only; the arm64 half needs a runner with buildx. -- [ ] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers - on `master`; `origin/main` is gone. The first two are in the files; no - workflow has run and `origin/main` still exists (manual prerequisite). + byte-identical to the tarball binaries. The v0.0.1 run built and pushed + both platforms on the runner; the published index lists exactly + `linux/amd64 linux/arm64`, and `release verify-image-binaries` compared + both binaries against the tarballs before publication. +- [x] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers + on `master`; `origin/main` is gone. Proven live: pushes to `master` run + the gates through `ci.yml`, and release runs 484-493 ran them through + `release.yml`. - [x] `THIRD-PARTY-NOTICES` covers musl, the Zig runtime, SQLite, Mbed TLS with its Apache-2.0 selection line and full text, Everest, p256-m and the web runtime closure. The dependency drift guard was proven able to fail: @@ -715,10 +718,21 @@ was reproduced before it was fixed. multi-architecture image — and was then deleted (release, git tag, both registry versions). Five attempts; the failures and their fixes are deviations 23-26. -- [ ] `v0.0.1` is published: five assets, a verifying signature, and an image at - `git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`. -- [ ] `docs/how-to/verify-a-release.md` was followed end to end against the - published release, from a clean directory, on this host. +- [x] `v0.0.1` is published: five assets, a verifying signature, and an image at + `git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`. Run 493, all jobs green + on the first attempt after the dry-run fixes. +- [x] `docs/how-to/verify-a-release.md` was followed end to end against the + published release, from a clean directory, on this host, with a clean + `GNUPGHOME` holding only the key fetched from keys.openpgp.org. Every + command on the page passed: the `releases/latest` redirect printed + `0.0.1`, both tarball downloads and the `latest` alias worked (and + GitHub's spelling answered 404 as documented), the signature verified + with matching primary and subkey fingerprints, `sha256sum -c` said OK for + all three files, the tarball layout and modes matched, `nxdns version` + printed the tag's commit, the tag digest equalled `IMAGE-DIGEST.txt`, the + platform list was exactly `linux/amd64 linux/arm64`, and the binary + copied out of the pulled-by-digest image hashed identical to the tarball + binary. - [x] No `zig build cross` or source-only-distribution text remains on any **active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and `docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text —