gtb sign β detached OpenPGP signing via a pluggable backend¶
- Authors
- Matt Cockayne
- Date
- 2026-06-09
- Status
- IMPLEMENTED (shipped in MR !35, 2026-06-09; live since v0.12.2)
Summary¶
Add a new top-level gtb sign command and an underlying
pkg/openpgpkey.DetachSign library function. The command takes a
file, a signing backend (aws-kms / local / future
GCP/Vault/YubiKey), and a key identifier; produces an
ASCII-armored OpenPGP detached signature compatible with the
verifier in pkg/setup/signing.go (MR !9) and with gpg --verify.
gtb sign \
--backend aws-kms \
--kms-region eu-west-2 \
--key-id alias/gtb-release-signing-v1 \
--name "PHP Boy Scout Release" \
--email [email protected] \
--output checksums.txt.sig \
checksums.txt
Replaces scripts/sign-release.sh's gpg --detach-sign call in MR
!9. The signing operation never sees the private key β only the
backend's remote Sign round-trip does.
Motivation¶
Phase 2 needs CI to mint a detached OpenPGP signature over
checksums.txt on every tag pipeline. The held-MR-!9 script uses
gpg --local-user $GTB_SIGNING_KEY which assumes a gpg-resolvable
key β workable only if you expose the KMS-held key through a
PKCS#11 provider / scdaemon, a moving-parts setup we explicitly
want to avoid (same reason gtb keys mint exists instead of
shelling out to gpg).
A native Go signer using the same pkg/signing registry closes
the loop: one consistent path for minting keys, publishing them
via WKD, and producing the detached signatures the verifier
expects. gpg is no longer in the release pipeline.
Design decisions¶
D1 β Command placement (top-level)¶
gtb sign is a top-level verb, not a subcommand under
gtb keys. The keys group is for key lifecycle (mint,
generate, wkd); sign is for using a key to operate on a file.
Conventional with cosign sign, signify -S, minisign -S,
signtool sign.
Implementation lives in a new internal/cmd/sign/ package (gtb-only;
not scaffoldable to downstream tools β same boundary as keys/).
D2 β Library-first split¶
Per the library-first principle, the signing primitive lives in
pkg/openpgpkey/sign.go (Beta tier) as DetachSign. Shape:
// DetachSign computes an ASCII-armored OpenPGP detached signature
// over data using signer. publicKey is the armored OpenPGP public-key
// block that identifies the signer; its fingerprint becomes the
// signer-fingerprint embedded in the signature. signer must produce
// signatures verifiable against publicKey's public half (we check
// this at call time and refuse to proceed if they disagree).
//
// data is read in full (the signature is over the whole content;
// no streaming).
//
// sigCreationTime is independent of the key's creation time embedded
// in publicKey; it stamps the signature itself.
func DetachSign(signer crypto.Signer, publicKey []byte, data io.Reader, sigCreationTime time.Time) ([]byte, error)
Uses the same crypto.Signer interface and OpenPGP packet framing
as ArmoredPublicKey. An opaque KMS-backed signer works without
the private key ever leaving the HSM.
API revision note: an earlier draft took
(signer, name, email, data, creationTime) and rebuilt the entity
inside DetachSign β but that conflated two distinct timestamps (the
key's creation time and the signature's creation time). A test run
against a freshly-minted key surfaced the bug: the signature named a
phantom-key fingerprint derived from the sig-time --created rather
than the actual key's published fingerprint, and gpg rejected the sig
with "No public key". The revised API takes the armored public key as
the identity source; the signer must produce signatures verifiable
against it (checked via RSA n/e comparison before signing).
D3 β Algorithms (RSA only in v0.1)¶
Same constraint as ArmoredPublicKey: signer.Public() must
return *rsa.PublicKey. Returns ErrUnsupportedKeyType otherwise.
AWS KMS asymmetric SIGN_VERIFY keys are RSA-only; Ed25519
signing via gtb sign is additive and can be added when a
backend that supplies Ed25519 signers (other than the
self-generated local key) appears.
D4 β Reproducibility: --created flag (signature timestamp)¶
The CLI exposes --created <rfc3339> (default: now). Pinning this
produces byte-identical signatures across re-runs over the same
content (with the same signing key) β useful for SLSA-style
attestation and for diffing two signed builds.
--created is the signature's creation time, not the key's. The
key's creation time is whatever was set when the key was minted
(gtb keys mint --created) and is now baked into the --public-key
file. Mixing the two was a bug surfaced during implementation β see
the API revision note under D2.
For deterministic output, go-crypto's default NonDeterministicSignaturesViaNotation
(a random-salt subpacket added as a fault-injection defence) is
explicitly disabled in DetachSign. The defence protects against
hardware-fault attacks on the signing machine; in our setup the
signer is AWS KMS, which has its own HSM-level fault protections.
D5 β One file at a time¶
The CLI signs one input file per invocation:
This matches goreleaser's signs block contract (signs[*].args
is called once per artifact with ${artifact} / ${signature})
and keeps error handling simple β one input, one output, one exit
code reflecting that pair.
If --output is omitted, defaults to <input>.sig.
D6 β AWS auth outside gtb sign¶
The CLI does not know about OIDC. CI is responsible for
exporting AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY /
AWS_SESSION_TOKEN (typically via aws sts
assume-role-with-web-identity in a before_script) before
invoking gtb sign. The signing-kms backend uses the standard
AWS SDK credential chain, which picks those up automatically.
This keeps gtb sign provider-agnostic β the same command works
with GitHub Actions OIDC, BitBucket Pipelines OIDC, or a
long-lived access key in a local terminal. Tightly coupling
gtb sign to a specific OIDC flow would require maintaining a
matrix of CI-platform integrations the framework doesn't need to
own.
D7 β Output format¶
Single output: armored OpenPGP detached signature
(-----BEGIN PGP SIGNATURE----- β¦ -----END PGP SIGNATURE-----).
That's what TrustSet.VerifyManifestSignature (MR !9) consumes
via openpgp.CheckArmoredDetachedSignature. No optional binary
form in v0.1.
D8 β Validation¶
--backendmust be a registered backend name (same lookup asgtb keys mint).--key-idand--public-keyrequired.--public-keyis the armored OpenPGP public-key file the signer's RSA key must match (n + e comparison); mismatch returns"signer's RSA public half does not match the public key block".- Input file must exist and be readable.
--outputdefaults to<input>.sig; refuses to equal the input path (would clobber the artifact).--createdparses as RFC 3339; default istime.Now().UTC()rounded to whole seconds (OpenPGP signature packets store uint32 seconds since epoch, so sub-second precision is lost anyway β rounding makes the timestamp explicit and test fixtures predictable).- Successful sign prints the public-key fingerprint at INFO so
operators can cross-check against the expected key without a
follow-up
gpg --verify.
Verification plan¶
- Unit:
pkg/openpgpkey/sign_test.gocovers concrete-RSA and opaque-signer (KMS simulation) paths, fingerprint pinning whencreationTimeis constant, golden test that the produced signature is accepted by go-crypto'sopenpgp.CheckArmoredDetachedSignatureagainst the matching public key (closes the loop within the same package). - CLI:
internal/cmd/sign/sign_test.gocovers happy path, flag validation, input/output equality refusal, missing-file error, fingerprint logging. - Cross-tool: integration test (gated
INT_TEST_SIGN=1) that usesgpg --verifyon the produced .sig against the matching public key (the recipient's expected behaviour). Catches any drift between our framing and the OpenPGP standard. - End-to-end: locally, against the real Phase 2 KMS key, sign
a 10 KB random file and verify with both go-crypto AND
gpg --verify. Documented in the eventualdocs/how-to/sign-releases.md.
Out of scope¶
- Inline (
--clearsign) and message-embedded signatures. Goreleaser only wants detached. - Signing entire directories or git refs. Per-artifact only.
- Verification:
gtb verifyis a natural counterpart but the existinggpg --verifyis fine for operator-facing checks, and the in-binary verifier (pkg/setup) already covers the self-update path. Add when there's a concrete asker.
Resolutions (open questions confirmed with user 2026-06-09)¶
- Command location β top-level
gtb sign(notgtb keys sign). Matches conventional signing-tool UX. --createdflag β present, defaults to now, allows pinning.- Multi-file β strict one-at-a-time, matches goreleaser's per-artifact invocation contract.
- OIDC location β outside the tool. CI assumes the role in a
before_scriptand exportsAWS_*env vars; gtb-sign reads them via the SDK's standard credential chain.
Related¶
- keys-mint spec β establishes the
pkg/signingbackend registry thatgtb signreuses. - keys-wkd spec β parallel pattern: library function + thin CLI wrapper.
- Phase 2 signing prep doc β Gate 1 selected AWS KMS as the signing-key store; this spec is the consumer.
- MR !9
β the verifier side; this signer's output is what its
TrustSet.VerifyManifestSignatureconsumes.