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:
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:
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¶
- Decrypt with AWS KMS — replace the local key with a key service.
- The fingerprint binding
— why
Fingerprintis in those parameters, and what breaks without it.