Repository navigation
Conversation
A release was made by any v* tag push, carried no signature, and its checksums came from the same job as the binaries. The installer and the self-updater installed whatever GitHub called the latest release without checking it, and the updater took any tag that differed from its own, older ones included. Each release now carries release.txt (version, commit, and the SHA-256 of every binary) and release.txt.sig, an ECDSA P-256 signature over it. - release.yml runs only by dispatch on main. The signing key is read only in a job in the `release` environment, which runs no repository code, and the release is drafted, read back, then published as latest. - The self-updater verifies the signature against built-in keys (any one of them, so a key can be rotated), requires release.txt to name the release's tag, checks the binary's hash before it replaces anything, and only moves forward to a full vX.Y.Z release. - The install script verifies the same way with openssl against keys pinned in the script, and exits before `sudo install` on any failure, including a missing openssl. - `make test-release` dispatches a signed draft that is never published or tagged, rather than force-pushing a tag that made a real release. - `make test` also runs the installer's tests. release-key.pub is a placeholder. Until the real public key replaces it (and the copies in release_keys.go and install.sh, which tests hold in step), every self-update and every install refuses to proceed.
golangci-lint v2.14's modernize check flags the hand-rolled LastIndex split now that Go 1.27 has strings.CutLast, which fails CI's lint step on every pull request.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A release is made by pushing any
v*tag. Nothing is signed, andchecksums.txtis written by the same job that builds the binaries, so it can't vouch for them.mainhas no protection and releases aren't immutable.get.once.comand the self-updater install whatever GitHub reports as the latest release without checking it. The updater runs as root every 24 hours and accepts any tag that differs from its own, including older ones. So anything able to push to this repository can reach every ONCE install.There's also an accident in the same class:
make test-releaseforce-pushesv0.0.1-test, which runs the real release workflow and produces a normal, non-prerelease release.Design
Each release now carries
release.txtandrelease.txt.sig:release.txt.sigis an ECDSA P-256 / SHA-256 signature (DER) made byopenssl dgst -sha256 -sign. That format verifies withopensslon any target machine and with the Go standard library, so no new dependency is added. The version line binds the signature to one release, so an older signed manifest can't be served as a newer one..github/workflows/release.ymlruns only onworkflow_dispatchonmain, with aversioninput and an optionaldraftinput. Each job holds one privilege:make dist VERSION=….releaseenvironment, so it is the only job that can readONCE_RELEASE_SIGNING_KEY. It runs no repository code and checks out onlyrelease-key.pub. It checks the files againstrelease.txtand checks the version and commit lines. Then it signs, and verifies the signature against the committed public key, so a mismatched secret fails before anything is published.contents: write, no key, no checkout) uploads to a draft at--target $GITHUB_SHAand reads the draft back to compare digests. It then publishes the draft as--latest, which creates the tag at the built commit, and confirms where the tag points. Withdraft: trueit stops at the draft.internal/version):verify.goandrelease_keys.goverifyrelease.txt.sigagainst the built-in keys. Any one of them is enough, so a key can be rotated.release.txtto name the release's tag, and refuses a release without the two files.replaceBinary. On a mismatch it removes the temp file and leaves the running binary untouched.vX.Y.Z(isNewerReleasereplaces the!=). Adevbuild never self-updates.RecordSelfUpdate(err).installer/templates/install.shdownloadsrelease.txtand.sigand verifies them withopensslagainst the keys pinned inRELEASE_KEYS. It checks the version line and the binary's hash, and exits beforesudo install(and beforeonce background install) on any failure. Ifopensslis missing, it says so and stops. There is no skip flag.Makefile:distwritesrelease.txt, and still writeschecksums.txtfor anyone using it.test-releasenow dispatches a signed draft (v0.0.1-test, draft only). A draft is never tagged, never latest, and never published.make testnow also runs the installer module's tests. CI didn't run them before.release-key.pubis a placeholder and is clearly marked as one. Tests holdrelease_keys.goand theinstall.shcopy byte-for-byte equal to it. While it is a placeholder, every self-update and every install refuses to proceed, so this must not be merged or deployed until the real key is in.Tests
devcases (no assets fetched)openssl-made signature verified by Gorelease-key.pubinstall_once, with downloads read from a fixture andsudorecorded):opensslsudois reached.make dist VERSION=v9.9.9, then the sign job's own steps with a throwaway key, then the rendered installer against the result. It verified, and refused a tampered binary and a mismatched tag.Setup for the owners (settings, not in this PR)
releaseenvironment:mainonly, no tag patternsrelease-key.pubwith it, and paste the identical text intoreleaseKeysininternal/version/release_keys.goandRELEASE_KEYSininstaller/templates/install.sh.make testfails until all three match.mainPR-only with required review and no bypass. The signature attests "main's workflow built main's commit after a reviewer approved", so it's only as strong asmain.Migration
Binaries up to and including v0.3.3 can't be retrofitted. They take one last unverified update, and every update after that is verified.
v0.4.0. Check it by hand withopenssl dgst -sha256 -verify release-key.pub -signature release.txt.sig release.txtandsha256sum. Asset names are unchanged, so existing installs take v0.4.0 on their next daily check. From then on they verify.kamal deploythe installer from amaincheckout, so new installs verify from then on.ONCE_NO_SELF_UPDATEset, or that are offline, stay on the old path until they runonce self-updateonce. The release notes for v0.4.0 should ask them to.Rotation: add the next public key to all three places alongside the current one, and ship a release. Then switch the environment secret to the next key. In a later release, drop the old public key.
What this PR does not do
ghcr.io/basecamp/*pulled by tag) or thebasecamp/kamal-proxyimage.get.docker.comstep.security.yml.GITHUB_TOKEN). They now install nowhere, but they would make new installs fail until removed. A tag ruleset, or read-only default workflow permissions, closes that. Those are settings too.Unrelated commit
Use strings.CutLast in cutLastfixes a golangci-lint v2.14modernizefinding ininternal/docker/helpers.go. That finding fails CI's lint step on every open PR today, including #116. It's here only so this PR's tests run in CI. Drop it if it lands separately.