Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions helper/hardware_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
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
}
}
}

// 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).
//
// 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")
}
}
87 changes: 87 additions & 0 deletions helper/hardware_token_test.go
Original file line number Diff line number Diff line change
@@ -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))
}