A while back I wrote that publishing a PGP key for security reports is mostly theatre, and then published one anyway. Two encrypted emails in twenty years isn’t much of a case for the ceremony.
What I didn’t get into is what decides whether it’s only theatre, and I’ve come to think it mostly comes down to where the private half lives.
In my experience a published security key rarely fails on the cryptography. Someone can’t find the passphrase, or it was generated on a laptop that has since left the company, or it’s on a USB stick in a drawer in an office nobody goes to any more… the key’s fine, and everything around it has rotted.
So the version I’m interested in is one where there’s no private key anywhere to rot, or to steal. The secret material sits in KMS, which offers no API that would export it, and your process only ever asks it to perform two operations on data anyone is allowed to see.
That’s what this walkthrough builds. About twenty minutes, and at the end gpg encrypts something to a certificate you published and you read it back.
This is the canonical walkthrough from the go/encryption docs, reproduced here so the two don’t drift: https://encryption.go.phpboyscout.uk/tutorials/receive-your-first-encrypted-report/.
A note on what’s verified here, since I’ve been strict about this elsewhere. The Go below compiles clean against go/encryption v0.2.1 and encryption-aws-kms v0.3.1, so every symbol, type and signature in it still exists, and sigillum decrypt at v0.4.1 carries the flags step 5 uses. The KMS round trip itself I have not re-run for this post, because it needs an account with two real keys in it. The steps are the docs’ own, validated when they were written and pinned here to the commit they came from.
Before you start
- An AWS account you’re willing to create two KMS keys in, with working credentials (
aws sts get-caller-identityshould return something). - Go 1.26 or later.
gpg, to play the part of the researcher sending you something.
Two KMS keys cost about $2/month combined. The last section deletes them.
1. Create the keys
Two of them, because an agreement key cannot sign its own binding signature. One key certifies, the other does the key agreement.
ENCRYPT_KEY=$(aws kms create-key \
--key-usage KEY_AGREEMENT --key-spec ECC_NIST_P256 \
--description 'tutorial: OpenPGP encryption subkey' \
--query KeyMetadata.KeyId --output text)
CERTIFY_KEY=$(aws kms create-key \
--key-usage SIGN_VERIFY --key-spec RSA_4096 \
--description 'tutorial: OpenPGP certification primary' \
--query KeyMetadata.KeyId --output text)
echo "encrypt=$ENCRYPT_KEY certify=$CERTIFY_KEY"
The RSA key takes a few seconds to become available. If step 3 fails with a key state error, wait and run it again.
2. Set up a module
mkdir first-report && cd first-report
go mod init first-report
go get gitlab.com/phpboyscout/go/encryption
go get gitlab.com/phpboyscout/go/encryption-aws-kms
go get github.com/aws/aws-sdk-go-v2/config
3. Assemble and publish the certificate
main.go:
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/x509"
"fmt"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/kms"
"gitlab.com/phpboyscout/go/encryption/certificate"
kmsenc "gitlab.com/phpboyscout/go/encryption-aws-kms"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "failed:", err)
os.Exit(1)
}
}
func run() error {
ctx := context.Background()
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
return err
}
client := kms.NewFromConfig(cfg)
encryptKey, certifyKey := os.Getenv("ENCRYPT_KEY"), os.Getenv("CERTIFY_KEY")
// Fixed, not time.Now(). It is hashed into the fingerprint, and the
// fingerprint is bound into every message sent to this certificate.
created := time.Unix(1_700_000_000, 0).UTC()
signer, err := kmsenc.NewSigner(ctx, client, client, certifyKey)
if err != nil {
return err
}
deriver, err := kmsenc.NewDeriverForKey(ctx, client, client, encryptKey)
if err != nil {
return err
}
// The subkey's public point, in the uncompressed form a packet carries.
out, err := client.GetPublicKey(ctx, &kms.GetPublicKeyInput{KeyId: &encryptKey})
if err != nil {
return err
}
parsed, err := x509.ParsePKIXPublicKey(out.PublicKey)
if err != nil {
return err
}
ec := parsed.(*ecdsa.PublicKey)
point := elliptic.Marshal(deriver.Curve(), ec.X, ec.Y)
der, err := certificate.Certificate{
UserID: "Tutorial Security <[email protected]>",
Created: created,
Subkey: certificate.ECDHPublicKey{
Created: created,
CurveOID: []byte{0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07},
Point: point,
HashID: 8, // SHA-256
KEKAlgID: 7, // AES-128
},
}.Assemble(signer.WithContext(ctx))
if err != nil {
return err
}
fmt.Printf("assembled %d octets, two kms:Sign calls, no private key here\n", len(der))
return os.WriteFile("certificate.pgp", der, 0o600)
}
ENCRYPT_KEY=$ENCRYPT_KEY CERTIFY_KEY=$CERTIFY_KEY go run .
That leaves you certificate.pgp, a publishable OpenPGP certificate whose private halves are both inside KMS.
The fixed creation time looks like a placeholder. The timestamp is hashed into the fingerprint, and the fingerprint is bound into every message anyone sends you. Use time.Now() and you get a different certificate every run, which means a different fingerprint, which means the thing you published last week no longer matches the thing you can read.
4. Be the researcher
Throwaway keyring, so nothing touches your real one:
export GNUPGHOME="$(mktemp -d)" && chmod 700 "$GNUPGHOME"
gpg --import certificate.pgp
That gpg accepted it is already worth something. gpg validates self-signatures on import and silently drops what doesn’t verify, so a certificate it takes is one whose binding signatures are real. Your two kms:Sign calls produced something a mature implementation agrees is a key.
Now send yourself a report:
echo 'XSS in the profile editor, PoC attached' > report.txt
gpg --batch --yes --trust-model always --encrypt \
--recipient [email protected] \
--output report.pgp report.txt
Check what your certificate told gpg to use:
gpg --list-packets certificate.pgp | grep -E 'pref-sym|features'
hashed subpkt 11 len 3 (pref-sym-algos: 9 8 7)
hashed subpkt 30 len 1 (features: 01)
9 is AES-256, and gpg takes the first algorithm it supports, so that’s what your report was encrypted with. Leave those preferences out and gpg falls back to AES-128, without mentioning it. That’s the sort of default that is fine until a researcher asks you what your security contact is encrypted with and you find you don’t know.
The message itself won’t tell you either. The algorithm sits inside the encrypted session key, so --list-packets report.pgp just says No secret key. You see it in the next step, when the key service opens it.
5. Read it back
Opening the encrypted body is a full OpenPGP concern: symmetric modes, integrity protection, compression. go/encryption deliberately leaves that to a mature implementation, and sigillum wires the two together.
go install gitlab.com/phpboyscout/sigillum/cmd/sigillum@latest
sigillum decrypt \
--certificate certificate.pgp \
--key "$ENCRYPT_KEY" \
report.pgp
The report prints, encrypted under the algorithm your certificate asked for.
If you’d rather stay in Go, the pieces are encryption.ParsePacket, certificate.Parse and encryption.SessionKey, with the deriver from step 3 supplying the one secret operation. That’s what the command does under the hood.
What actually happened
Three KMS calls did all the secret work. Two kms:Sign to build the certificate, one kms:DeriveSharedSecret to open the message. Everything else, the key derivation, the AES key unwrap, the packet framing, the body decryption, happened locally on data anyone is allowed to see.
At no point did a private key exist in your process, not even briefly in a variable you were careful with, and there is no code path that could have exported one, because KMS offers no API that would allow it.
So that’s where I’ve landed on the objection I started with. A key you can’t operate is theatre, but one whose private half can’t be lost, copied or left on an ex-employee’s laptop feels like a different proposition, even if it only ever gets used twice.
Clean up
gpgconf --kill all && rm -rf "$GNUPGHOME"
aws kms schedule-key-deletion --key-id "$ENCRYPT_KEY" --pending-window-in-days 7
aws kms schedule-key-deletion --key-id "$CERTIFY_KEY" --pending-window-in-days 7
Seven days is AWS’s minimum window, so both keys sit pending deletion afterwards rather than vanishing. cancel-key-deletion if you change your mind.
Next
The docs carry the production shape: provisioning the keys properly with the reader and certifier roles split, and the role split on why the credential that reads reports must not be able to issue certificates.
And if you want the argument about whether any of this is worth doing, that’s the other post. (It concedes rather a lot, fair warning.)





