Skip to content

Assemble a KMS-backed certificate

Emit a publishable OpenPGP certificate whose primary and encryption subkey are both KMS keys, with both signatures made by the service.

What you need

  • A SIGN_VERIFY KMS key for the certification primary. A KEY_AGREEMENT key cannot sign its own binding signature, so a second signing-capable key is structurally required.
  • A KEY_AGREEMENT key on a NIST curve for the encryption subkey.
  • Credentials that can call kms:Sign on the primary and kms:GetPublicKey on both.

Assemble

signer, err := kmsenc.NewSigner(ctx, client, client, "alias/your-certify-key")
if err != nil {
    return err
}

der, err := certificate.Certificate{
    UserID:  "Security <security@example.com>",
    Created: created,
    Subkey:  subkey,
}.Assemble(signer.WithContext(ctx))

There is no field for the primary's public key: it is read from the signer, so a certificate cannot advertise a key its signer does not hold.

Assemble makes two kms:Sign calls and returns binary packets. Armour them yourself if you are publishing to a page rather than to WKD.

Why WithContext

Assemble takes a crypto.Signer, the standard library's interface for a key held elsewhere. Its Sign method has nowhere to put a context, but the call it makes here is a network request that must be cancellable — so the provider carries one on a copy of the signer. Pass signer directly and it uses the context it was constructed with.

Choose the signature hash

SHA-256 by default. WithHash(crypto.SHA384) or WithHash(crypto.SHA512) if you would rather, subject to your key service supporting the matching signing algorithm — KMS offers PKCS#1 v1.5 over all three.

Pick the creation time deliberately

Created is hashed into the fingerprint, and the fingerprint is bound into every message addressed to the certificate. The same key material with a different creation time is a different certificate, and nothing encrypted to the old one will open.

Store the timestamp you used.

Assembly is not byte-reproducible by default

Each signature carries a random salt, so assembling the same inputs twice produces different octets. That is deliberate — see what a certificate claims.

It does not change who the certificate is. Fingerprints are hashed over the public-key packets alone, so a regenerated certificate accepts exactly the same messages as the one it replaces.

If you genuinely need identical octets — a reproducible build, or a published file that must not appear to have changed — pass WithoutSignatureSalt(), and read what it costs before you do.

Verify before publishing

Do not publish a certificate you have not had something else parse:

entities, err := openpgp.ReadKeyRing(bytes.NewReader(der))
if err != nil {
    return fmt.Errorf("certificate is not readable: %w", err)
}

if len(entities[0].Identities) != 1 || len(entities[0].Subkeys) != 1 {
    return errors.New("a signature did not verify")
}

An OpenPGP implementation drops an identity or subkey whose signature fails rather than erroring, so their survival is the check. A certificate that parses but has lost its subkey is one nobody can encrypt to.

Then prove it round-trips

The strongest check is to encrypt something to the certificate you just made and recover the session key with the decryption path. Assembly and decryption meet at the fingerprint, so a round trip exercises both — and neither is meaningfully tested without the other. See the fingerprint binding.