ECDH encryption using ed25519 keys
Elliptic-curve Diffie-Hellman (ECDH) is a key agreement protocol that allows two parties, each having an elliptic public-private key pair to establish a shared secret over an insecure channel. Ed25519 is an elliptic curve signing algorithm using EdDSA and Curve25519. This tutorial will explain in detail how the ECDH works and has also some code examples in golang.
TL;DR
You can find the full source code and examples at github.com/gabihodoroaga/ecdh.
How ECDH encryption works
ECDH is based on the following property of EC points:
(a * G) * b = (b * G) * a
If we have two secret numbers a and b (two private keys, belonging to Alice and Bob) and an ECC elliptic curve with generator point G, we can exchange over an insecure channel the values (a * G) and (b * G) (the public keys of Alice and Bob) and then we can derive a shared secret: secret = (a * G) * b = (b * G) * a. Pretty simple. The above equation takes the following form:
alicePubKey * bobPrivKey = bobPubKey * alicePrivKey = secret
The ECDH algorithm (Elliptic Curve Diffie-Hellman Key Exchange) goes like this:
- Alice generates a random ECC key pair: alicePrivKey, alicePubKey
- Bob generates a random ECC key pair: bobPrivKey, bobPubKey
- Alice and Bob exchange their public keys through the insecure channel (e.g. over Internet)
- Alice calculates sharedKey = bobPubKey * alicePrivKey
- Bob calculates sharedKey = alicePubKey * bobPrivKey
- Now both Alice and Bob have the same sharedKey == bobPubKey * alicePrivKey == alicePubKey * bobPrivKey
How to get shared secret using ed25519 keys
For encryption, the ed25519 signing keys cannot be used directly and in order to achieve the above we need to convert the ed25519 key to X25519 keys.
There is an excellent article from Fillipo Valsorada Using Ed25519 signing keys for encryption that explains all the mathematics behind. And in fact some of the code used here is copied from the Age, a simple, modern and secure file encryption tool, format, and Go library.
There are 2 functions that we need in order to convert the ed25519 key into x25519 keys
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha512"
"filippo.io/edwards25519"
"golang.org/x/crypto/curve25519"
)
// ed25519PrivateKeyToCurve25519 converts a ed25519 private key in X25519 equivalent
// source: https://github.com/FiloSottile/age/blob/980763a16e30ea5c285c271344d2202fcb18c33b/agessh/agessh.go#L287
func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) []byte {
h := sha512.New()
h.Write(pk.Seed())
out := h.Sum(nil)
return out[:curve25519.ScalarSize]
}
// ed25519PublicKeyToCurve25519 converts a ed25519 public key in X25519 equivalent
// source: https://github.com/FiloSottile/age/blob/main/agessh/agessh.go#L190
func ed25519PublicKeyToCurve25519(pk ed25519.PublicKey) ([]byte, error) {
// See https://blog.filippo.io/using-ed25519-keys-for-encryption and
// https://pkg.go.dev/filippo.io/edwards25519#Point.BytesMontgomery.
p, err := new(edwards25519.Point).SetBytes(pk)
if err != nil {
return nil, err
}
return p.BytesMontgomery(), nil
}
And we can wrap everything together in a public function in order to get the shared secret
func SharedSecret(pub, priv []byte, decrypt bool) ([]byte, error) {
pkPriv := ed25519.PrivateKey(priv)
xPriv := ed25519PrivateKeyToCurve25519(pkPriv)
xPub, err := ed25519PublicKeyToCurve25519(pub)
if err != nil {
return nil, err
}
secret, err := curve25519.X25519(xPriv, xPub)
if err != nil {
return nil, err
}
return secret, nil
}
Next we should test if our implementation works as expected.
First let’s generate some key pairs and for this we can use a tool from Parity called subkey.
docker run -it --rm parity/subkey generate --scheme Ed25519
amd the output should be like this
Secret phrase: force balcony plate offer dinosaur diet sort faith sell kidney edge office
Secret seed: 0x8099218df05be91769679587124cfb3c1f6b0602805ffda193f26790c531e1eb
Public key (hex): 0xfef1ed54588c4edc73e6c1320d7c67c886f72caeb24e2c072e4aeb1c2be1edab
Account ID: 0xfef1ed54588c4edc73e6c1320d7c67c886f72caeb24e2c072e4aeb1c2be1edab
Public key (SS58): 4ntgbTB6sPGMBtKp4BqLWrXU8WXXPUW6hS1LjHwM3pgSqX8P
SS58 Address: 4ntgbTB6sPGMBtKp4BqLWrXU8WXXPUW6hS1LjHwM3pgSqX8P
and another one for Bob
Secret phrase: giant local surge bike plunge skate arena universe brand village coffee relief
Secret seed: 0x8ed0ce08849ef03657e0f137f15b73afbfc4ecbfcc76505e5fb5f63c998bb8a0
Public key (hex): 0x584db3b049decbcee583e051e2d9f205ae516597b3adb3f98806a19c6c05ad62
Account ID: 0x584db3b049decbcee583e051e2d9f205ae516597b3adb3f98806a19c6c05ad62
Public key (SS58): 4j8BrtFpwevyuwPRSq1bAXMeoYn7Wzg4QunbmvcPVx8QtPs3
SS58 Address: 4j8BrtFpwevyuwPRSq1bAXMeoYn7Wzg4QunbmvcPVx8QtPs3
Nex we need to create our test function
import (
"encoding/hex"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSharedSecret(t *testing.T) {
alicePrivKey,_ := hex.DecodeString("8099218df05be91769679587124cfb3c1f6b0602805ffda193f26790c531e1eb")
alicePubKey,_ := hex.DecodeString("fef1ed54588c4edc73e6c1320d7c67c886f72caeb24e2c072e4aeb1c2be1edab")
bobPrivKey,_ := hex.DecodeString("8ed0ce08849ef03657e0f137f15b73afbfc4ecbfcc76505e5fb5f63c998bb8a0")
bobPubKey,_ := hex.DecodeString("584db3b049decbcee583e051e2d9f205ae516597b3adb3f98806a19c6c05ad62")
aliceSecret, err := SharedSecret(bobPubKey, alicePrivKey, true)
require.Nil(t, err)
require.NotNil(t, aliceSecret)
bobSecret, err := SharedSecret(alicePubKey, bobPrivKey, true)
require.Nil(t, err)
require.NotNil(t, bobSecret)
assert.Equal(t, aliceSecret, bobSecret)
}
This is it. Now this shared secret can be used as a key to encrypt any message using a symmetric-key cipher.
In reality when you need to encrypt a message for Bob you don’t need to use the sender’s public key and it is more practical to use an ephemeral key pair and include the ephemeral public key into the final message.
- Bob provides a public key: bobPubKey
- A new ephemeral key is generated: ephemeralPubKey, ephemeralPrivKey
- A shared secret is calculated: bobPubKey * ephemeralPrivKey
- A message is encrypted with the shared secret: encryptedMessage
- Bob will receive a message containing both ephemeralPubKey + encryptedMessage
- When Bob wants to decrypt the message read the ephemeralPubKey and the encryptedMessage
- Calculates the shared secret: ephemeralPubKey * bobPrivKey
- Decrypts the message using the shared secret
For a complete example of how to do ECDHE with AES-128-CBC-HMAC-SHA1 check the example in this repository github.com/gabihodoroaga/ecdh.
References
I’m not an expert in cryptography. I’m not the actual author of all this. All I did is to copy pieces of code from the following sources and put them together in a way that makes sense to me and I hope it will make sense for someone else too.
ECDH Key Exchange by Svetlin Nakov