Featured image of post Sign your own binaries with go-tool-base, part 6: sign every release with GoReleaser

Sign your own binaries with go-tool-base, part 6: sign every release with GoReleaser

By now you’ve got all the pieces lying on the bench. A KMS key that signs but never hands over its private half (Part 2). A CI role you can assume over OIDC with no stored credentials (Part 3). A public key you’ve minted and published (Part 4), and embedded in the binary (Part 5). What you don’t have yet is the bit that makes it routine: a release that signs itself, every time, without you remembering to do anything. That’s this part. We wire signing into the tagged-release pipeline so that pushing a v* tag is the whole ceremony.

This is the part of the signing series where the chain stops being a set of commands you run by hand and becomes something the pipeline does for you. We’re using GoReleaser, which already builds your binaries, writes a checksums file and cuts the release. It needs one extra trick: sign the checksums on the way out, through the KMS key, using credentials that only exist for the length of the job. And you don’t hand-write that trick into your release config. gtb does.

You’ll need a working GoReleaser setup releasing a go-tool-base CLI, plus the KMS key, the signer role and the embedded public key from the earlier parts.

One command wires the signing in

In Part 5 you ran gtb enable signing to turn on the verifying side: embed the key, check every update against it. Now you give that same command the key the release pipeline should sign with, and it wires the producing side too:

gtb enable signing --key-id alias/acme-release-signing-v1

That records the KMS key in your .gtb/manifest.yaml and regenerates .goreleaser.yaml with a signs: block that calls gtb sign. The WKD email and everything else you set in Part 5 stay exactly as they were; you’re adding the key, not starting over. Because the block is generated, you don’t hand-edit the release config any more than you hand-edit the embed wiring. Change the key later (a new region, a rotated alias) and you re-run the command, not the YAML.

The generated block looks like this:

signs:
  - id: checksums
    cmd: gtb
    args:
      - "--ci"
      - "sign"
      - "--backend"
      - "aws-kms"
      - "--kms-region"
      - "eu-west-2"
      - "--key-id"
      - "alias/acme-release-signing-v1"
      - "--public-key"
      - "internal/trustkeys/keys/signing-key-v1.asc"
      - "--output"
      - "${signature}"
      - "${artifact}"
    artifacts: checksum
    signature: "${artifact}.sig"
    output: true

The region defaults to eu-west-2 and the public key to the internal/trustkeys/keys/signing-key-v1.asc you embedded in Part 5; pass --kms-region or --public-key if yours differ. The backend defaults to aws-kms, which is the one that matters in CI.

Why only the checksums

artifacts: checksum is the line doing the load-bearing work. It tells GoReleaser to run the signing command once, over the checksums manifest only, not over every binary and archive in the release.

Here’s why that’s enough. GoReleaser builds your binaries, then writes a checksums.txt listing the SHA-256 of each one. Every artefact in the release is named in that file by its hash. So if you sign the manifest, you’ve transitively vouched for everything it lists: change a single byte of any binary and its hash no longer matches the line in checksums.txt, and the moment you alter checksums.txt to cover for that, the signature over it breaks. One signature, the entire release covered, through the hash chain. The per-binary build stays completely untouched, which keeps reproducible builds reproducible. signature: "${artifact}.sig" names the output checksums.txt.sig, the detached, ASCII-armored OpenPGP signature your tool looks for on every self-update.

No shim, just gtb

If you go reading go-tool-base’s own release config, you’ll find its signs: block points at a scripts/sign-release.sh shim rather than calling gtb directly. Yours doesn’t, and the difference is worth understanding.

go-tool-base is signing the very binary it’s in the middle of building, so it can’t use an installed gtb to do it. Its shim runs go run ./cmd/gtb to build a throwaway signer from source. It also reads the key id, public key and region from environment variables, because the one config has to serve every build. Your tool has neither problem. It calls the gtb you already installed, and the generator already knows your key id, region and public-key path because you just told it. So there’s nothing for a shim to abstract: the whole invocation goes straight into args:, where you can read it.

The one thing you do still need is gtb on the release runner’s PATH. Install it in a before_script, or bake it into your CI image, the same way you would any other release tool.

Where the credentials come from

gtb sign asks KMS to sign. KMS will only oblige if the caller has credentials, and the whole point of keyless CI is that there are no stored credentials to leak. So they’re minted on the fly.

On GitLab, the release job declares an id_tokens: block. GitLab injects a short-lived OIDC token (a JWT) into the job, the before_script writes it to a file, and the AWS SDK’s default credential chain picks it up from there. No aws CLI call, no assume-role-with-web-identity you write yourself: set AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE and the SDK does the web-identity exchange the first time gtb sign touches KMS:

goreleaser:
  id_tokens:
    AWS_WEB_IDENTITY_TOKEN:
      aud: sts.amazonaws.com
  variables:
    AWS_REGION: eu-west-2
    AWS_ROLE_ARN: arn:aws:iam::…:role/acme-release-signing-v1-signer
    AWS_WEB_IDENTITY_TOKEN_FILE: /tmp/oidc-token
  before_script:
    - echo "$AWS_WEB_IDENTITY_TOKEN" > "$AWS_WEB_IDENTITY_TOKEN_FILE"

The key id and region live in the generated signs: block now, not here, so CI only supplies the AWS credentials. AWS_REGION still earns its place: the --kms-region flag points the KMS client at the key, while the SDK uses AWS_REGION for the STS exchange that mints the credentials in the first place. The aud has to match the audience the signer role’s trust policy expects (Part 3 set this up; for the OIDC provider go-tool-base uses, that’s sts.amazonaws.com). On GitHub the moving parts are the same, you just let aws-actions/configure-aws-credentials do the token-to-credentials dance instead of writing the file yourself.

Those credentials are scoped tight. The signer role’s trust policy pins it to this project’s tag pipelines, so even if the role ARN leaked, nothing but a release tag on your repo can assume it.

Don’t sign when there’s nothing to sign with

A local goreleaser release --snapshot, or a CI run that isn’t a release, has no OIDC token and no business reaching for KMS. GoReleaser is told to skip the whole signing step in that case: the release job runs with --skip=sign unless the web-identity token is present. So a non-release build never so much as looks at KMS, which is exactly what you want when you’re iterating on your laptop. The signing only fires on the real thing.

Cut a release

With all of that in place, releasing is one push:

git tag v1.4.0
git push origin v1.4.0

The tag pipeline fires. GoReleaser builds the binaries, writes checksums.txt, calls gtb sign, gtb asks KMS to sign over OIDC, and checksums.txt.sig lands next to the manifest. Both get attached to the release. Nobody typed a signing command and no private key was anywhere near the runner.

Wiring the release signing with gtb enable signing --key-id

A two-person gate, if you want one

There’s a window worth thinking about: a compromised CI runner during a release could, in principle, ride the OIDC credentials to get one malicious thing signed. You can shut that window with an approval gate in front of the signing job. On GitLab, a protected environment with a required approval (or a manual job) makes the release wait for a second pair of eyes; on GitHub, “required reviewers” on the release environment does the same. It’s optional, and it adds friction to every release, so weigh it against how exposed your runners are. For a lot of projects the OIDC scoping alone is enough; for anything where a forged release would be a genuine incident, the gate is cheap insurance.

Where this leaves you

Every release you cut from here on carries a signature made by a key you control and verifiable by anyone, and you got there by pushing a tag. That closes the loop the series has been building toward: the production side now actually produces signed releases.

Which means you can finally pull the trigger on the bit Part 5 left primed. We embedded the public key and left enforcement off, because turning it on before you ship signatures would brick every update. Now that signatures are shipping, Part 7 deals with the part everyone skips and nobody can afford to: rotating the key, and getting yourself out of trouble if one ever goes bad, without locking your users out.

Built with Hugo
Theme Stack designed by Jimmy