diff --git a/helper/hardware_token.go b/helper/hardware_token.go new file mode 100644 index 00000000..93675daf --- /dev/null +++ b/helper/hardware_token.go @@ -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") + } +} 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)) +}