pkg/openpgpkey¶
API stability: Beta (per
docs/about/api-stability.md and spec
D4).
What it does¶
Mints an ASCII-armored OpenPGP public key from any crypto.Signer
whose Public() returns *rsa.PublicKey. The OpenPGP self-signature
is produced by calling signer.Sign(...) exactly once β so an opaque
HSM-backed signer (AWS KMS, GCP KMS, YubiKey) works without the
private key ever leaving the HSM.
Used by:
internal/cmd/keys/mint.go(production) andinternal/cmd/keys/generate.go(RSA path) inside thegtbbinary.
Anyone with a crypto.Signer that produces RSA signatures can call
this package directly to produce an embeddable / WKD-publishable
.asc.
Public API¶
// ArmoredPublicKey is the one-call form. Returns the armored bytes
// in memory; callers persist them however they like.
func ArmoredPublicKey(signer crypto.Signer, name, email string, creationTime time.Time) ([]byte, error)
// WriteArmoredPublicKey streams directly to an io.Writer. Useful for
// writing to a file or network without an intermediate buffer.
func WriteArmoredPublicKey(w io.Writer, signer crypto.Signer, name, email string, creationTime time.Time) error
// Entity returns the underlying *openpgp.Entity if you need both the
// public and private halves. Used by `gtb keys generate` to write
// the two halves to separate files.
func Entity(signer crypto.Signer, name, email string, creationTime time.Time) (*openpgp.Entity, error)
// Sentinel for non-RSA signers. Use errors.Is to check.
var ErrUnsupportedKeyType
What signer must provide¶
signer.Public()returns*rsa.PublicKey. Anything else returnsErrUnsupportedKeyType.signer.Sign(rand, digest, opts)produces a PKCS#1 v1.5 RSA signature overdigest.opts.HashFunc()is one of SHA-256, SHA-384, or SHA-512 (whichever the underlying OpenPGP packet framing chooses β that's an internal go-crypto detail; your signer must support all three to be robust).
Why RSA only¶
AWS KMS β the production HSM in scope for v0.1 β only exposes RSA
for asymmetric SIGN_VERIFY keys. Ed25519 minting flows through
internal/cmd/keys/generate.go instead, which uses go-crypto's own
openpgp.NewEntity(...) with PubKeyAlgoEdDSA to produce v4 EdDSA
(algorithm 22) keys that GnuPG 2.4 imports cleanly.
A consumer with a Backend producing Ed25519 signers via a route that does land in mainstream gpg would be a reason to add Ed25519 here additively. None exists in v0.1.
Example: wrap a local RSA key¶
priv, _ := rsa.GenerateKey(rand.Reader, 4096)
armored, err := openpgpkey.ArmoredPublicKey(priv,
"MyTool Release",
"[email protected]",
time.Now().UTC(),
)
if err != nil {
log.Fatal(err)
}
os.WriteFile("release.asc", armored, 0o644)
Example: wrap a KMS-backed signer¶
// signer.Public() returns *rsa.PublicKey; signer.Sign() calls
// kms.Sign() under the hood. See pkg/signing/kms for the concrete
// implementation.
signer, _ := kms.NewSigner(ctx, "eu-west-2", "alias/release-signing-v1")
armored, err := openpgpkey.ArmoredPublicKey(signer, "...", "...", time.Now().UTC())
Exactly one kms.Sign round-trip happens during the mint: the
self-signature on the binding between the public-key packet and the
User ID.
Reproducibility¶
creationTime is folded into the OpenPGP fingerprint. Two mints of
the same key one second apart produce different fingerprints. To
re-derive an existing key after losing the .asc file, pin
creationTime to the original value.
Algorithm + version details (for the curious)¶
- OpenPGP packet version: v4. RFC 4880 native, accepted by every modern OpenPGP implementation.
- RSA public-key packet: produced via
packet.NewRSAPublicKey(creationTime, *rsa.PublicKey). - Self-signature: positive cert (
SignatureTypePositiveCert), produced byEntity.AddUserIdwhich routes throughsigner.Sign(...). - Hash: chosen by go-crypto/openpgp at sign time; typically SHA-256 for RSA-2048/3072 and SHA-384/512 for larger keys.
Detached OpenPGP signing (DetachSign)¶
For the per-release-signing step (CI signs checksums.txt β
checksums.txt.sig), the package exposes DetachSign, a
companion to ArmoredPublicKey that produces armored detached
signatures verifiable against the public key it was paired with.
// 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).
func DetachSign(signer crypto.Signer, publicKey []byte, data io.Reader, sigCreationTime time.Time) ([]byte, error)
Why an armored detached signature¶
It's the exact format the verifier in pkg/setup
(TrustSet.VerifyManifestSignature β
openpgp.CheckArmoredDetachedSignature) consumes, and what
gpg --verify produces / accepts. One on-the-wire shape across
producer, in-tool verifier, and standard third-party tools.
Reproducibility¶
sigCreationTime is the signature's creation-time subpacket,
not the key's (the key's creation time lives in publicKey).
Pinning sigCreationTime plus PKCS#1 v1.5 RSA's deterministic
output yields byte-identical signatures across runs over the same
content β useful for SLSA-style attestation.
To keep signatures deterministic, DetachSign explicitly disables
go-crypto's default NonDeterministicSignaturesViaNotation
(a random-salt subpacket added as a fault-injection defence
designed for EdDSA on consumer hardware). The defence is
inapplicable to our setup β the signer is AWS KMS, an HSM with its
own fault-injection protections.
Signer/key-pair mismatch detection¶
The signer's Public() RSA key is compared against publicKey's
public half via n and e; if they disagree, DetachSign errors
out before producing a signature with a fingerprint that doesn't
match the actual signing key (a silent-fail scenario that would
ship unverifiable signatures to consumers).
Pairing with the CLI¶
gtb sign --backend <name> --key-id <id> --public-key <path.asc>
<input> is the operator-facing wrapper. See
How-to: sign release artefacts and
Spec 2026-06-09-sign-command.
Web Key Directory (WKD) tree generation¶
The package also produces a complete WKD layout per
draft-koch-openpgp-webkey-service Β§3.1, paired with
pkg/setup/signing_wkd.go's client-side WKDResolver. Use this to
publish public keys at openpgpkey.<yourdomain> so the verifier can
cross-check the embedded key against an externally-served copy on
each gtb update.
// One bucket per email β keys are concatenated under hu/<hash> in
// lexicographic fingerprint order for reproducible output.
type Entry struct {
Email string
Keys [][]byte // armored *or* binary; auto-detected
}
type Method string
const (
MethodAdvanced Method = "advanced" // default β dedicated openpgpkey.<domain> subdomain
MethodDirect Method = "direct" // root-domain layout (no openpgpkey/<domain>/ level)
)
type Options struct {
Method Method
SubmissionAddress string // optional; if non-empty, written to the domain root for WKS-aware clients
}
func WriteWKDTree(outDir, domain string, opts Options, entries ...Entry) ([]string, error)
func WKDHash(email string) (string, error) // exposed for callers that just need the hash
Output for the standard PHP Boy Scout setup:
out/.well-known/openpgpkey/phpboyscout.uk/
βββ policy (empty; required by RFC)
βββ submission-address (optional; configured via Options.SubmissionAddress)
βββ hu/y84sdmnksfqswe7fxf5mzjg53tbdz8f5 (binary concatenated public keys for release@)
WKDHash and the underlying zbase32Encode are validated against
the reference implementation: a unit test feeds [email protected]
and compares to the output of gpg-wks-client --print-wkd-hash. Any
drift from RFC compliance fails CI.
Why a native generator¶
The reference tool, gpg-wks-client, is a thin shell wrapper over the
same packet routines pkg/openpgpkey already uses. Bringing
generation in-process means a downstream tool can publish WKD without
requiring a gpg install β the same property gtb keys generate
provides on the key-minting side.
For the operator-facing recipe (Cloudflare Pages Direct Upload), see How-to: publish via WKD.
Related¶
- Release-binary signing concept β the big-picture story.
pkg/signingβ the backend registry that drivesgtb keys mint.- Spec D12 β
the (revised) RSA-only design decision and why Ed25519 minting
lives in
internal/cmd/keys/. - Spec 2026-06-09-keys-wkd-command β the WKD generator design (D1βD8) and resolved open questions.