Skip to content

Secure Releases: Checksum Verification

GTB's self-update flow verifies every downloaded binary against a GoReleaser-produced checksums.txt manifest before installing it. A tampered or truncated binary is rejected; a passing check is logged at INFO ("checksum verified") and the update proceeds.

This is Phase 1 of the release-integrity work from 0056-remote-update-checksum-verification. Phase 2 adds a GPG signature over the manifest, closing the same-origin trust gap (an attacker who can replace the binary on the release platform can also replace checksums.txt, only a signature from an off-platform key defeats that). Phase 2's code is implemented and dormant; see Phase 2 below.

How it fits together

Update() β†’  findReleaseAsset()           = target binary
         β†’  fetchChecksumsManifest()     = checksums.txt (via ChecksumProvider or asset list)
         β†’  VerifyChecksumFromManifest() = binary SHA-256 vs manifest entry
         β†’  extract()                    = only reached when verify succeeds

checksums.txt is GoReleaser's default manifest. One <hex-sha256> <filename> entry per line. If your .goreleaser.yaml uses the defaults, no changes are needed; the file is already attached to every release.

Producing verifiable releases

The default GoReleaser checksum block generates checksums.txt and attaches it to the release. No configuration change is required. Verify locally with:

just snapshot
ls dist/checksums.txt

The release runs the binary it built before publishing. The scaffolded .goreleaser.yaml (and gtb's own) carries a post-build hook that runs the freshly built artefact once, <tool> version --ci, on the target that matches the runner; the other targets pass through. A tool whose binary cannot start therefore fails the build, and nothing is published. This is deliberately the artefact GoReleaser built and not a go run of the package: the two are different builds (main path, tags, ldflags, CGO), and a release that built a single main.go instead of the package shipped green with a binary that died at start (go-tool-base #95). A tool that needs a build tag to start sets it in the build's flags, where the hook already sees it.

Manual / CI pipelines

If you don't use GoReleaser, produce the manifest with standard sha256sum output and upload it alongside the binaries. The file format is:

<64-hex-hash>  <filename>
<64-hex-hash>  <filename>

Blank lines at end-of-file are tolerated; every other line must match or the whole manifest is rejected (a truncated manifest never produces a false pass).

Bitbucket

Upload checksums.txt to the repository's Downloads alongside the binaries (same upload flow as your release assets). The Bitbucket provider looks it up by exact filename: not via the asset-name regex that the binary uses.

Direct HTTP releases

Set checksum_url_template in the direct configuration subtree to a URL template that expands to the manifest location:

direct:
  url_template: https://releases.example.com/{tool}/{version}/{tool}_{os}_{arch}.{ext}
  checksum_url_template: https://releases.example.com/{tool}/{version}/checksums.txt

The release source itself names only the connection:

props.Tool.ReleaseSource = props.ReleaseSource{Type: "direct"}

The same placeholders ({version}, {version_bare}, {os}, {arch}, {tool}, {ext}) are available.

Publishing on the static channel

A tool on the static release channel (spec 0203) reads no forge, so the release has to publish what the reader needs: a per-tag release.json beside the archives and a latest.json pointer at the base URL, moved last. The scaffolded .goreleaser.yaml of a project generated with --release-channel static carries the three pieces, in pipeline order:

  1. A before_publish hook (GoReleaser Pro, the one slot that runs after checksums and signatures exist and before anything is uploaded) runs go tool releasemanifest, which writes dist/release.json and dist/latest.json from metadata.json, checksums.txt and the archives themselves. The go.mod tool directive for it is scaffolded with the channel.
  2. blobs: uploads the tag's objects to an S3-compatible store, release.json among them, under <base URL path>/<tag>/.
  3. A publishers: entry, last in the publish pipe and skipped with it, runs scripts/move-pointer.sh: a conditional SigV4 curl that reads the pointer's ETag and PUTs the new one with If-Match (If-None-Match: * for a first release) and Cache-Control: no-cache, so two publishes cannot silently clobber each other.

The pipeline needs, as CI/CD variables: GORELEASER_KEY (the Pro licence; the scaffolded GitLab pipeline sets the goreleaser component's pro: true input), RELEASE_STORE_ENDPOINT and RELEASE_STORE_BUCKET (the store's S3 endpoint and bucket; the scaffolded pipeline maps the estate's onto them), and AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY for the store, which the estate's goreleaser component supplies. The public base URL must serve the bucket's path prefix. The signature over checksums.txt is produced exactly as above; the manifest names it and the reader verifies it the same way.

The layout, field by field, is the static release channel reference; a publisher without GoReleaser follows it directly.

Consuming (tool author)

Pick a failure mode

By default, a release without checksums.txt logs a warning and the update proceeds. This preserves backward compatibility for tools whose existing releases predate this feature. Once your tool has shipped at least one release with a manifest, flip the default to fail-closed:

requireChecksum := true

props := &props.Props{
    Tool: props.Tool{
        Signing: props.SigningConfig{
            RequireChecksum: &requireChecksum,
        },
    },
}

It is a pointer so "unset" stays distinguishable from "explicitly false": nil leaves the framework default (permissive).

Overriding at runtime

End users can override via config file:

update:
  require_checksum: true          # abort if manifest missing or verification fails
  checksum_asset_name: ""         # override default "checksums.txt" filename

…or via env var (respects the tool's configured env prefix):

export MYTOOL_UPDATE_REQUIRE_CHECKSUM=true

Config wins over env var; env var wins over the tool-author baseline in props.Tool.Signing.RequireChecksum.

GTB itself sets Signing.RequireChecksum: every gtb update verifies. Override with GTB_UPDATE_REQUIRE_CHECKSUM=false or update.require_checksum: false in config only if you need to update across a legacy release that predates the manifest (all GoReleaser-built releases have it, so this should rarely apply).

Size bounds

The manifest download is capped at setup.DefaultMaxChecksumsSize (1 MiB); the binary download at setup.DefaultMaxBinaryDownloadSize (512 MiB). A hostile server streaming beyond those bounds aborts with ErrChecksumTooLarge.

Those are constants, not package variables (the previous mutable globals raced under t.Parallel() and scoped a bound to the whole process). No tool has needed a different bound; one that ships larger artefacts asks for a props.Tool field rather than a per-updater option.

Phase 2: GPG-signed manifests

Phase 1 defends against accidental corruption and single-asset tampering, but a full VCS compromise can replace both the binary and checksums.txt on the release. Phase 2 closes that gap by signing the manifest with a project-controlled GPG key. An attacker who replaces the files on the VCS still cannot produce a valid checksums.txt.sig without access to the private key.

Verifier API extracted into the signing module

The verification primitives, TrustSet, the KeyResolver chain (embedded, WKD, composite), LoadTrustSet, the minimum-strength policy, and the DefaultRequireSignature / DefaultKeySource / DefaultExternalKeyEmail / DefaultRequireExternalCrosscheck variables: now live in the standalone signing module at gitlab.com/phpboyscout/go/signing/verify (v0.1.0). go-tool-base's SelfUpdater (still in pkg/setup) consumes them, injecting an *slog.Logger and a hardened *http.Client. Where the snippets below show these symbols with a setup. prefix, read them as verify.; setup.NewUpdater remains in pkg/setup, and DefaultRequireChecksum (Phase 1) stays there too. See the Signature Verification component reference and the signing module docs.

Status: the rollout is complete for gtb itself. Every release carries a detached checksums.txt.sig alongside the manifest, produced by the GoReleaser signs block in .goreleaser.yaml, and cli/pkg/cmd/root/signing.go sets verify.DefaultRequireSignature = true, so gtb update refuses an unsigned release rather than warning about one.

For a tool you build on GTB, the default is still off. The framework ships verify.DefaultRequireSignature = false deliberately: a tool that demanded a signature before its users had received an embedded key in a prior release would lock those users out of the very update that fixes it. Flip it in your own main() once a signed release is out and a key has shipped ahead of it. The Phase 2 Signing Prep checklist has the N+1 / N+2 / N+3 ordering, and the Signature Verification component has the verifier API.

Producing signed releases

GoReleaser signs checksums.txt via a signs block that shells out to scripts/sign-release.sh, producing an ASCII-armored detached checksums.txt.sig (the exact shape TrustSet.VerifyManifestSignature expects):

# .goreleaser.yaml
signs:
  - id: checksums
    cmd: scripts/sign-release.sh
    artifacts: checksum
    signature: "${artifact}.sig"
    args: ["${artifact}", "${signature}"]
    output: true

scripts/sign-release.sh does not touch a local gpg keyring. It shells out to gtb sign --backend aws-kms, so the private half never leaves KMS and nothing on the runner can export it. Three variables select the key, all with defaults:

Variable Default What it is
GTB_SIGNING_KEY_ID alias/gtb-release-signing-v2 the KMS key alias to sign with
GTB_SIGNING_KEY_PUBLIC cli/pkg/trustkeys/keys/signing-key-v2.asc the public half, which must be present in the working tree
AWS_REGION eu-west-2 where the key lives

There is no signing gate, and the script fails closed. If neither AWS_ACCESS_KEY_ID nor AWS_WEB_IDENTITY_TOKEN_FILE is set, sign-release.sh refuses to sign and exits non-zero rather than quietly producing an unsigned release. A tag pipeline that cannot reach KMS fails; it does not degrade.

CI resolves credentials through OIDC web identity rather than a stored secret. GitLab injects a token with aud: sts.amazonaws.com, the job writes it to a file, and the AWS SDK assumes the signer role. That role's trust policy pins it to project_path:phpboyscout/go-tool-base:ref_type:tag:ref:v*, so a leaked role ARN still only lets this project's tag pipelines sign anything.

One signer, two trusted keys. The 2026-07-24 rotation moved signing to alias/gtb-release-signing-v2 in a new AWS account; releases were signed with both keys until every supported binary trusted v2, and the old account was closed on 2026-09-15, which destroyed the v1 private key. The v1 public key stays in cli/pkg/trustkeys/keys/ so releases signed before then keep verifying: verification is offline and never needs the private half. Nothing in the pipeline references v1 any more.

The sign→verify contract. That a signature gtb sign produces is accepted by the same trust set self-update enforces, is covered by TestSignVerifyContract_* in cli/pkg/cmd/sign. Those tests sign a manifest through the real runSign path, verify it via verify.LoadTrustSet, and assert that both a tampered manifest and an untrusted signing key are rejected with ErrSignatureInvalid. They need no credentials and run in the normal unit suite.

Coverage gap: the KMS path in scripts/sign-release.sh

The script itself is not exercised by any test. It is KMS-only, it shells out to gtb sign --backend aws-kms and refuses to run without AWS credentials, so covering it requires a real KMS key and OIDC-derived credentials.

The previous end-to-end test (TestSignReleaseScript_VerifiesViaTrustSet, gated INT_TEST_SIGNING=1) drove the script with gpg and was lost when signing moved to go/signing. It cannot simply be restored: the gpg path it depended on no longer exists.

What remains untested is therefore the script's argument wiring and its KMS round-trip, not the cryptographic contract above.

Customised .goreleaser.yaml and .gtb/ignore

gtb enable signing --key-id … (and gtb disable signing) adds or removes the top-level signs: block in your .goreleaser.yaml. It does this without re-rendering the whole file, so a hand-customised release config, extra builds, app_bundles:, dmg:, deliberate platform exclusions, is preserved. The precedence is:

  1. Listed in .gtb/ignore β†’ the file is never written (this takes precedence over the command's overwrite mode). The command prints the exact signs: block to paste and continues with the rest of enable signing.
  2. Present and safely editable β†’ only the top-level signs: key is injected (enable) or removed (disable); every other block, comment, and scalar is left byte-for-byte intact. Disable removes only a block gtb itself wrote (identified by its # Release signing: gtb enable signing wired this. marker); an author-written signs: block is never touched.
  3. A signs: block already exists, the file is unparseable, or absent-then- customised β†’ the file is not modified; the command prints the block to paste (advisory) and still scaffolds internal/trustkeys, wires the root command, and updates the manifest.

Because the release-config edit can degrade to an advisory, always check the command output: it states clearly when .goreleaser.yaml was not modified and manual action is required. Only a brand-new (absent) .goreleaser.yaml is rendered from the full skeleton.

Trust model at a glance

A signature is only as trustworthy as the key used to verify it. Phase 2 uses a composite trust set: the verifier loads public keys from two independent sources and requires their fingerprints to agree before accepting a signature.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  embedded in binary β”‚      β”‚   external: Web Key Directory β”‚
β”‚  (//go:embed)       β”‚      β”‚   or custom HTTPS endpoint    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚                                β”‚
           └──────────►  CompositeResolver β—„β”˜
                             fingerprints must match
                                     β”‚
                                     β–Ό
                              TrustSet ──► verify(checksums.txt.sig)
  • Embedded key: baked into each binary at build time via //go:embed. Works offline and in air-gapped environments. Rotates only when a new binary is shipped.
  • External key (third-party source): fetched from an HTTPS endpoint under a domain you control. For a VCS compromise to produce a valid signature, the attacker must also control your DNS and TLS termination; the two trust anchors are administered independently. The canonical implementation is Web Key Directory (WKD), an OpenPGP RFC-draft serving public keys from a well-known path. Other HTTPS endpoints (self-hosted, Vault, a static S3 bucket) are supported via a custom KeyResolver.

Resolver implementations

// Interface β€” implement this to plug in any key source.
type KeyResolver interface {
    Resolve(ctx context.Context) (*TrustSet, error)
}

Three ship with GTB:

Resolver Source Offline? Primary use
setup.NewEmbeddedResolver(...) //go:embed of *.asc files in internal/trustkeys/keys/ βœ… Yes Always available; the fallback that keeps air-gapped updates working.
setup.NewWKDResolver(cfg) https://openpgpkey.<domain>/.well-known/openpgpkey/<domain>/hu/<z-base-32>?l=<email> ❌ No The project's public key published via the GPG WKD standard; cross-checks the embedded copy.
setup.CompositeResolver{Resolvers: []KeyResolver{embedded, wkd}} Both, with fingerprint-equality enforcement ⚠️ Partial The production default. Offline builds still work via update.key_source=embedded.

Configuration surface

update:
  require_signature: false               # library default; flip on via DefaultRequireSignature
  key_source: both                       # "embedded" | "external" | "both"
  external_key_email: [email protected]  # drives the WKD URL
  require_external_crosscheck: false     # true β†’ WKD failure aborts update
  signature_asset_name: ""               # override default "checksums.txt.sig"

Compile-time overrides (tool authors in main), set on the gitlab.com/phpboyscout/go/signing/verify package:

verify.DefaultRequireSignature = true
verify.DefaultKeySource = "both"
verify.DefaultExternalKeyEmail = "[email protected]"
verify.DefaultRequireExternalCrosscheck = true

Publishing a public key

  1. Generate an Ed25519 signing keypair (RSA-4096 is acceptable if your KMS doesn't support Ed25519). DSA, 1024-bit RSA, and weak curves are refused at load time.
  2. Embed the public half. Drop the ASCII-armored file at internal/trustkeys/keys/signing-key-v1.asc in your repo: go:embed picks it up at build time. Tests gate a CI check that refuses any accidentally committed private key.
  3. Publish the same key via your chosen external source:
  4. WKD: serve the ASCII-armored key at the WKD path under openpgpkey.<yourdomain>. DNS and TLS cert are your trust anchors, administered independently from your VCS.
  5. Anything else (Vault, static S3, an internal CA-served endpoint) is not wired into the updater: the resolver chain is built from update.key_source (embedded, external over WKD, or both). A tool needing another source asks for it as a props.Tool.Signing field.
  6. Store the private half in a KMS (AWS/GCP/Azure), Vault Transit, or a hardware token. GitHub encrypted secrets are a last resort. See the spec's Key Management section.

Diagnosing live updates from logs

Every gtb update (or your tool's equivalent) emits structured log lines that name the concrete resolver used. The first is at debug level, because the updater is built on every invocation and the line would otherwise appear under version and --help too, so run with --debug to see it:

DEBUG update signature verification configured resolver=composite[embedded,wkd:openpgpkey.<yourdomain>]
INFO signature verified resolver=composite[embedded,wkd:openpgpkey.<yourdomain>]

The resolver= value is the most useful single field for support triage. composite[embedded,wkd:…] means both trust anchors were consulted and agreed; embedded or wkd:… alone means only one anchor was consulted (cryptographically sound but lower defence-in-depth). Full interpretation table (including failure-side log shapes for active-tampering signals) lives in the Signature Verification component reference.

Custom resolvers (third-party key source)

The updater builds its resolver chain itself, from the tool's embedded keys (props.Tool.Signing.EmbeddedKeys) and the update.key_source family, through verify.BuildKeyResolver. There is no hook for a tool to hand it a resolver of its own: the option that did that was a test seam in practice and was removed (spec 0198 D4). A tool that needs a further key source (Vault, an internal endpoint) asks for it as a props.Tool.Signing field, so that the same rules below apply to it as to the built-in sources:

  • Return a *TrustSet containing only keys that passed the minimum-strength policy.
  • Honour the context's deadline and cancellation.
  • Cap response bodies at verify.MaxWKDResponseSize (64 KiB) or an equivalent bound.
  • Not leak private material anywhere.

Key rotation

The trust set is a set, not a single key. During a rotation window, ship releases signed by both the old and new key; the verifier accepts either. Once all supported versions of the tool include the new key in their trust set, the old key can stop signing. Keep its public half in the embedded trustkeys directory for as long as anything signed by it should still verify, and never edit a WKD entry a shipped binary pins: the verifier requires the embedded set and the WKD set for its compiled-in identity to agree exactly, so a new trust set gets a new WKD identity and the old entry stays frozen (see infra spec 0012 D2 for the 2026 rotation that established this).

For emergency rotation (compromise of the primary signing key), the design reserves a second "rotation-authority" key whose private half is stored offline. It authorises a change to the trust set and never signs a release: put it in props.Tool.Signing.AnchorOnlyKeys, not EmbeddedKeys, so a manifest it signs is refused. The emergency rotation flow itself is not built yet (spec 0101).


Testing

Run the Phase 1 tests:

go test ./pkg/setup/ -run "TestVerifyChecksum|TestVerifyAssetChecksum|TestFindChecksumsAsset"

…and the manifest fuzzer:

go test ./pkg/setup/ -run "^$" -fuzz=FuzzParseChecksumManifest -fuzztime=30s