Skip to content

Require signed updates

By the end of this, your tool carries a copy of your release key, checks every self-update's signature against it and against the copy you published on WKD, and, once you've shipped a signed release, refuses any update that doesn't verify. Users don't need gpg installed for any of it.

This is the fifth part of the signing series. Publish your public key put your key on WKD. This part builds the other half of the check into the binary.

Allow about twenty minutes, plus three releases' worth of patience for the last step, which explains why.

Before you start

  • A tool generated by gtb, such as mytool from Build your first CLI, with the update feature on (it is by default).
  • signing-key-v1.asc and rotation-authority.asc from Publish your public key, and the WKD tree served for your domain.
  • The same gtb release. Everything below is verified against v0.50.0.

Turn on signing

Your root command is generated, so you don't wire signing in by hand. Turn the capability on and the generator does the wiring:

gtb enable signing --email [email protected]
INFO  Signing enabled.
INFO  Drop your minted public key(s) into internal/trustkeys/keys/*.asc, then commit.
INFO  require_signature stays off until a signed release has shipped — re-run with --require-signature to flip it.

It changes only generated files:

  • internal/trustkeys/, a small package that embeds every .asc file in its keys/ directory and returns them from trustkeys.Keys().
  • pkg/cmd/root/cmd.go gains Signing: props.SigningConfig{EmbeddedKeys: trustkeys.Keys()}, which hands the keys to the self-updater.
  • pkg/cmd/root/signing.go holds the enforcement defaults, generated from a new signing block in .gtb/manifest.yaml:
signing:
  enabled: true
  external_key_email: [email protected]

Change the posture by re-running gtb enable signing with different flags, never by editing signing.go. The next regeneration would overwrite it.

Signing is off in a new project on purpose: it needs a key and a WKD endpoint, so the generator doesn't switch it on uninvited.

Add your keys

The keys/ directory starts empty. Copy in the public key you minted and the rotation authority:

cp signing-key-v1.asc internal/trustkeys/keys/
cp rotation-authority.asc internal/trustkeys/keys/
go build ./...

Keep the -v1 in the filename. When you rotate, signing-key-v2.asc sits beside it for a release or two.

With no .asc file present, Keys() returns nothing and verification stays dormant. So enabling signing before you have a key breaks nothing.

One limit to know about. Every key in keys/ is trusted to sign a release, the rotation authority included, so a release signed with it would verify. gtb's own binary marks that key anchor-only, which keeps it in the trust set but refuses it as a signer. A generated tool can't do that yet: #155. Until it can, the protection is that the rotation authority's private half stays offline.

Cross-check against WKD

The --email you passed matters. The embedded key on its own only says "this was the key on the day I was built". The copy on WKD is a second source that your release platform can't reach. The default key source is both: the updater resolves the embedded keys and the WKD keys, and refuses to continue if their fingerprints disagree. The email is how it finds the WKD URL. Leave --email off and both quietly falls back to the embedded key alone.

By default, if WKD can't be reached (a 404, a flaky network), the update carries on against the embedded key. For a locked-down tool that should refuse instead, turn on the strict cross-check:

gtb enable signing --email [email protected] --require-external-crosscheck

Most tools want the default: WKD strengthens the check when it's reachable, and the embedded key still works when it isn't.

Confirm the cross-check is firing

Leave the email out and nothing complains. The updater verifies against the embedded key and carries on. To see which anchors were used, read the log line every verified update prints:

INFO  signature verified resolver=composite[embedded,wkd:openpgpkey.acme.dev] fingerprint=...

The resolver= field tells you:

  • composite[embedded,wkd:...]: both keys were fetched, their fingerprints agreed, and the signature checked out. This is what you want.
  • embedded: only the baked-in key was used. WKD was never asked, usually because no email was set. If you see this after passing --email, the value didn't take.
  • wkd:... alone: WKD was asked but nothing was embedded.

The fingerprint is the key that authorised this update, for the audit trail.

Run the update with --debug to see the resolver chosen before any network call:

DEBUG update signature verification configured resolver=composite[embedded,wkd:openpgpkey.acme.dev]

Two failures are worth recognising:

  • WARN composite resolver failed (RequireAll=false, continuing): the WKD fetch failed and the update fell back to the embedded key. That's the soft default; --require-external-crosscheck makes it fatal.
  • ErrKeyResolverMismatch: the embedded and WKD keys disagree. This one is always fatal, and it's the tamper alarm the whole scheme exists to raise.

Require signatures, in the right order

Enabling signing doesn't require it. require_signature stays off, and that's deliberate: turning it on too early breaks self-update for everyone who already has your tool.

A user on an older version has a binary built before you enabled signing. It has no embedded key. If the first update it sees requires a signature, it has nothing to check the signature against, so it refuses. You'd lock out exactly the people you set out to protect.

So ship the key before you require the signature, across three releases:

  1. First, ship the key. Release with signing enabled (above) and require_signature off. Existing installs take this update on its checksum alone, and pick up the embedded key as they do.
  2. Then ship a signed release. The GoReleaser part of the series sets this up. The signature is checked when it's present but not yet required, so nothing breaks.
  3. Only then require it:
gtb enable signing --require-signature

That sets require_signature: true in the manifest and regenerates signing.go, so the change is tracked and reproducible. Skipping the middle release, requiring a signature before any release has carried one, is the mistake the order exists to prevent.

Signature checks sit on top of the checksum check, which is already on; they don't replace it. And a user genuinely stuck on an old release can still get out: setting update.require_signature: false in their config, or MYTOOL_UPDATE_REQUIRE_SIGNATURE=false with your tool's prefix, turns the requirement off for them. It's an escape hatch, not the front door, but it means turning enforcement on can't strand anyone for good.

Where this leaves you

Your binary carries the key it expects, checks every update against it and its WKD twin, and once enforcement is on refuses anything that doesn't verify. The check you did by hand with gpg in the first part now runs by itself on every user's machine.

What's missing is the signature on each release: nothing produces checksums.txt.sig yet. The next part wires KMS signing into GoReleaser.