bug(release-assets): the minisign signature does not bind a release to its tag, so an older signed rt passes verification under a newer tag #1516

Closed
opened 2026-10-02 19:53:20 +02:00 by claude · 3 comments
Member

fetch-rt.sh checks that checksums.txt is signed by a trusted key and that rt matches it. Nothing ties those three files to the tag they were fetched for. An older release's correctly signed checksums.txt, .minisig and binary, served under a newer tag, pass every check. The adopter then runs the older rt while the log reads as a verified fetch of the tag they pinned. The signature authenticates a release signed by this key, not the release your tag names.

Found while working on #1514. That work narrowed docs/SECURITY.md's SHA-pin claims and deliberately left this out.

Measured

At origin/main 0156d84 (2026-10-02):

# Where What binds the files to the tag
1 scripts/fetch-rt.sh:68-69 asset="rt-${os}-${arch}" and rel=…/releases/download/${RT_VERSION}. The tag appears only in the download URL.
2 .goreleaser.yaml:45, :47-50 the asset is rt-{{ .Os }}-{{ .Arch }} and the manifest is checksums.txt, neither versioned
3 .goreleaser.yaml:56-69 minisign -S -q -s … -x $signature -m $artifact, with no -t, so the trusted comment is minisign's default
4 the published files v0.63.1's checksums.txt is one line, 84c6eaf4… rt-linux-amd64. v0.62.0's is 3741dc7b… rt-linux-amd64. Both trusted comments read timestamp:<unix> file:checksums.txt hashed.
5 scripts/fetch-rt.sh:191-193, :246-269 verifies the signature (-V -q, the trusted comment unread), then checks that rt's sha256 equals the rt-linux-amd64 line. No step compares anything with RT_VERSION.
6 composite/bootstrap-rt/action.yml:84 runs rt --version to prove the binary runs. It does not compare the output.

The binary does know its version. .goreleaser.yaml:36 stamps main.version={{ .Version }}, and rt --version prints rt <version> (cmd/rt/main.go:362). That has been true since v0.34.0, and signing began at v0.56.1. Nothing reads it.

Reproduction

Real scripts/fetch-rt.sh at 0156d84, in rt-bats:local with Debian's minisign 0.12. A throwaway key signs two goreleaser-shaped fake releases exactly as .goreleaser.yaml:56-69 does, and a local HTTP server serves them as RT_DOWNLOAD_BASE. No real asset is downloaded.

== 1 control: genuine v0.2.0, fetched as v0.2.0
rc=0 rt-says='rt 0.2.0'
== 2 control: v0.2.0 binary swapped for v0.1.0's, manifest left alone
rc=1 ::error::release-toolkit: checksum mismatch for rt-linux-amd64 (expected 1a8572aa…, got 1273df60…)
== 3 control: v0.2.0 manifest edited to match the swapped binary, signature left alone
rc=1 ::error::release-toolkit: minisign verification failed for checksums.txt @ v0.2.0; refusing unsigned or unverifiable manifest
== 4 REPLAY: v0.2.0's three files replaced by v0.1.0's signed three, fetched as v0.2.0
rc=0 rt-says='rt 0.1.0'
== trusted comment the verifier accepted in 4:
trusted comment: timestamp:1790963425	file:checksums.txt	hashed

Controls 2 and 3 show that the checksum and signature checks work. Row 4 is the gap. The script is at the end.

Why it matters

  • This is the case the signature exists for. checksums.txt is fetched from the same release as the binary. The signature (#513) is what should make write access to releases insufficient to change what adopters run. The secret key is a separate credential, RELEASE_TOOLKIT_MINISIGN_SECRET_KEY. Replay lets someone who can write releases but lacks the key serve any older signed rt under the newest tag. The pool is every release from v0.56.1 on, including any with a bug since fixed.
  • Write access to releases is enough. This forge's API documents POST/PATCH/DELETE /repos/{owner}/{repo}/releases/{id}/assets[/{attachment_id}] and PATCH/DELETE /repos/{owner}/{repo}/releases/{id} (swagger, Forgejo 15.0.7). The release workflow's own publishing token holds exactly that write access, by construction.
  • The documented manual check has the same gap. docs/SECURITY.md §Verifying a release gives minisign -Vm checksums.txt -p minisign.pub and then sha256sum -c checksums.txt. A replayed set passes both.
  • The log gives nothing away. It prints minisign signature OK and checksum OK, and the composite prints whatever rt --version says without comparing it.

Who is affected

Every adopter on the FETCH path, i.e. every tag-pinned adopter. Adopters run the fetch-rt.sh of the tag they pin. From the #1514 census of the six consumers on this instance:

Consumer Pins Fetcher at that tag
storey v0.63.0 signature + checksum: this issue applies
tmux-tell v0.63.1 signature + checksum: this issue applies
purser v0.36.0 checksum only (minisign absent from fetch-rt.sh)
cellblock v0.42.0 checksum only
ember v0.42.0, v0.56.0 checksum only
tic-tac-toe v0.26.0 no fetch-rt.sh at that tag

For the three checksum-only consumers no replay is needed: a swapped asset plus a matching checksums.txt passes. Upgrading to v0.56.1 or later fixes that, so it is not this issue. A fix here reaches an adopter only once they pin a tag that contains it. Toolkit-self builds rt from source at @main (AGENTS.md §2.1) and fetches nothing.

Not established

  • The exact permission behind the asset endpoints was not probed, and no release was modified to show the replay live. The reproduction is synthetic by design.
  • The public mirror. reusable-mirror-release.yml republishes release assets. Whether .minisig goes with them, and who can write releases there, was not measured.

Decision tree

Every caller of fetch-rt.sh fetches its own release. bootstrap-rt.sh:34 passes the baked ref, and the goreleaser verifier (goreleaser.yml:1274-1284) passes the tag it just published. So a check only has to recognise the fetcher's own release, and any older release fails it whatever format it prints.

(A) Compare rt --version with RT_VERSION after the checksum passes. Refuse unless the output is rt ${RT_VERSION#v}. Run it on both the cache-hit and cache-miss paths.

  • Right when a small change that works for every release from the fix on is wanted now. The binary is already signature- and checksum-verified, so running it is no worse than what the next step does, and the composite already runs it.
  • Leaves: the binding rests on the binary's self-report, not on the signature. It is still sound, because a signed binary's bytes, and so its version string, cannot change, but the claim is one step removed. The manual recipe needs the same step.

(B) Sign a trusted comment that names the tag, and check it. Add -t "release-toolkit {{ .Tag }} checksums.txt" to .goreleaser.yaml's signs.args. fetch-rt.sh drops -q, reads the verified trusted comment, and refuses unless it names RT_VERSION.

  • Right when the binding should live in the signed bytes themselves, so the manual recipe can check it with minisign -V alone.
  • Leaves: releases before the cutover carry no such comment. Refusing a manifest without one breaks no caller, because each caller fetches its own release and older tags run their own older fetch-rt.sh. The fetch-rt.bats minisign mock must learn to emit a trusted comment.

(C) Version the asset or manifest names, e.g. rt-{{ .Version }}-linux-amd64, so a replayed manifest lacks the expected line.

  • Right when the version should be visible in the filenames too.
  • Costs: the most churn. The asset name appears in fetch-rt.sh, the docs' manual recipe, the mirror workflow and the post-publish verifier, and the cutover is the same as (B)'s.

(D) Document only. docs/SECURITY.md would state that the signature authenticates a signed release, not the tagged one, and that tag and release immutability rest on discipline.

  • Right when write access to releases is outside the threat model, which SECURITY.md's "trust the upstream" framing arguably already says.
  • Leaves: the signature's stated purpose unmet.

Lean, for the operator to overrule: (B). The binding belongs in what the key signs, and it is the only option that lets the manual recipe catch a replay with minisign alone. (A) is the smaller change if the signing step should stay untouched, and it closes the automated path just as well. Either one protects from the first cut that carries it. (C) buys nothing over (B) at a higher cost.

Acceptance criteria

  • A decision is recorded on this issue among (A)–(D) or a combination. — DONE in comment 114216: option (B).
  • scripts/fetch-rt.sh refuses a release that is not the one RT_VERSION names, on both the cache-hit and cache-miss paths. The refusal names both the expected and the found version. — DONE @ #1518 (66ee9ce, reviewed as 66ee9ce)
  • tests/fetch-rt.bats reproduces the replay: an older signed set served under a newer tag. It is red before the change and green after, and mutation-verified (AGENTS.md §8 Axis B). The genuine fetch, the swapped binary and the edited manifest stay as controls. — DONE @ #1518 (66ee9ce, reviewed as 66ee9ce)
  • Prerelease tags (v1.0.0-alpha.0 shape) pass the comparison. — DONE @ #1518 (66ee9ce, reviewed as 66ee9ce)
  • docs/SECURITY.md §Verifying a release states what the signature binds and gives a manual step that catches a replay. — DONE @ #1518 (66ee9ce, reviewed as 66ee9ce)
  • If (B): .goreleaser.yaml's signing args are changed and the first signed release after it is read back to show the trusted comment names its tag (AGENTS.md §8: quote the read-back). — PARTIAL @ #1518 (66ee9ce): the signing args are changed, and goreleaser v2.17.0 (the pinned version) was run locally in snapshot mode. Its checksums.txt.minisig read back trusted comment: release-toolkit tag:v0.62.4 file:checksums.txt. DONE @ v0.64.0, the first cut after 66ee9ce: its checksums.txt.minisig reads back trusted comment: release-toolkit tag:v0.64.0 file:checksums.txt. goreleaser run 19184's "adopters can fetch the published asset" job ran v0.64.0's own scripts/fetch-rt.sh, which carries the check, twice (cache miss, then hit) under set -euo pipefail. Both runs installed rt (step "measure the cache-hit path", cache-HIT end-to-end: 91ms).
  • The public mirror's handling of .minisig is measured and either covered here or filed separately. — DONE @ #1518 (66ee9ce, reviewed as 66ee9ce): measured, and no change needed. reusable-mirror-release.yml:310-327 copies every asset by name and never re-signs. The live mirror's v0.62.0 .minisig carries the origin's trusted comment and timestamp (1788725401) byte for byte.
  • A changelog fragment, because the change is adopter-visible. — DONE @ #1518 (66ee9ce, reviewed as 66ee9ce)

Reproduction script

replay-repro.sh: run as docker run --rm -v <clone>:/src:ro -v <script>:/repro.sh:ro rt-bats:local bash /repro.sh
#!/usr/bin/env bash
# Reproduce: fetch-rt.sh accepts an OLDER signed release served under a NEWER tag.
# Throwaway minisign key, fake binaries, local HTTP server. No real asset is used.
set -euo pipefail
apt-get update -qq >/dev/null && apt-get install -y -qq minisign >/dev/null
echo "minisign: $(minisign -v 2>&1 | head -1)"

FETCH=/src/scripts/fetch-rt.sh
W=$(mktemp -d); cd "$W"
minisign -G -W -p key.pub -s key.sec >/dev/null
export RT_MINISIGN_PUBLIC_KEY="$(cat key.pub)"

REL=srv/frankenbit/release-toolkit/releases/download
publish() { # publish <version>: a goreleaser-shaped release, signed like .goreleaser.yaml:56-69
  local v=$1 d="$REL/v$1"
  mkdir -p "$d"
  printf '#!/bin/sh\necho "rt %s"\n' "$v" > "$d/rt-linux-amd64"
  (cd "$d" && sha256sum rt-linux-amd64 > checksums.txt \
           && minisign -S -q -s "$W/key.sec" -x checksums.txt.minisig -m checksums.txt)
}
publish 0.1.0
publish 0.2.0
cp -r "$REL/v0.2.0" "$W/v0.2.0.genuine"

(cd srv && python3 -m http.server 18080 --bind 127.0.0.1 >/dev/null 2>&1) &
trap 'kill %1 2>/dev/null || true' EXIT
for _ in $(seq 50); do curl -sf -o /dev/null http://127.0.0.1:18080/ && break; sleep 0.1; done

fetch() { # fetch <tag> -> prints "rc=<n> rt-says=<version output>"
  local dir rc out
  dir=$(mktemp -d)
  out=$(RT_VERSION=$1 RT_DOWNLOAD_BASE=http://127.0.0.1:18080 RT_INSTALL_DIR="$dir" \
        env -u GITHUB_PATH bash "$FETCH" 2>&1) && rc=0 || rc=$?
  if [ "$rc" -eq 0 ]; then
    echo "rc=0 rt-says='$("$dir/rt" --version)'"
  else
    echo "rc=$rc $(printf '%s\n' "$out" | grep '::error::' | head -1)"
  fi
}

echo "== 1 control: genuine v0.2.0, fetched as v0.2.0"
fetch v0.2.0
echo "== 2 control: v0.2.0 binary swapped for v0.1.0's, manifest left alone"
cp "$REL/v0.1.0/rt-linux-amd64" "$REL/v0.2.0/rt-linux-amd64"; fetch v0.2.0
echo "== 3 control: v0.2.0 manifest edited to match the swapped binary, signature left alone"
(cd "$REL/v0.2.0" && sha256sum rt-linux-amd64 > checksums.txt); fetch v0.2.0
echo "== 4 REPLAY: v0.2.0's three files replaced by v0.1.0's signed three, fetched as v0.2.0"
cp "$REL/v0.1.0/"* "$REL/v0.2.0/"; fetch v0.2.0
echo "== trusted comment the verifier accepted in 4:"; sed -n 3p "$REL/v0.2.0/checksums.txt.minisig"
`fetch-rt.sh` checks that `checksums.txt` is signed by a trusted key and that `rt` matches it. Nothing ties those three files to the tag they were fetched for. **An older release's correctly signed `checksums.txt`, `.minisig` and binary, served under a newer tag, pass every check.** The adopter then runs the older `rt` while the log reads as a verified fetch of the tag they pinned. The signature authenticates *a release signed by this key*, not *the release your tag names*. Found while working on #1514. That work narrowed `docs/SECURITY.md`'s SHA-pin claims and deliberately left this out. ## Measured At `origin/main` `0156d84` (2026-10-02): | # | Where | What binds the files to the tag | |---|---|---| | 1 | `scripts/fetch-rt.sh:68-69` | `asset="rt-${os}-${arch}"` and `rel=…/releases/download/${RT_VERSION}`. The tag appears only in the download URL. | | 2 | `.goreleaser.yaml:45`, `:47-50` | the asset is `rt-{{ .Os }}-{{ .Arch }}` and the manifest is `checksums.txt`, neither versioned | | 3 | `.goreleaser.yaml:56-69` | `minisign -S -q -s … -x $signature -m $artifact`, with no `-t`, so the trusted comment is minisign's default | | 4 | the published files | v0.63.1's `checksums.txt` is one line, `84c6eaf4… rt-linux-amd64`. v0.62.0's is `3741dc7b… rt-linux-amd64`. Both trusted comments read `timestamp:<unix> file:checksums.txt hashed`. | | 5 | `scripts/fetch-rt.sh:191-193`, `:246-269` | verifies the signature (`-V -q`, the trusted comment unread), then checks that `rt`'s sha256 equals the `rt-linux-amd64` line. No step compares anything with `RT_VERSION`. | | 6 | `composite/bootstrap-rt/action.yml:84` | runs `rt --version` to prove the binary *runs*. It does not compare the output. | The binary does know its version. `.goreleaser.yaml:36` stamps `main.version={{ .Version }}`, and `rt --version` prints `rt <version>` (`cmd/rt/main.go:362`). That has been true since v0.34.0, and signing began at v0.56.1. Nothing reads it. ### Reproduction Real `scripts/fetch-rt.sh` at `0156d84`, in `rt-bats:local` with Debian's `minisign 0.12`. A throwaway key signs two goreleaser-shaped fake releases exactly as `.goreleaser.yaml:56-69` does, and a local HTTP server serves them as `RT_DOWNLOAD_BASE`. No real asset is downloaded. ``` == 1 control: genuine v0.2.0, fetched as v0.2.0 rc=0 rt-says='rt 0.2.0' == 2 control: v0.2.0 binary swapped for v0.1.0's, manifest left alone rc=1 ::error::release-toolkit: checksum mismatch for rt-linux-amd64 (expected 1a8572aa…, got 1273df60…) == 3 control: v0.2.0 manifest edited to match the swapped binary, signature left alone rc=1 ::error::release-toolkit: minisign verification failed for checksums.txt @ v0.2.0; refusing unsigned or unverifiable manifest == 4 REPLAY: v0.2.0's three files replaced by v0.1.0's signed three, fetched as v0.2.0 rc=0 rt-says='rt 0.1.0' == trusted comment the verifier accepted in 4: trusted comment: timestamp:1790963425 file:checksums.txt hashed ``` Controls 2 and 3 show that the checksum and signature checks work. Row 4 is the gap. The script is at the end. ## Why it matters - **This is the case the signature exists for.** `checksums.txt` is fetched from the same release as the binary. The signature (#513) is what should make *write access to releases* insufficient to change what adopters run. The secret key is a separate credential, `RELEASE_TOOLKIT_MINISIGN_SECRET_KEY`. Replay lets someone who can write releases but lacks the key serve any older signed `rt` under the newest tag. The pool is every release from v0.56.1 on, including any with a bug since fixed. - **Write access to releases is enough.** This forge's API documents `POST`/`PATCH`/`DELETE /repos/{owner}/{repo}/releases/{id}/assets[/{attachment_id}]` and `PATCH`/`DELETE /repos/{owner}/{repo}/releases/{id}` (swagger, Forgejo 15.0.7). The release workflow's own publishing token holds exactly that write access, by construction. - **The documented manual check has the same gap.** `docs/SECURITY.md` §Verifying a release gives `minisign -Vm checksums.txt -p minisign.pub` and then `sha256sum -c checksums.txt`. A replayed set passes both. - **The log gives nothing away.** It prints `minisign signature OK` and `checksum OK`, and the composite prints whatever `rt --version` says without comparing it. ## Who is affected Every adopter on the FETCH path, i.e. every tag-pinned adopter. Adopters run the `fetch-rt.sh` of the tag they pin. From the #1514 census of the six consumers on this instance: | Consumer | Pins | Fetcher at that tag | |---|---|---| | storey | v0.63.0 | signature + checksum: **this issue applies** | | tmux-tell | v0.63.1 | signature + checksum: **this issue applies** | | purser | v0.36.0 | checksum only (`minisign` absent from `fetch-rt.sh`) | | cellblock | v0.42.0 | checksum only | | ember | v0.42.0, v0.56.0 | checksum only | | tic-tac-toe | v0.26.0 | no `fetch-rt.sh` at that tag | For the three checksum-only consumers no replay is needed: a swapped asset plus a matching `checksums.txt` passes. Upgrading to v0.56.1 or later fixes that, so it is not this issue. **A fix here reaches an adopter only once they pin a tag that contains it.** Toolkit-self builds `rt` from source at `@main` (AGENTS.md §2.1) and fetches nothing. ## Not established - **The exact permission behind the asset endpoints** was not probed, and no release was modified to show the replay live. The reproduction is synthetic by design. - **The public mirror.** `reusable-mirror-release.yml` republishes release assets. Whether `.minisig` goes with them, and who can write releases there, was not measured. ## Decision tree Every caller of `fetch-rt.sh` fetches its own release. `bootstrap-rt.sh:34` passes the baked ref, and the goreleaser verifier (`goreleaser.yml:1274-1284`) passes the tag it just published. So a check only has to recognise the fetcher's own release, and any older release fails it whatever format it prints. **(A) Compare `rt --version` with `RT_VERSION` after the checksum passes.** Refuse unless the output is `rt ${RT_VERSION#v}`. Run it on both the cache-hit and cache-miss paths. - *Right when* a small change that works for every release from the fix on is wanted now. The binary is already signature- and checksum-verified, so running it is no worse than what the next step does, and the composite already runs it. - *Leaves:* the binding rests on the binary's self-report, not on the signature. It is still sound, because a signed binary's bytes, and so its version string, cannot change, but the claim is one step removed. The manual recipe needs the same step. **(B) Sign a trusted comment that names the tag, and check it.** Add `-t "release-toolkit {{ .Tag }} checksums.txt"` to `.goreleaser.yaml`'s `signs.args`. `fetch-rt.sh` drops `-q`, reads the verified trusted comment, and refuses unless it names `RT_VERSION`. - *Right when* the binding should live in the signed bytes themselves, so the manual recipe can check it with `minisign -V` alone. - *Leaves:* releases before the cutover carry no such comment. Refusing a manifest without one breaks no caller, because each caller fetches its own release and older tags run their own older `fetch-rt.sh`. The `fetch-rt.bats` minisign mock must learn to emit a trusted comment. **(C) Version the asset or manifest names**, e.g. `rt-{{ .Version }}-linux-amd64`, so a replayed manifest lacks the expected line. - *Right when* the version should be visible in the filenames too. - *Costs:* the most churn. The asset name appears in `fetch-rt.sh`, the docs' manual recipe, the mirror workflow and the post-publish verifier, and the cutover is the same as (B)'s. **(D) Document only.** `docs/SECURITY.md` would state that the signature authenticates a signed release, not the tagged one, and that tag and release immutability rest on discipline. - *Right when* write access to releases is outside the threat model, which SECURITY.md's "trust the upstream" framing arguably already says. - *Leaves:* the signature's stated purpose unmet. **Lean, for the operator to overrule:** (B). The binding belongs in what the key signs, and it is the only option that lets the manual recipe catch a replay with `minisign` alone. (A) is the smaller change if the signing step should stay untouched, and it closes the automated path just as well. Either one protects from the first cut that carries it. (C) buys nothing over (B) at a higher cost. ## Acceptance criteria - [x] **A decision is recorded on this issue** among (A)–(D) or a combination. — DONE in comment 114216: option (B). - [x] **`scripts/fetch-rt.sh` refuses a release that is not the one `RT_VERSION` names**, on both the cache-hit and cache-miss paths. The refusal names both the expected and the found version. — DONE @ #1518 (`66ee9ce`, reviewed as `66ee9ce`) - [x] **`tests/fetch-rt.bats` reproduces the replay**: an older signed set served under a newer tag. It is red before the change and green after, and mutation-verified (AGENTS.md §8 Axis B). The genuine fetch, the swapped binary and the edited manifest stay as controls. — DONE @ #1518 (`66ee9ce`, reviewed as `66ee9ce`) - [x] Prerelease tags (`v1.0.0-alpha.0` shape) pass the comparison. — DONE @ #1518 (`66ee9ce`, reviewed as `66ee9ce`) - [x] `docs/SECURITY.md` §Verifying a release states what the signature binds and gives a manual step that catches a replay. — DONE @ #1518 (`66ee9ce`, reviewed as `66ee9ce`) - [x] If (B): `.goreleaser.yaml`'s signing args are changed and the first signed release after it is read back to show the trusted comment names its tag (AGENTS.md §8: quote the read-back). — PARTIAL @ #1518 (`66ee9ce`): the signing args are changed, and goreleaser v2.17.0 (the pinned version) was run locally in snapshot mode. Its `checksums.txt.minisig` read back `trusted comment: release-toolkit tag:v0.62.4 file:checksums.txt`. **DONE @ v0.64.0, the first cut after `66ee9ce`:** its `checksums.txt.minisig` reads back `trusted comment: release-toolkit tag:v0.64.0 file:checksums.txt`. goreleaser run 19184's "adopters can fetch the published asset" job ran v0.64.0's own `scripts/fetch-rt.sh`, which carries the check, twice (cache miss, then hit) under `set -euo pipefail`. Both runs installed `rt` (step "measure the cache-hit path", `cache-HIT end-to-end: 91ms`). - [x] The public mirror's handling of `.minisig` is measured and either covered here or filed separately. — DONE @ #1518 (`66ee9ce`, reviewed as `66ee9ce`): measured, and no change needed. `reusable-mirror-release.yml:310-327` copies every asset by name and never re-signs. The live mirror's v0.62.0 `.minisig` carries the origin's trusted comment and timestamp (`1788725401`) byte for byte. - [x] A changelog fragment, because the change is adopter-visible. — DONE @ #1518 (`66ee9ce`, reviewed as `66ee9ce`) ## Reproduction script <details> <summary>replay-repro.sh: run as <code>docker run --rm -v &lt;clone&gt;:/src:ro -v &lt;script&gt;:/repro.sh:ro rt-bats:local bash /repro.sh</code></summary> ```bash #!/usr/bin/env bash # Reproduce: fetch-rt.sh accepts an OLDER signed release served under a NEWER tag. # Throwaway minisign key, fake binaries, local HTTP server. No real asset is used. set -euo pipefail apt-get update -qq >/dev/null && apt-get install -y -qq minisign >/dev/null echo "minisign: $(minisign -v 2>&1 | head -1)" FETCH=/src/scripts/fetch-rt.sh W=$(mktemp -d); cd "$W" minisign -G -W -p key.pub -s key.sec >/dev/null export RT_MINISIGN_PUBLIC_KEY="$(cat key.pub)" REL=srv/frankenbit/release-toolkit/releases/download publish() { # publish <version>: a goreleaser-shaped release, signed like .goreleaser.yaml:56-69 local v=$1 d="$REL/v$1" mkdir -p "$d" printf '#!/bin/sh\necho "rt %s"\n' "$v" > "$d/rt-linux-amd64" (cd "$d" && sha256sum rt-linux-amd64 > checksums.txt \ && minisign -S -q -s "$W/key.sec" -x checksums.txt.minisig -m checksums.txt) } publish 0.1.0 publish 0.2.0 cp -r "$REL/v0.2.0" "$W/v0.2.0.genuine" (cd srv && python3 -m http.server 18080 --bind 127.0.0.1 >/dev/null 2>&1) & trap 'kill %1 2>/dev/null || true' EXIT for _ in $(seq 50); do curl -sf -o /dev/null http://127.0.0.1:18080/ && break; sleep 0.1; done fetch() { # fetch <tag> -> prints "rc=<n> rt-says=<version output>" local dir rc out dir=$(mktemp -d) out=$(RT_VERSION=$1 RT_DOWNLOAD_BASE=http://127.0.0.1:18080 RT_INSTALL_DIR="$dir" \ env -u GITHUB_PATH bash "$FETCH" 2>&1) && rc=0 || rc=$? if [ "$rc" -eq 0 ]; then echo "rc=0 rt-says='$("$dir/rt" --version)'" else echo "rc=$rc $(printf '%s\n' "$out" | grep '::error::' | head -1)" fi } echo "== 1 control: genuine v0.2.0, fetched as v0.2.0" fetch v0.2.0 echo "== 2 control: v0.2.0 binary swapped for v0.1.0's, manifest left alone" cp "$REL/v0.1.0/rt-linux-amd64" "$REL/v0.2.0/rt-linux-amd64"; fetch v0.2.0 echo "== 3 control: v0.2.0 manifest edited to match the swapped binary, signature left alone" (cd "$REL/v0.2.0" && sha256sum rt-linux-amd64 > checksums.txt); fetch v0.2.0 echo "== 4 REPLAY: v0.2.0's three files replaced by v0.1.0's signed three, fetched as v0.2.0" cp "$REL/v0.1.0/"* "$REL/v0.2.0/"; fetch v0.2.0 echo "== trusted comment the verifier accepted in 4:"; sed -n 3p "$REL/v0.2.0/checksums.txt.minisig" ``` </details>
Author
Member

Decision (operator, 2026-10-02): option (B). The signature's trusted comment names the release tag, and fetch-rt.sh refuses a manifest whose verified trusted comment does not name RT_VERSION.

  • (A), (C) and (D) are not pursued here.
  • A manifest signed before the change carries minisign's default comment, which names no tag. A fetcher containing this change refuses it. That breaks no caller, because each caller fetches its own release, and older tags keep their own older fetch-rt.sh.
  • AC 6 (read back the first signed release's trusted comment) can only be ticked after the next cut, so this issue stays open until then.
**Decision (operator, 2026-10-02): option (B).** The signature's trusted comment names the release tag, and `fetch-rt.sh` refuses a manifest whose verified trusted comment does not name `RT_VERSION`. - (A), (C) and (D) are not pursued here. - A manifest signed before the change carries minisign's default comment, which names no tag. A fetcher containing this change refuses it. That breaks no caller, because each caller fetches its own release, and older tags keep their own older `fetch-rt.sh`. - AC 6 (read back the first signed release's trusted comment) can only be ticked after the next cut, so this issue stays open until then.
Author
Member

Landed via #1518. This issue stays open for AC 6.

  • Landed: 8630004 and 66ee9ce1c4a2df8fac1b127e548f1ca66f20751d on main, fast-forwarded by alex at 20:26:53. The merge commit is the reviewed head, and main's top two commits are exactly the two that were reviewed.
  • Approval: review 7195 by alex, APPROVED at 66ee9ce. The verdict review was 7193 (66ee9ce), after 7192 (8630004), whose three non-blocking items went into 66ee9ce.
  • Checks: at the reviewed head, all 30 PR contexts are success, including the bats job with real minisign (run 19146, test 48 ran, not skipped). On the landed commit, 32 of 46 push contexts were success and 14 still pending when this was written, none failed.
  • Head branch: claude/1516-signed-tag-binding is gone (404).

What AC 6 still needs: the first release cut after 66ee9ce. Read back its checksums.txt.minisig and quote the third line, which must be trusted comment: release-toolkit tag:<that tag> file:checksums.txt. Also confirm that the cut's verify-fetch-arm job passed, because it is the first fetch through the new check against a real release. Tick AC 6 and shut this issue by hand once both are recorded.

**Landed via #1518. This issue stays open for AC 6.** - **Landed:** `8630004` and `66ee9ce1c4a2df8fac1b127e548f1ca66f20751d` on `main`, fast-forwarded by alex at 20:26:53. The merge commit is the reviewed head, and main's top two commits are exactly the two that were reviewed. - **Approval:** review 7195 by alex, `APPROVED` at `66ee9ce`. The verdict review was 7193 (`66ee9ce`), after 7192 (`8630004`), whose three non-blocking items went into `66ee9ce`. - **Checks:** at the reviewed head, all 30 PR contexts are `success`, including the bats job with real minisign (run 19146, test 48 ran, not skipped). On the landed commit, 32 of 46 push contexts were `success` and 14 still `pending` when this was written, none failed. - **Head branch:** `claude/1516-signed-tag-binding` is gone (404). **What AC 6 still needs:** the first release cut after `66ee9ce`. Read back its `checksums.txt.minisig` and quote the third line, which must be `trusted comment: release-toolkit tag:<that tag> file:checksums.txt`. Also confirm that the cut's `verify-fetch-arm` job passed, because it is the first fetch through the new check against a real release. Tick AC 6 and shut this issue by hand once both are recorded.
Author
Member

AC 6 is met, so every acceptance criterion is ticked and I'm shutting this by hand (AGENTS.md §15).

  • Read-back (§8: the read-back, not the write-ack): https://git.frankenbit.de/frankenbit/release-toolkit/releases/download/v0.64.0/checksums.txt.minisig, line 3:
    trusted comment: release-toolkit tag:v0.64.0 file:checksums.txt
    
  • First live fetch through the check: goreleaser run 19184, job 4 ("adopters can fetch the published asset"), success. It checks out the digest-bearing v0.64.0, whose scripts/fetch-rt.sh carries expected_comment="release-toolkit tag:${RT_VERSION} file:checksums.txt", and runs it twice with RT_VERSION=v0.64.0 under set -euo pipefail. Stdout goes to /dev/null, so the evidence is the exit status plus the installed binary, not a log line. A mismatch exits 1.
  • Landed earlier as 8630004 + 66ee9ce via #1518 (approval 7195). See comment 114255.

Unrelated to this change: the same cut's mirror drift check / check (release) went red on v0.63.1, which has never been mirrored to gitea.com (#1348). Tracked there.

AC 6 is met, so every acceptance criterion is ticked and I'm shutting this by hand (AGENTS.md §15). - **Read-back** (§8: the read-back, not the write-ack): `https://git.frankenbit.de/frankenbit/release-toolkit/releases/download/v0.64.0/checksums.txt.minisig`, line 3: ``` trusted comment: release-toolkit tag:v0.64.0 file:checksums.txt ``` - **First live fetch through the check:** goreleaser run 19184, job 4 ("adopters can fetch the published asset"), `success`. It checks out the digest-bearing `v0.64.0`, whose `scripts/fetch-rt.sh` carries `expected_comment="release-toolkit tag:${RT_VERSION} file:checksums.txt"`, and runs it twice with `RT_VERSION=v0.64.0` under `set -euo pipefail`. Stdout goes to `/dev/null`, so the evidence is the exit status plus the installed binary, not a log line. A mismatch exits 1. - **Landed** earlier as `8630004` + `66ee9ce` via #1518 (approval 7195). See comment 114255. Unrelated to this change: the same cut's `mirror drift check / check (release)` went red on **v0.63.1**, which has never been mirrored to gitea.com (#1348). Tracked there.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
frankenbit/release-toolkit#1516
No description provided.