Packaging and releasing CLIs

Verify a downloaded binary: checksums, GPG, minisign, cosign

· 10 min read

On this page (9)
  1. Who needs this
  2. Threats, in plain terms
  3. Layer one: the checksums file
  4. Layer two: a signature on the checksums file
  5. Comparing the options
  6. What an installer should do, in order
  7. Deploy scripts do the same on a VPS
  8. Mistakes we see
  9. FAQ

A release page offers an archive, a file called checksums.txt and perhaps a .sig or .minisig next to it. People download the archive, run it, and never touch the other files. The ones who do verify often run a command that cannot fail, or one that proves less than they think. This post lays out what each check actually protects against, shows the commands on both the publishing and the downloading side, and finishes with the order an installer should apply them in.

What vouches for what

A chain of trust from top to bottom: a public key obtained off the release page verifies the signature, which covers the checksums file, which lists the archive hash, which the downloaded archive must match.Public keyobtained away from the release pageSignatureverifies the checksums fileChecksums fileone hash line per archiveDownloaded archiveits SHA-256 must match its line

Who needs this

You publish a CLI and want to stop saying "trust me". You have checksums already because GoReleaser makes them. The next question from a cautious user is who vouches for the checksums file. A signature answers that, and the setup is an afternoon of work once.

You deploy a binary to your own server from a release page or an artifact store. The deploy script downloads app_1.4.2_linux_amd64.tar.gz onto a VPS and unpacks it into a releases directory. An unverified download there means the code that runs with your database credentials came from whatever answered the request. Two lines of verification in the deploy script close that. See rollback in under a minute with symlink releases for where such a step fits in the deploy.

You consume other people's tools in CI. Your pipeline fetches shellcheck, restic or a linter by URL. Pin the version and verify the hash in the pipeline so that a changed upstream file fails the build instead of running with your secrets.

You review a security questionnaire. "How do you ensure the integrity of release artifacts?" has a short honest answer when you can name a checksums file, a signing key and a documented verification command, and a long awkward one when you cannot.

Threats, in plain terms

Different tools stop different attackers, so name the attacker first.

  1. Corruption. A flaky connection, a bad proxy or a half-written disk gives you wrong bytes. Nobody is malicious.
  2. A swapped archive. One file on the server or CDN is replaced, and the checksums file is left alone.
  3. A swapped archive and checksums file. Whoever can write to the release page replaces both. Every checksum matches.
  4. A stolen publishing account. The attacker can do whatever the maintainer can do through the web UI or API, including uploading new files that look official.
  5. A compromised build. The release is built and signed correctly, from source that was tampered with, or on a machine that was subverted.

A checksum alone covers the first two. A signature made with a key that is not on the release host covers the third. The fourth depends on where the key lives: a key on the same compromised laptop or CI secret store does not help, and a key kept offline or tied to a CI identity does. The fifth is the business of reproducible builds, where someone rebuilds the tag and compares hashes.

Layer one: the checksums file

GoReleaser writes mycli_1.2.3_checksums.txt with one line per archive in the format sha256sum reads:

3f4a...e9  mycli_1.2.3_linux_amd64.tar.gz
a81c...07  mycli_1.2.3_linux_arm64.tar.gz

To verify by hand on Linux, download the archive and the checksums file into one directory, then:

sha256sum --ignore-missing -c mycli_1.2.3_checksums.txt
# mycli_1.2.3_linux_amd64.tar.gz: OK

--ignore-missing is a GNU option that skips the lines for archives you did not download, which is what you want, since the file lists every platform. Read the output. If it prints nothing at all, you checked zero files, and you have proved nothing. On macOS use shasum -a 256 -c and pick the line yourself:

grep ' mycli_1.2.3_darwin_arm64.tar.gz$' mycli_1.2.3_checksums.txt | shasum -a 256 -c -

Piping one line in avoids the missing-file noise. In scripts, comparing explicitly is safer, and the installer post shows an awk lookup that insists on exactly one matching line.

What this layer gives you is detection of corruption and of a swapped archive. What it does not give you is anything against an attacker who controls the page: they publish a new archive and a new checksums file, and the match is perfect.

Layer two: a signature on the checksums file

You sign the checksums file, not every archive. One signature then covers all of them, because the checksums file contains each archive's hash. The user verifies the signature, then verifies the archive against the file. There are three practical tools, and the choice is mostly about key management.

GPG

GPG is everywhere and every Linux distribution ships it. Generate a dedicated key for releases, publish the public key somewhere other than the release page (the repository, your site, a keyserver), and sign in CI or on your machine.

gpg --quick-generate-key "mycli releases <releases@example.com>" ed25519 sign 2y
gpg --armor --export releases@example.com > release-key.asc
gpg --fingerprint releases@example.com

GoReleaser signs through a signs block. This one targets the checksums file and calls gpg in batch mode:

signs:
  - id: gpg-checksums
    cmd: gpg
    artifacts: checksum
    args:
      - "--batch"
      - "--local-user"
      - "{{ .Env.GPG_FINGERPRINT }}"
      - "--output"
      - "${signature}"
      - "--detach-sign"
      - "${artifact}"

The result is mycli_1.2.3_checksums.txt.sig, uploaded with the release. ${artifact} and ${signature} are placeholders GoReleaser fills in, and the signature defaults to the artifact name plus .sig. A key with a passphrase needs more arguments, such as --pinentry-mode loopback and a passphrase supplied from a CI secret, so test that path before you rely on it.

A user verifies like this:

gpg --import release-key.asc
gpg --verify mycli_1.2.3_checksums.txt.sig mycli_1.2.3_checksums.txt

Do not skip the fingerprint check. gpg --verify saying "Good signature" for a key you imported from the same page an attacker controls proves nothing. Compare the fingerprint with one you obtained through a second channel.

minisign

minisign is a small tool built around Ed25519 with a short public key you can paste into a README. There is no keyring and no web of trust, which is the appeal.

minisign -G                                  # creates minisign.pub and ~/.minisign/minisign.key
minisign -Sm mycli_1.2.3_checksums.txt       # writes mycli_1.2.3_checksums.txt.minisig
minisign -Vm mycli_1.2.3_checksums.txt -p minisign.pub

The public key is a single line starting with RW. Users paste it into the verify command, so there is nothing to import. It is available as a Debian package and through Homebrew. The cost is that it is one more tool for users to install, and GoReleaser has no built-in minisign mode, so you call it through the same signs block with cmd: minisign and arguments for -S, -s, -m and -x.

cosign

cosign comes from the Sigstore project and supports two ways of working. With a key pair it behaves like the tools above: cosign generate-key-pair, then cosign sign-blob --key cosign.key and cosign verify-blob --key cosign.pub --signature <file>.sig <file>. With keyless signing, the CI job proves its identity through OpenID Connect, receives a short-lived certificate, and the signing event is recorded in a public transparency log. There is no long-lived private key to steal.

A keyless verification names who is allowed to have signed it:

cosign verify-blob \
  --certificate-identity-regexp '^https://github.com/OWNER/mycli/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --signature mycli_1.2.3_checksums.txt.sig \
  --certificate mycli_1.2.3_checksums.txt.pem \
  mycli_1.2.3_checksums.txt

The identity and the issuer are the whole point: they say "signed by a workflow in this repository, run by GitHub Actions", which is a statement a key fingerprint cannot make. Cosign's output formats have been changing across major versions, with newer releases moving toward a single bundle file in place of separate signature and certificate files. Check the flags against the version your CI installs, and pin that version. Keyless fits projects that publish from a hosted CI; for a release cut on a laptop, a key-based tool is simpler.

GitHub also offers build provenance attestations, verified with gh attestation verify <file> --repo OWNER/REPO. That answers a slightly different question, which is which workflow built the file, and complements a signed checksums file instead of replacing it.

Comparing the options

Three ways to sign the checksums file

Side by side summary of GPG, minisign and cosign: what each is good at and what it asks of the user.GPGInstalled on nearly everyLinuxUsers import your key fileCheck the fingerprint froma second channelminisignOne-line public key, nokeyringUsers must install one moretoolCalled through the signsblock in GoReleasercosignKeyless mode needs nolong-lived keyVerify names identity andissuerPin the version, outputformats change
sha256 onlyGPGminisigncosign
Detects corruptionYesYesYesYes
Detects a swapped archiveYesYesYesYes
Detects archive plus checksums swappedNoYes, if key is trustedYes, if key is trustedYes, if identity or key is trusted
User needssha256sumgpg, your keyminisign, one linecosign
Key to look afterNoneYesYesNone with keyless, yes otherwise
Fits a laptop releaseYesYesYesKey mode
Fits CI-only releaseYesYes, with a secretYes, with a secretYes
Learning curve for usersLowMediumLowMedium

For a small project, our default recommendation is checksums for everyone and one signature scheme for people who want more. GPG is the one most security reviewers already have tooling for. minisign has the lowest friction. cosign keyless is attractive if all your releases come from GitHub Actions and your users already use Sigstore.

What an installer should do, in order

An installer chains the layers, and the order matters, because each step trusts what the previous one proved.

Installer verification order

Five ordered steps: https download, verify the signature on the checksums file, find exactly one hash line, compare the archive hash, then install atomically.1DownloadoverhttpsRedirectstoo2VerifysignatureBeforereadingany hash3One hashlineNone orseveral isa refusal4ComparearchivehashMismatchstops5AtomicinstallTemp name,thenrename
  1. Download over https only. Both the checksums file and the archive, with redirects restricted to https too.
  2. If a verification key or identity is configured, download the signature and verify the checksums file first. Refuse if the signature is missing, the key is wrong or the file was modified. Do this before reading any hash out of the file, because the hash is only as good as the file.
  3. Find exactly one line for the archive in the checksums file. Refuse on none or several.
  4. Hash the archive and compare. Refuse on mismatch.
  5. Extract only the expected binary and install it atomically. A copy to a temporary name and a rename, so an existing install is never half replaced.

Here is the signature step from our installer, which uses a throwaway keyring so the user's own GPG configuration is never touched:

GNUPGHOME="$TMP/gnupg"; export GNUPGHOME
mkdir -m 700 "$GNUPGHOME"
gpg --batch --quiet --import "$GPG_PUBKEY" 2>/dev/null || die "could not import $GPG_PUBKEY"
gpg --batch --quiet --trust-model always --verify "$TMP/$SUMS.sig" "$TMP/$SUMS" 2>/dev/null ||
  die "signature of $SUMS is NOT valid"

--trust-model always looks alarming but is correct here: the user supplied exactly one key file on purpose, so there is no trust path to evaluate. The key file is the trust decision.

The test that matters is a negative one. In our harness, a checksums file modified after signing, with the archive itself intact, installs without a configured key and is refused with one. That is the demonstration of what a signature adds: without the key, a modified checksums file passes.

Rule of thumb: verify the signature on the checksums file first, then the hash of the archive. A hash from an unverified file proves nothing.

Deploy scripts do the same on a VPS

The same chain fits a deploy script that fetches your own release. For a service you run, you do not need a full installer, only the essentials:

set -euo pipefail
v=1.4.2
base=https://github.com/OWNER/app/releases/download/v$v
cd "$(mktemp -d)"
curl -fsSLO --proto '=https' "$base/app_${v}_checksums.txt"
curl -fsSLO --proto '=https' "$base/app_${v}_checksums.txt.minisig"
minisign -Vm "app_${v}_checksums.txt" -P 'RWQ...your-public-key...'
curl -fsSLO --proto '=https' "$base/app_${v}_linux_amd64.tar.gz"
grep " app_${v}_linux_amd64.tar.gz\$" "app_${v}_checksums.txt" | sha256sum -c -

set -e aborts at the first failure. -P takes the public key on the command line, so the key lives in your deploy repository, not on the release page. Put the key string in version control and a compromise of the release page alone is not enough.

Mistakes we see

Verifying with a hash copied from the same page as the file. It guards against corruption only. Say that honestly in docs instead of calling it "secure".

Signing the archives but publishing no way to get the key safely. A public key on the release page is as replaceable as the archive. Publish the key, or its fingerprint, in the repository and on your site.

Treating "Good signature" as a full answer. With GPG, a good signature from an untrusted key is just a valid signature. Confirm the fingerprint.

Leaving the signing key on a developer laptop with no backup or rotation plan. Decide how the key is stored, who can use it, and how you will announce a replacement. For GPG, set an expiry as in the command above.

Not testing the failure path. A verifier that cannot say no is decoration. Modify a byte in each file and watch the installer refuse.

Forgetting that a signature does not prove the code is good. It proves the file is the one the key holder published. A malicious maintainer, or a tampered build, signs cleanly.

FAQ

Is SHA-256 on the download page enough?

It is enough to detect a corrupted or substituted single file. If the page itself can be changed by an attacker, it is not. A signature on the checksums file, verified with a key obtained elsewhere, is the usual upgrade, and for many small tools published over HTTPS the baseline is still reasonable.

Should I sign every archive or just the checksums file?

Just the checksums file. It contains the hash of each archive, so one signature covers all of them, and users verify once and then check the hash of the file they downloaded. It also keeps the release page uncluttered.

GPG, minisign or cosign for a new project?

If your users are Linux admins, GPG is expected. If you want the least friction and a one-line key, pick minisign. If every release comes from GitHub Actions and you would rather not manage a long-lived key, look at cosign keyless. Whatever you choose, document the exact verification command in the README.

Does verification protect me from a malicious dependency?

No. Verification tells you the file matches what the publisher released. It says nothing about what is inside. Review and pin dependencies, commit go.sum, and use the reproducible build check to confirm the release binary matches the source.

How do users get my public key safely?

From more than one place. Commit it to the repository, serve it from your own site, and put its fingerprint in the README. An attacker would need to change all of them at once, and a user who compares two sources is protected from the single-source swap.

The Go CLI Release Kit includes a GoReleaser configuration that produces the checksums file, an optional GPG signing block for it, and an installer that verifies the signature and the hash in the order described here, with tests for each refusal. It is at /products/go-release-kit/.

securityreleasego