Featured image of post Sign your own binaries with go-tool-base, part 5: embed the key and require verification

Sign your own binaries with go-tool-base, part 5: embed the key and require verification

By now you’ve got a public key your tool can publish off-platform: minted from a KMS-held private key in Part 4 and served over WKD. That’s half the trust loop. The other half lives inside the binary itself: the tool has to hold a copy of the key it expects so that when an update lands, it can check the signature against something an attacker who owns the release page can’t quietly swap. This part bakes that trust anchor in, wires the self-updater to use it, and turns enforcement on without locking out the people who already have your tool installed.

That last clause is the one that bites, so we’ll come to it slowly. First, turning signing on.

Enable signing with one command

Your root command is generated by gtb, so you don’t wire signing in by hand-editing it. You turn the feature on and let the generator do the wiring:

gtb enable signing --email release@acme.dev

That one command does three things, all in generated, regenerable code you never touch:

  • scaffolds an internal/trustkeys package that //go:embeds your release keys and hands them to the self-updater;
  • wires Signing: props.SigningConfig{EmbeddedKeys: trustkeys.Keys()} into your generated root command;
  • writes a signing.go holding the enforcement defaults, generated from a signing block it adds to your .gtb/manifest.yaml. You change posture by re-running the command, never by editing the file.

Enabling signing on a generated project with gtb enable signing

Signing is off until you run this, on purpose: it needs a key and a published WKD endpoint, so a freshly generated tool doesn’t carry it uninvited. (If you’re curious what the embed package looks like, it’s a small //go:embed all:keys over an internal/trustkeys/keys/ directory with a Keys() [][]byte accessor. The all: prefix is load-bearing: a plain //go:embed keys won’t compile over a directory that holds only a dotfile, so the scaffold keeps a .gitkeep there. You don’t write any of it.)

Drop your key in

The scaffold gives you an empty internal/trustkeys/keys/. Put the public key you minted in Part 4 into it, alongside the break-glass key:

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

The -v1 in the filename isn’t decoration: you’ll rotate one day and embed signing-key-v2.asc alongside it for a release or two (Part 7). The rotation-authority key rides along the same way. Rebuild, and trustkeys.Keys() now returns them. With no .asc present it returns nothing and verification stays dormant, so enabling signing before you have a key breaks nothing.

The WKD cross-check, via --email

The --email you passed is doing real work. The embedded key alone is a static anchor (how the verification works): it only ever says “this was the key on the day I was built.” Pairing it with the live WKD copy you published in Part 4 gives a second, independent source the release platform can’t reach. The default key source is both, an embedded-plus-WKD CompositeResolver, and the email is what lets the updater derive the WKD URL. Leave --email off and both quietly degrades to embedded-only.

For a locked-down tool that should refuse to update when it can’t reach WKD, rather than fall back to the embedded key, enable the strict cross-check:

gtb enable signing --email release@acme.dev --require-external-crosscheck

Most tools want the softer default, where WKD strengthens verification when it’s reachable and the embedded key still works when it isn’t.

Confirm the cross-check is actually firing

It’s easy to get this wrong by leaving the email out, and when you do, nothing complains: the updater quietly verifies against the embedded key alone and carries on. The way to know which anchors were actually consulted is to read the log. Every update prints a line naming the resolver it used:

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

That resolver= field is the whole tell:

  • composite[embedded,wkd:...] is what you want: both the embedded key and the WKD-served key were fetched, their fingerprints agreed, and the signature checked against the result. The cross-check is live.
  • embedded means only the baked-in key was used and WKD was never consulted. That’s the silent-degrade trap: key_source is "both", but with no external email there was no WKD URL to derive, so it fell back to a single anchor. If you see this after passing --email, the value didn’t take.
  • wkd:... on its own is the reverse: WKD was consulted but nothing was embedded.

There’s a matching update signature verification configured resolver=... line at the very start of an update, before any network call, if you’d rather see the choice before the fetch. Two failure shapes are worth recognising too. WARN composite resolver failed (RequireAll=false, continuing) means the WKD fetch fell over (a 404, a flaky network) and the update carried on against the embedded key alone, the soft default you can harden with --require-external-crosscheck. ERROR ErrKeyResolverMismatch is the one you want to see fire in anger: the embedded and WKD keys disagreed, which is exactly the tamper alarm the whole scheme exists to raise (the same mismatch the verification deep-dive walks through).

Require it, in the right order

Here’s the part everyone trips over. Enabling signing does not yet 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.

Think about what an existing install holds. A user on an old version has a binary built before you enabled signing. It has no trust anchor. If the first thing it ever sees is a signed, signature-required release, it has nothing to check the signature against, and the update is refused. You’ve locked out exactly the people you were protecting.

The fix is to ship the key ahead of the requirement, so the anchor is already on the user’s machine by the time the first mandatory signature arrives. gtb follows the rollout in its own docs/development/phase2-signing-prep.md, across three releases:

Ship the key before you require the signature, or you lock out every install that predates it. In order:

  • Release N+1. Enable signing (above) and ship it, with require_signature off. Existing installs pull this update on checksum alone and pick up the embedded key as a side effect.
  • Release N+2. Ship your first signed release (Part 6 wires GoReleaser). Still not required: the signature is verified when present but not enforced, so nothing breaks.
  • Release N+3. Now, and only now, turn enforcement on.

When you reach N+3, it’s one command, not a code edit:

gtb enable signing --require-signature

That flips require_signature in the manifest and regenerates signing.go, so the change is tracked and reproducible. Skipping the middle release, requiring a signature before you’ve shipped one, is the exact mistake the ordering exists to prevent; gtb’s own rollout cites v0.12.2 as its first signed release for precisely this reason.

The checksum floor from Phase 1 sits underneath all this and is already on; signature verification adds to it, it doesn’t replace it. And even with signatures required, an end user genuinely stuck on a legacy release can escape with the update.require_signature config key or your tool’s <PREFIX>_UPDATE_REQUIRE_SIGNATURE=false. It’s an escape hatch, not the front door, but it means a requirement you turn on can’t permanently strand anyone.

Where this leaves you

Your binary now carries the key it expects, checks every update against that key and its live WKD twin, and refuses anything that doesn’t match, all without a gpg install on the user’s side and without stranding the installs that came before the key. The trust loop you built by hand in Part 1 now runs on its own, inside a stranger’s copy of your tool.

The one thing still missing is the signature itself on each release. Right now nothing is actually producing checksums.txt.sig. Part 6 wires the KMS signing from Parts 2 and 3 into a real GoReleaser pipeline, so every tagged release comes out signed without you touching a key.

Built with Hugo
Theme Stack designed by Jimmy