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
Labels
No labels
bump
major
bump
minor
bump
patch
kind
bug
kind
chore
kind
docs
kind
epic
kind
feature
loop/paused
mirror-drift
priority
critical
priority
high
priority
low
priority
medium
size
L
size
M
size
S
size
XL
status
blocked
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
frankenbit/release-toolkit#1516
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
fetch-rt.shchecks thatchecksums.txtis signed by a trusted key and thatrtmatches it. Nothing ties those three files to the tag they were fetched for. An older release's correctly signedchecksums.txt,.minisigand binary, served under a newer tag, pass every check. The adopter then runs the olderrtwhile 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/main0156d84(2026-10-02):scripts/fetch-rt.sh:68-69asset="rt-${os}-${arch}"andrel=…/releases/download/${RT_VERSION}. The tag appears only in the download URL..goreleaser.yaml:45,:47-50rt-{{ .Os }}-{{ .Arch }}and the manifest ischecksums.txt, neither versioned.goreleaser.yaml:56-69minisign -S -q -s … -x $signature -m $artifact, with no-t, so the trusted comment is minisign's defaultchecksums.txtis one line,84c6eaf4… rt-linux-amd64. v0.62.0's is3741dc7b… rt-linux-amd64. Both trusted comments readtimestamp:<unix> file:checksums.txt hashed.scripts/fetch-rt.sh:191-193,:246-269-V -q, the trusted comment unread), then checks thatrt's sha256 equals thert-linux-amd64line. No step compares anything withRT_VERSION.composite/bootstrap-rt/action.yml:84rt --versionto prove the binary runs. It does not compare the output.The binary does know its version.
.goreleaser.yaml:36stampsmain.version={{ .Version }}, andrt --versionprintsrt <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.shat0156d84, inrt-bats:localwith Debian'sminisign 0.12. A throwaway key signs two goreleaser-shaped fake releases exactly as.goreleaser.yaml:56-69does, and a local HTTP server serves them asRT_DOWNLOAD_BASE. No real asset is downloaded.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
checksums.txtis 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 signedrtunder the newest tag. The pool is every release from v0.56.1 on, including any with a bug since fixed.POST/PATCH/DELETE /repos/{owner}/{repo}/releases/{id}/assets[/{attachment_id}]andPATCH/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.docs/SECURITY.md§Verifying a release givesminisign -Vm checksums.txt -p minisign.puband thensha256sum -c checksums.txt. A replayed set passes both.minisign signature OKandchecksum OK, and the composite prints whateverrt --versionsays without comparing it.Who is affected
Every adopter on the FETCH path, i.e. every tag-pinned adopter. Adopters run the
fetch-rt.shof the tag they pin. From the #1514 census of the six consumers on this instance:minisignabsent fromfetch-rt.sh)fetch-rt.shat that tagFor the three checksum-only consumers no replay is needed: a swapped asset plus a matching
checksums.txtpasses. 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 buildsrtfrom source at@main(AGENTS.md §2.1) and fetches nothing.Not established
reusable-mirror-release.ymlrepublishes release assets. Whether.minisiggoes with them, and who can write releases there, was not measured.Decision tree
Every caller of
fetch-rt.shfetches its own release.bootstrap-rt.sh:34passes 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 --versionwithRT_VERSIONafter the checksum passes. Refuse unless the output isrt ${RT_VERSION#v}. Run it on both the cache-hit and cache-miss paths.(B) Sign a trusted comment that names the tag, and check it. Add
-t "release-toolkit {{ .Tag }} checksums.txt"to.goreleaser.yaml'ssigns.args.fetch-rt.shdrops-q, reads the verified trusted comment, and refuses unless it namesRT_VERSION.minisign -Valone.fetch-rt.sh. Thefetch-rt.batsminisign 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.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.mdwould state that the signature authenticates a signed release, not the tagged one, and that tag and release immutability rest on discipline.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
minisignalone. (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
scripts/fetch-rt.shrefuses a release that is not the oneRT_VERSIONnames, on both the cache-hit and cache-miss paths. The refusal names both the expected and the found version. — DONE @ #1518 (66ee9ce, reviewed as66ee9ce)tests/fetch-rt.batsreproduces 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 as66ee9ce)v1.0.0-alpha.0shape) pass the comparison. — DONE @ #1518 (66ee9ce, reviewed as66ee9ce)docs/SECURITY.md§Verifying a release states what the signature binds and gives a manual step that catches a replay. — DONE @ #1518 (66ee9ce, reviewed as66ee9ce).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. Itschecksums.txt.minisigread backtrusted comment: release-toolkit tag:v0.62.4 file:checksums.txt. DONE @ v0.64.0, the first cut after66ee9ce: itschecksums.txt.minisigreads backtrusted 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 ownscripts/fetch-rt.sh, which carries the check, twice (cache miss, then hit) underset -euo pipefail. Both runs installedrt(step "measure the cache-hit path",cache-HIT end-to-end: 91ms)..minisigis measured and either covered here or filed separately. — DONE @ #1518 (66ee9ce, reviewed as66ee9ce): measured, and no change needed.reusable-mirror-release.yml:310-327copies every asset by name and never re-signs. The live mirror's v0.62.0.minisigcarries the origin's trusted comment and timestamp (1788725401) byte for byte.66ee9ce, reviewed as66ee9ce)Reproduction script
replay-repro.sh: run as
docker run --rm -v <clone>:/src:ro -v <script>:/repro.sh:ro rt-bats:local bash /repro.shbootstrap rtin 16 of 18 reusable files; resolve-ref emits the SHA and bootstrap-rt.sh has no arm for it #1514Decision (operator, 2026-10-02): option (B). The signature's trusted comment names the release tag, and
fetch-rt.shrefuses a manifest whose verified trusted comment does not nameRT_VERSION.fetch-rt.sh.Landed via #1518. This issue stays open for AC 6.
8630004and66ee9ce1c4a2df8fac1b127e548f1ca66f20751donmain, 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.APPROVEDat66ee9ce. The verdict review was 7193 (66ee9ce), after 7192 (8630004), whose three non-blocking items went into66ee9ce.success, including the bats job with real minisign (run 19146, test 48 ran, not skipped). On the landed commit, 32 of 46 push contexts weresuccessand 14 stillpendingwhen this was written, none failed.claude/1516-signed-tag-bindingis gone (404).What AC 6 still needs: the first release cut after
66ee9ce. Read back itschecksums.txt.minisigand quote the third line, which must betrusted comment: release-toolkit tag:<that tag> file:checksums.txt. Also confirm that the cut'sverify-fetch-armjob 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.AC 6 is met, so every acceptance criterion is ticked and I'm shutting this by hand (AGENTS.md §15).
https://git.frankenbit.de/frankenbit/release-toolkit/releases/download/v0.64.0/checksums.txt.minisig, line 3:success. It checks out the digest-bearingv0.64.0, whosescripts/fetch-rt.shcarriesexpected_comment="release-toolkit tag:${RT_VERSION} file:checksums.txt", and runs it twice withRT_VERSION=v0.64.0underset -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.8630004+66ee9cevia #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.