Skip to content

Receive your first encrypted report

By the end of this you will have published an OpenPGP certificate backed by two KMS keys, had gpg encrypt a report to it, and read that report back — with no private key ever existing in your process.

This is the whole capability, end to end, in about twenty minutes.

Before you start

You need:

  • an AWS account you are willing to create two KMS keys in, and credentials for it (aws sts get-caller-identity should work);
  • Go 1.26 or later;
  • gpg, to play the part of the researcher sending you something.

Two KMS keys cost about $2/month combined. Delete them at the end if this is only an experiment — the last section says how.

Step 1 — create the keys

Two keys, because an agreement key cannot sign its own binding signature. If that sounds arbitrary, why two keys explains it.

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"

Write those down; you will need them in a moment.

The RSA key takes a few seconds to become available. If step 3 fails with a key state error, wait and try again.

Step 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

Step 3 — assemble and publish the certificate

Create 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 <security@example.invalid>",
        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)
}

Run it:

ENCRYPT_KEY=$ENCRYPT_KEY CERTIFY_KEY=$CERTIFY_KEY go run .

You now have certificate.pgp — a publishable OpenPGP certificate whose private halves are both inside KMS.

Step 4 — be the researcher

Use a 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 meaningful: gpg validates self-signatures on import and silently drops what does not verify.

Now send yourself a report:

echo 'XSS in the profile editor, PoC attached' > report.txt

gpg --batch --yes --trust-model always --encrypt \
    --recipient security@example.invalid \
    --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 picks the first algorithm it supports — so that is what your report was encrypted with. Without those preferences gpg would have used AES-128, silently. See what a certificate claims.

The message itself will not tell you: the algorithm is inside the encrypted session key, so --list-packets report.pgp reports No secret key. You will see the algorithm in step 5, when the key service opens it.

Step 5 — read it back

Opening the encrypted body is a full OpenPGP concern — symmetric modes, integrity protection, compression — which this module deliberately leaves to a mature implementation. sigillum wires the two together, so use it:

go install gitlab.com/phpboyscout/sigillum/cmd/sigillum@latest

sigillum decrypt \
  --certificate certificate.pgp \
  --key "$ENCRYPT_KEY" \
  report.pgp

The report prints, and the algorithm it was encrypted under is the one your certificate asked for.

If you would rather stay in Go, the pieces are encryption.ParsePacket, certificate.Parse and encryption.SessionKey — the deriver you built in step 3 supplies the one secret operation. That is exactly what the command does.

What just 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. There is no code path that could have exported one, because KMS offers no API that would allow it.

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 the minimum window. Cancel with cancel-key-deletion if you change your mind.

Next

  • Provision the keys properly, with the reader and certifier roles split.
  • The role split — why the credential that reads reports must not be able to issue certificates.