Generator signing support — gtb enable signing, scaffold internal/trustkeys, wire props.Signing¶
- Authors
- Matt Cockayne
- Date
- 10 June 2026
- Status
- IMPLEMENTED (status corrected 2026-06-15 — shipped via
internal/cmd/enable/signing.go,internal/cmd/disable/disable.go,internal/generator/signing.go,ManifestSigningininternal/generator/manifest.go, theinternal/generator/templates/skeleton_trustkeys.goscaffold +Signing:wiring inskeleton_root.go, and thesigning_*_testsuites)
Summary¶
Phase 2 self-update signature verification reads its embedded trust anchor from
props.Tool.Signing.EmbeddedKeys (pkg/props/tool.go), populated by an
internal/trustkeys package that //go:embeds the release public key(s). gtb
itself wires this by hand in internal/cmd/root/root.go and keeps its enforcement
defaults in a hand-written internal/cmd/root/signing.go, deliberately separate from
root.go "so the scaffolding generator (which templates root.go) doesn't ship these
values into downstream tools that have their own signing posture."
A generated tool gets none of this, and the only way to add it today is to hand-edit
the generated root command and hand-create the internal/trustkeys package, which is
exactly the regen-owned code the generator exists to keep people out of.
This spec adds first-class generator support. Signing is disabled by default —
turning it on is a deliberate gtb enable signing (or a --signing flag / interactive
prompt on generate project). Once enabled, the package, the Signing: wiring, and a
manifest-driven signing.go are scaffolded and regenerated like any other generated
code, never hand-edited. This is the framework half of the "Sign your own binaries"
tutorial's embed-and-verify step; the tutorial is blocked on it.
Motivation¶
The "Sign your own binaries with go-tool-base" tutorial reached the step where a
consumer embeds their minted public key and turns verification on, and the only way to
express it was "edit the props.Props your generator produced." That is wrong: the
root command is templated by internal/generator/templates/skeleton_root.go and is
subject to regeneration and hash-protection, so a hand-edit either gets clobbered on
the next gtb regenerate or trips the conflict guard. Telemetry, the closest existing
capability, is never hand-edited — the generator templates the Telemetry: block
conditionally from manifest values. Signing should work the same way.
Signing is off by default because it carries real prerequisites: a signing key (ideally in a KMS), a published WKD endpoint, and a release pipeline wired to sign. Someone scaffolding a quick CLI shouldn't be handed any of that machinery they didn't ask for. Enabling it is therefore an explicit, active choice.
Design overview¶
When signing is enabled, the generator does three things:
- Scaffolds
internal/trustkeys/— atrustkeys.go(//go:embed all:keys+ aKeys() [][]byteaccessor, mirroring the existinginternal/trustkeys/trustkeys.go) and akeys/.gitkeepso the directory exists in git while empty. The author later drops their minted*.ascfiles intokeys/. The package is generated and regen-protected; keys are added as files, never by editing the.go. - Templates the
Signing:block into the generated root command'sprops.Tool{}, exactly the way Telemetry is templated:Signing: props.SigningConfig{EmbeddedKeys: trustkeys.Keys()}. - Generates a
signing.goin the generated root-command package — aninit()that sets thepkg/setupenforcement defaults (DefaultExternalKeyEmail,DefaultRequireSignature,DefaultKeySource,DefaultRequireExternalCrosscheck) from manifest values. This mirrorsgtb's owninternal/cmd/root/signing.gosplit, but is generated from the manifest rather than hand-written, so the author tunes posture by changing the manifest (viagtb enable signingflags), not by editing code.
All three are driven by a signing block in .gtb/manifest.yaml, so regeneration
reproduces them deterministically. The whole thing is gated on signing.enabled, which
defaults to false.
What gets scaffolded¶
When signing.enabled is true, the generated file set gains:
| Path | Generated? | Owner | Notes |
|---|---|---|---|
internal/trustkeys/trustkeys.go |
yes (templated) | generator | //go:embed all:keys, Keys() [][]byte. Regen-protected. |
internal/trustkeys/keys/.gitkeep |
yes | generator | keeps the dir in git; all: embed needs a file to match. |
internal/trustkeys/keys/*.asc |
no | author | the author adds minted keys here (from gtb keys mint). |
root command props.Tool{} Signing: field |
yes (templated) | generator | props.SigningConfig{EmbeddedKeys: trustkeys.Keys()}. |
root-command-package signing.go |
yes (templated) | generator | init() setting the setup.Default* enforcement vars from the manifest. |
The Keys() accessor returns nil when no *.asc is present, and
props.SigningConfig{}'s zero value is safe, so a tool that has enabled signing but
not yet added a key still builds and runs with verification dormant. Enabling the
feature is harmless on its own; nothing breaks before there's a key.
Manifest representation¶
Add a Signing block to ManifestProperties (internal/generator/manifest.go),
parallel to the existing Telemetry block:
# .gtb/manifest.yaml
properties:
# ...
signing:
enabled: true # default false; set by `gtb enable signing`
external_key_email: "[email protected]" # derives the WKD URL; enables the WKD leg
require_signature: false # N+1 rollout: stays false until the key has shipped
key_source: "both" # embedded | external | both (default both)
require_external_crosscheck: false # fail closed if WKD is unreachable
type ManifestSigning struct {
Enabled bool `yaml:"enabled,omitempty"`
ExternalKeyEmail string `yaml:"external_key_email,omitempty"`
RequireSignature bool `yaml:"require_signature,omitempty"`
KeySource string `yaml:"key_source,omitempty"`
RequireExternalCrosscheck bool `yaml:"require_external_crosscheck,omitempty"`
}
signing.enabled defaults to false: a project with no signing: block (every project
today) scaffolds nothing signing-related. Once enabled, empty values for the rest map to
the framework defaults (key_source → "both", require_signature → false).
gtb enable signing (and gtb disable signing)¶
Enabling signing on a project is its own verb, not a generate subcommand: generate
creates new user-facing surface (commands, flags, config fields), whereas this toggles a
capability and rewrites existing wiring. A new gtb enable command group hosts it (and
leaves room for gtb enable <other-capability> later):
gtb enable signing \
--email [email protected] # external_key_email (recommended; enables the WKD leg)
--key-source both # embedded | external | both (default both)
# --require-signature # default off; flip later, see rollout
# --require-external-crosscheck # default off
gtb enable signing:
- Sets
properties.signing.enabled = trueand the supplied options in.gtb/manifest.yaml. - Selectively regenerates only the signing-affected files — it writes
internal/trustkeys/trustkeys.go,internal/trustkeys/keys/.gitkeepand the generatedsigning.go, and re-renders the one generated root-command file to add theSigning:field. It does not run a fullregenerate project; unrelated files are left untouched. The same hash-protection applies to the files it does touch. - Is idempotent: run again with new flags and it updates the manifest block and re-renders just those files.
gtb disable signing is the inverse: sets signing.enabled = false and selectively
regenerates to drop the Signing: field and the generated signing.go. It leaves the
internal/trustkeys directory and any author-added *.asc in place (the generator never
deletes author content) and says so in its output.
Selective regeneration is a small addition to the regenerate machinery: a way to
re-render a named subset of generated files rather than the whole tree. If that proves
awkward, the fallback is to run the existing full regenerate project after the manifest
edit, but the intent is to touch only what signing owns.
Collecting signing configuration¶
Configuration (external_key_email, key_source, require_signature,
require_external_crosscheck) must be collectable both non-interactively (flags) and
interactively, from both entry points:
gtb enable signing— flags as above. When a flag is omitted and the terminal is interactive, prompt for it (at minimum the WKD email); in--ci/ non-interactive mode, omitted flags take their defaults and nothing prompts.gtb generate project— gains a--signingflag (off by default) plus--signing-email,--signing-key-source, etc., and the interactive wizard gains a signing step: an "Enable release signing?" prompt (default No), and when yes, follow-up prompts for the WKD email and key source. The wizard never prompts forrequire_signature(it must stay false until a key has shipped — see rollout); that is only ever flipped later viagtb enable signing --require-signature.
Both paths write the same properties.signing manifest block, so a project created with
--signing and one enabled later via gtb enable signing are identical.
The N+1 rollout and require_signature¶
require_signature defaults to false and stays there until the embedded key has
shipped in a prior release (the N+1 / N+2 / N+3 discipline in
docs/development/phase2-signing-prep.md). The author flips it by re-running
gtb enable signing --require-signature once a signed release is out. Because the value
lives in the manifest and is templated into signing.go, the flip is a tracked,
regenerable change rather than a hand-edit of generated code, which is the point of this
spec.
Why a config block, not a FeatureCmd¶
The FeatureCmd constants (update, init, mcp, docs, doctor, config,
changelog, ai, telemetry) are command groups a tool exposes to its end users.
Signing exposes no command to a tool's users — it is release-engineering wiring the
author sets up, the same kind of thing as Telemetry config, which is also not a
FeatureCmd. Modelling signing as a manifest config block keeps it off the
end-user-facing feature surface and avoids implying a mytool signing command that
should not exist. (Telemetry is also off by default, for consent reasons; signing is off
by default for a different reason — the prerequisite setup — but the config-block shape
is the same.)
Regeneration, hashes and user-owned files¶
trustkeys.go, theSigning:field andsigning.goare generated and participate in the manifest hash set (internal/cmd/regenerate/regenerate.go), so a latergtb regenerate projectkeeps them in sync and the conflict guard catches manual edits, same as any scaffolded file.internal/trustkeys/keys/*.ascare author-owned content, never templated or hashed. The.gitkeepis generated once.gtb disable signingremoves the generatedSigning:field andsigning.gobut never deletes theinternal/trustkeysdirectory or author-added*.asc.
Testing¶
- E2E (mirrors the scaffold-and-build tests):
gtb generate project --signing --signing-email [email protected], thengo build ./...succeeds, theinternal/trustkeyspackage exists, and the generated root command contains theSigning:wiring and asigning.gosetting the expected defaults. - Enable on existing: scaffold a plain project (no signing), run
gtb enable signing --email [email protected], assert the manifest gains the signing block, only the signing-affected files change (diff is scoped), andgo buildpasses. - Disable:
gtb disable signingdrops theSigning:field andsigning.go, leavesinternal/trustkeys+ any*.asc, andgo buildpasses. - Dormant-safe: signing enabled but no
*.ascpresent →trustkeys.Keys()returnsnil, binary builds and runs (verification dormant). - Interactive wizard: the
generate projectwizard's "Enable release signing?" path collects the email and produces the same manifest block as the flag path. - Selective-regen scope:
gtb enable signingtouches only the signing files; an unrelated generated file's hash is unchanged. - Regenerate idempotency / round-trip:
gtb regenerate projecttwice is a no-op; the manifestSigningblock round-trips through read/write andast_extract.
Out of scope¶
- Minting and key generation —
gtb keys mint/gtb keys generate(already shipped). - WKD publishing —
gtb keys wkd(already shipped). - KMS / OIDC infrastructure — the public
terraform-aws-signing-kmsmodule. - Signing releases in CI — the GoReleaser
signs:block and the signing shim, owned by the tool author.
This feature is solely the generated tool's embed-and-verify wiring.
References¶
pkg/props/tool.go—SigningConfig.EmbeddedKeys, theFeatureCmdset.internal/trustkeys/trustkeys.go— the package shape to mirror.internal/cmd/root/root.go,internal/cmd/root/signing.go— gtb's own (hand-written) reference wiring, and the comment on why it is kept out of the templated root.internal/generator/templates/skeleton_root.go:140-156— the Telemetry conditional to mirror forSigning:.internal/generator/skeleton.go:417— the keychain conditional scaffold to mirror (the emission mechanism; signing's default is off rather than on).internal/generator/manifest.go—ManifestProperties/ManifestTelemetry, whereManifestSigningslots in.internal/cmd/generate/— thegenerate projectwizard + flags to extend with signing.internal/cmd/regenerate/— the regenerate pipeline and hash protection; the basis for selective regeneration.docs/development/phase2-signing-prep.md— the N+1 rollout discipline.docs/components/setup/signature-verification.md— the resolver chain and log output.