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
mytoolfrom Build your first CLI, with theupdatefeature on (it is by default). signing-key-v1.ascandrotation-authority.ascfrom 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.ascfile in itskeys/directory and returns them fromtrustkeys.Keys().pkg/cmd/root/cmd.gogainsSigning: props.SigningConfig{EmbeddedKeys: trustkeys.Keys()}, which hands the keys to the self-updater.pkg/cmd/root/signing.goholds the enforcement defaults, generated from a newsigningblock 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:
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:
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-crosscheckmakes 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:
- First, ship the key. Release with signing enabled (above) and
require_signatureoff. Existing installs take this update on its checksum alone, and pick up the embedded key as they do. - 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.
- Only then require it:
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.
What to read next¶
- Ship self-updates: the update flow this check sits inside.
- Release-binary signing: the resolvers, the cross-check and what each failure means.
- Secure releases: checksum and signature verification settings, and the rollout.