Skip to content

Decrypt your first message

By the end of this you will have taken a real OpenPGP message, recovered its session key, and seen why the key service never has to hand over a private key.

No AWS account is needed. The private key stays local for now, standing in for the place it would really live.

Before you start

You need Go 1.26 or later, and gpg to compose a message. Check both:

go version
gpg --version

Step 1 — make a key and a message

Work in a scratch directory, with its own keyring so nothing touches your real one:

mkdir first-decrypt && cd first-decrypt
export GNUPGHOME="$PWD/gnupg" && mkdir gnupg && chmod 700 gnupg

Generate a NIST P-256 key. The curve matters: this package implements ECDH over the NIST curves, which is what a KMS can hold.

cat > keyparams <<'PARAMS'
%no-protection
Key-Type: ECDSA
Key-Curve: nistp256
Key-Usage: sign,cert
Subkey-Type: ECDH
Subkey-Curve: nistp256
Subkey-Usage: encrypt
Name-Real: First Decrypt
Name-Email: you@example.invalid
Expire-Date: 0
%commit
PARAMS

gpg --batch --gen-key keyparams

Now encrypt something to it:

echo 'the quick brown fox' > plaintext.txt
gpg --batch --yes --trust-model always --encrypt \
    --recipient you@example.invalid \
    --output message.pgp plaintext.txt

You now have message.pgp: an OpenPGP message nobody but the holder of that key can read.

Step 2 — set up a Go module

go mod init first-decrypt
go get gitlab.com/phpboyscout/go/encryption
go get github.com/ProtonMail/go-crypto

go-crypto is here to read the key file and the packet framing. This tutorial uses it for everything except the part encryption owns, so you can see exactly where the boundary is.

Step 3 — recover the session key

Create main.go:

package main

import (
    "bytes"
    "crypto/ecdh"
    "fmt"
    "os"

    "github.com/ProtonMail/go-crypto/openpgp"
    pgpecdh "github.com/ProtonMail/go-crypto/openpgp/ecdh"
    "github.com/ProtonMail/go-crypto/openpgp/packet"

    "gitlab.com/phpboyscout/go/encryption"
)

func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, "failed:", err)
        os.Exit(1)
    }
}

func run() error {
    // The recipient's key, and the message addressed to it.
    keyFile, err := os.Open("secret.pgp")
    if err != nil {
        return err
    }
    defer keyFile.Close()

    ring, err := openpgp.ReadKeyRing(keyFile)
    if err != nil {
        return err
    }

    sub := ring[0].Subkeys[0]

    message, err := os.ReadFile("message.pgp")
    if err != nil {
        return err
    }

    // The message's first packet is the PKESK: it carries the sender's
    // ephemeral point and the wrapped session key.
    pkesk, err := encryption.ParsePKESK(message[2 : 2+int(message[1])])
    if err != nil {
        return err
    }

    // THIS is the only step a key service would perform. Here it happens
    // locally; in production the private scalar never leaves the service and
    // this becomes one API call that returns the same 32 bytes.
    priv := sub.PrivateKey.PrivateKey.(*pgpecdh.PrivateKey)

    scalar := make([]byte, 32)
    copy(scalar[32-len(priv.D):], priv.D)

    ours, err := ecdh.P256().NewPrivateKey(scalar)
    if err != nil {
        return err
    }

    peer, err := ecdh.P256().NewPublicKey(pkesk.EphemeralPoint)
    if err != nil {
        return err
    }

    sharedSecret, err := ours.ECDH(peer)
    if err != nil {
        return err
    }

    // Everything from here is public data and arithmetic.
    var pkbuf bytes.Buffer
    if err := sub.PublicKey.Serialize(&pkbuf); err != nil {
        return err
    }

    raw := pkbuf.Bytes()
    body := raw[2:]
    oidLen := int(body[6])
    tail := raw[len(raw)-4:]

    cipherID, sessionKey, err := encryption.SessionKey(encryption.Message{
        Version:      pkesk.Version,
        SharedSecret: sharedSecret,
        WrappedKey:   pkesk.WrappedKey,
    }, encryption.KDFParams{
        CurveOID:        body[6 : 7+oidLen],
        Fingerprint:     sub.PublicKey.Fingerprint,
        HashID:          tail[2],
        KEKAlgID:        tail[3],
        CoordinateBytes: 32,
    })
    if err != nil {
        return err
    }

    fmt.Printf("session key: %x\n", sessionKey)
    fmt.Printf("for symmetric algorithm %d\n", cipherID)

    _ = packet.CipherFunction(cipherID)

    return nil
}

Export the key so the program can read it, then run:

gpg --batch --yes --export-secret-keys --output secret.pgp you@example.invalid
go run .

You should see a 32-byte session key and an algorithm identifier.

What just happened

Read the code again and notice how little of it needed the private key. One call — the ECDH agreement — produced 32 bytes. Everything after that was arithmetic over values anyone can see: the KDF, the key unwrap, the checksum.

That is the whole idea. Replace those few lines with a call to AWS KMS and the private key never exists in your process at all, while the rest of the program is unchanged.

What you did not do

You recovered the session key, not the plaintext. Opening the encrypted-data packet with that key is a separate job that this package deliberately leaves to a full OpenPGP implementation.

Next