From 2853fd1b240c94fce81da78907390695229b0438 Mon Sep 17 00:00:00 2001 From: "Benoit (83noit)" <32198131+83noit@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:31:53 +0100 Subject: [PATCH 1/2] feat(helper): Add hardware token ECDH decryption support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new helper functions for integrating OpenPGP hardware tokens (YubiKey, smartcards) with gopenpgp: GetEncryptedKeyFieldsFromMessage extracts the key ID, algorithm, and raw encrypted MPI fields from the first PKESK packet in a PGP message. This allows callers to send the ephemeral point (MPI1) to a hardware token for the ECDH Decaps operation. DecryptMessageWithECDHSharedSecret completes ECDH decryption given a pre-computed shared secret from an external Decaps operation. It performs the KDF (RFC 6637 §8) and AES key unwrap (RFC 3394) to recover the session key, then decrypts the message. The private key is never needed. Depends on go-crypto additions: EncryptedKey.GetEncryptedMPI1/2, PublicKey.GetECDHOid, and ecdh.DecryptWithSharedSecret. Relates to #174. --- helper/hardware_token.go | 176 ++++++++++++++++++++++++++++++++++ helper/hardware_token_test.go | 87 +++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 helper/hardware_token.go create mode 100644 helper/hardware_token_test.go diff --git a/helper/hardware_token.go b/helper/hardware_token.go new file mode 100644 index 00000000..124f4046 --- /dev/null +++ b/helper/hardware_token.go @@ -0,0 +1,176 @@ +package helper + +import ( + "bytes" + "io" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/ecdh" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/ProtonMail/gopenpgp/v2/constants" + "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/pkg/errors" +) + +// GetEncryptedKeyFieldsFromMessage parses a PGP message and returns the +// encrypted key material from the first PKESK (Public-Key Encrypted Session +// Key) packet. This is needed for hardware token integrations where the +// encrypted material must be sent to an external device for decryption. +// +// Returns: +// - keyID: the ID of the key the message is encrypted to +// - algo: the public key algorithm (e.g., ECDH, RSA) +// - mpi1: the first encrypted MPI field (ephemeral point for ECDH, +// encrypted session key for RSA) +// - mpi2: the second encrypted MPI field (wrapped session key for ECDH, +// nil for RSA) +func GetEncryptedKeyFieldsFromMessage(pgpMessage *crypto.PGPMessage) (keyID uint64, algo int, mpi1, mpi2 []byte, err error) { + packets := packet.NewReader(bytes.NewReader(pgpMessage.GetBinary())) + + for { + var p packet.Packet + if p, err = packets.Next(); err == io.EOF { + err = errors.New("gopenpgp: no encrypted key packet found in message") + return + } + if err != nil { + err = errors.Wrap(err, "gopenpgp: unable to parse packet") + return + } + + if ek, ok := p.(*packet.EncryptedKey); ok { + keyID = ek.KeyId + algo = int(ek.Algo) + mpi1 = ek.GetEncryptedMPI1() + mpi2 = ek.GetEncryptedMPI2() + err = nil + return + } + } +} + +// DecryptMessageWithECDHSharedSecret decrypts a PGP message encrypted to an +// ECDH key, given the raw shared secret from an external Decaps operation +// (e.g., a YubiKey PSO:DECIPHER command). +// +// This function completes the ECDH key agreement by performing the KDF +// (RFC 6637 §8) and AES key unwrap (RFC 3394), then decrypts the message +// with the recovered session key. The private key is never needed. +// +// Parameters: +// - pgpMessage: the full encrypted PGP message +// - publicKey: the recipient's armored public key (provides ECDH parameters +// for the KDF: curve OID, KDF hash, KEK algorithm, and fingerprint) +// - sharedSecret: the raw ECDH shared secret (zb) returned by the hardware +// token's Decaps operation +func DecryptMessageWithECDHSharedSecret( + pgpMessage *crypto.PGPMessage, + publicKey string, + sharedSecret []byte, +) (plaintext []byte, err error) { + // Parse the public key to find the ECDH subkey. + publicKeyObj, err := crypto.NewKeyFromArmored(publicKey) + if err != nil { + return nil, errors.Wrap(err, "gopenpgp: unable to parse public key") + } + + entity := publicKeyObj.GetEntity() + ecdhPub, oid, fingerprint, err := findECDHKey(entity) + if err != nil { + return nil, err + } + + // Extract the wrapped session key (MPI2) from the message. + _, _, _, wrappedKey, err := GetEncryptedKeyFieldsFromMessage(pgpMessage) + if err != nil { + return nil, err + } + if wrappedKey == nil { + return nil, errors.New("gopenpgp: no wrapped key (MPI2) found in ECDH encrypted message") + } + + // Complete ECDH: KDF + AES key unwrap using the shared secret. + unwrapped, err := ecdh.DecryptWithSharedSecret(ecdhPub, sharedSecret, wrappedKey, oid, fingerprint) + if err != nil { + return nil, errors.Wrap(err, "gopenpgp: unable to unwrap session key with shared secret") + } + + // Parse the unwrapped key material: + // m = symm_alg_ID || session key || checksum + if len(unwrapped) < 3 { + return nil, errors.New("gopenpgp: unwrapped key material too short") + } + + cipherFunc := packet.CipherFunction(unwrapped[0]) + if !cipherFunc.IsSupported() { + return nil, errors.New("gopenpgp: unsupported cipher in unwrapped session key") + } + + // Strip algorithm ID byte and 2-byte checksum to get the raw session key. + sessionKeyBytes := unwrapped[1 : len(unwrapped)-2] + algoName, err := cipherFuncToName(cipherFunc) + if err != nil { + return nil, err + } + + sessionKey := crypto.NewSessionKeyFromToken(sessionKeyBytes, algoName) + + // Split the message and decrypt the data packet with the session key. + splitMsg, err := pgpMessage.SplitMessage() + if err != nil { + return nil, errors.Wrap(err, "gopenpgp: unable to split message") + } + + message, err := sessionKey.Decrypt(splitMsg.GetBinaryDataPacket()) + if err != nil { + return nil, errors.Wrap(err, "gopenpgp: unable to decrypt message with session key") + } + + return message.GetBinary(), nil +} + +// findECDHKey searches an OpenPGP entity for the first ECDH encryption subkey. +// Falls back to the primary key if it is an ECDH key. Returns the ECDH public +// key, encoded curve OID, and key fingerprint needed for the KDF. +func findECDHKey(entity *openpgp.Entity) (ecdhPub *ecdh.PublicKey, oid, fingerprint []byte, err error) { + // Check subkeys first (preferred for encryption). + for _, sub := range entity.Subkeys { + if sub.PublicKey.PubKeyAlgo == packet.PubKeyAlgoECDH { + pub, ok := sub.PublicKey.PublicKey.(*ecdh.PublicKey) + if !ok { + continue + } + return pub, sub.PublicKey.GetECDHOid(), sub.PublicKey.Fingerprint, nil + } + } + + // Fall back to primary key. + if entity.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoECDH { + pub, ok := entity.PrimaryKey.PublicKey.(*ecdh.PublicKey) + if !ok { + return nil, nil, nil, errors.New("gopenpgp: primary key is ECDH but has unexpected type") + } + return pub, entity.PrimaryKey.GetECDHOid(), entity.PrimaryKey.Fingerprint, nil + } + + return nil, nil, nil, errors.New("gopenpgp: no ECDH key found in the provided public key") +} + +// cipherFuncToName maps a packet.CipherFunction to the string name used by +// crypto.NewSessionKeyFromToken. +func cipherFuncToName(cf packet.CipherFunction) (string, error) { + switch cf { + case packet.Cipher3DES: + return constants.ThreeDES, nil + case packet.CipherCAST5: + return constants.CAST5, nil + case packet.CipherAES128: + return constants.AES128, nil + case packet.CipherAES192: + return constants.AES192, nil + case packet.CipherAES256: + return constants.AES256, nil + default: + return "", errors.New("gopenpgp: unsupported cipher function") + } +} diff --git a/helper/hardware_token_test.go b/helper/hardware_token_test.go new file mode 100644 index 00000000..a782719d --- /dev/null +++ b/helper/hardware_token_test.go @@ -0,0 +1,87 @@ +package helper + +import ( + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/ecdh" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetEncryptedKeyFieldsFromMessage(t *testing.T) { + publicKey := readTestFile("keyring_publicKey", false) + privateKey := readTestFile("keyring_privateKey", false) + + plaintext := "test message for key field extraction" + armored, err := EncryptMessageArmored(publicKey, plaintext) + require.NoError(t, err) + + pgpMessage, err := crypto.NewPGPMessageFromArmored(armored) + require.NoError(t, err) + + keyID, algo, mpi1, mpi2, err := GetEncryptedKeyFieldsFromMessage(pgpMessage) + require.NoError(t, err) + + assert.NotZero(t, keyID) + assert.Equal(t, int(packet.PubKeyAlgoRSA), algo) + assert.NotEmpty(t, mpi1, "MPI1 should not be empty for RSA") + assert.Nil(t, mpi2, "MPI2 should be nil for RSA") + + // Verify message is still decryptable. + decrypted, err := DecryptMessageArmored(privateKey, testMailboxPassword, armored) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) +} + +func TestDecryptMessageWithECDHSharedSecret(t *testing.T) { + // Generate EdDSA+ECDH key (ed25519 primary + curve25519 ECDH subkey). + // Use gopenpgp's GenerateKey which properly serializes and creates the key. + // "x25519" produces EdDSA primary + curve25519 ECDH subkey (algo 18). + key, err := crypto.GenerateKey("test", "test@test.com", "x25519", 0) + require.NoError(t, err) + + entity := key.GetEntity() + + // Verify we got a classic ECDH subkey (algorithm 18). + var ecdhSubkey *openpgp.Subkey + for i := range entity.Subkeys { + if entity.Subkeys[i].PublicKey.PubKeyAlgo == packet.PubKeyAlgoECDH { + ecdhSubkey = &entity.Subkeys[i] + break + } + } + require.NotNil(t, ecdhSubkey, "should have an ECDH subkey") + + publicKeyArmored, err := key.GetArmoredPublicKey() + require.NoError(t, err) + + // Encrypt a message to the ECDH key. + plaintext := "shared secret test message" + publicKeyRing, err := createPublicKeyRing(publicKeyArmored) + require.NoError(t, err) + + pgpMessage, err := publicKeyRing.Encrypt(crypto.NewPlainMessageFromString(plaintext), nil) + require.NoError(t, err) + + // Extract encrypted key fields. + _, algo, mpi1, wrappedKey, err := GetEncryptedKeyFieldsFromMessage(pgpMessage) + require.NoError(t, err) + assert.Equal(t, int(packet.PubKeyAlgoECDH), algo) + assert.NotEmpty(t, mpi1, "MPI1 (ephemeral point) should not be empty") + assert.NotEmpty(t, wrappedKey, "MPI2 (wrapped key) should not be empty") + + // Simulate hardware token: compute the ECDH shared secret from the + // ephemeral point and the private scalar. + ecdhPriv := ecdhSubkey.PrivateKey.PrivateKey.(*ecdh.PrivateKey) + ephemeral := ecdhPriv.PublicKey.GetCurve().UnmarshalBytePoint(mpi1) + sharedSecret, err := ecdhPriv.PublicKey.GetCurve().Decaps(ephemeral, ecdhPriv.D) + require.NoError(t, err, "computing shared secret should succeed") + + // Decrypt using only the shared secret (no private key needed). + decrypted, err := DecryptMessageWithECDHSharedSecret(pgpMessage, publicKeyArmored, sharedSecret) + require.NoError(t, err) + assert.Equal(t, plaintext, string(decrypted)) +} From f89caa5e3b96aa438fa6d7e9dfb0b42d1984b972 Mon Sep 17 00:00:00 2001 From: "Benoit (83noit)" <32198131+83noit@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:08:18 +0100 Subject: [PATCH 2/2] feat(helper): Add GetEncryptedMPI1FromMessage gomobile wrapper Adds a gomobile-compatible wrapper around GetEncryptedKeyFieldsFromMessage that returns only the first MPI field. gomobile cannot bind functions with more than 2 return values, so this single-purpose wrapper is needed for iOS integration. --- helper/hardware_token.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/helper/hardware_token.go b/helper/hardware_token.go index 124f4046..93675daf 100644 --- a/helper/hardware_token.go +++ b/helper/hardware_token.go @@ -49,6 +49,17 @@ func GetEncryptedKeyFieldsFromMessage(pgpMessage *crypto.PGPMessage) (keyID uint } } +// GetEncryptedMPI1FromMessage extracts the first encrypted MPI field from +// the first PKESK packet in a PGP message. For ECDH, this is the ephemeral +// public key point needed by a hardware token to compute the shared secret. +// For RSA, this is the encrypted session key. +// +// This is a gomobile-compatible wrapper around GetEncryptedKeyFieldsFromMessage. +func GetEncryptedMPI1FromMessage(pgpMessage *crypto.PGPMessage) ([]byte, error) { + _, _, mpi1, _, err := GetEncryptedKeyFieldsFromMessage(pgpMessage) + return mpi1, err +} + // DecryptMessageWithECDHSharedSecret decrypts a PGP message encrypted to an // ECDH key, given the raw shared secret from an external Decaps operation // (e.g., a YubiKey PSO:DECIPHER command).