Skip to content
ecdsa.com

ECDH key agreement · Go

How to derive a shared key with ECDH and HKDF in Go

Go derives a shared key with ECDH using only the standard library: crypto/ecdh covers X25519 and the NIST curves behind one interface, and crypto/hkdf (Go 1.24+) turns the shared secret into a key. The only uneven part is loading PEM keys, which this recipe handles for both curves with the RFC 7748 and RFC 5903 test keys.

Tested with Go 1.27.3 (crypto/ecdh and crypto/hkdf, linux/arm64).

keys.go — load X25519 and P-256 PEM keys as crypto/ecdh keys
package main
import (
"crypto/ecdh"
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
)
func readDER(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("%s: no PEM block", path)
}
return block.Bytes, nil
}
// loadPrivateKey returns an *ecdh.PrivateKey for X25519 and NIST-curve PKCS#8 keys alike.
func loadPrivateKey(path string) (*ecdh.PrivateKey, error) {
der, err := readDER(path)
if err != nil {
return nil, err
}
parsed, err := x509.ParsePKCS8PrivateKey(der)
if err != nil {
return nil, err
}
switch key := parsed.(type) {
case *ecdh.PrivateKey: // X25519
return key, nil
case *ecdsa.PrivateKey: // P-256 keys parse as ECDSA keys
return key.ECDH()
}
return nil, fmt.Errorf("%s: %T cannot do ECDH", path, parsed)
}
// loadPublicKey does the same for SPKI ("BEGIN PUBLIC KEY") files.
func loadPublicKey(path string) (*ecdh.PublicKey, error) {
der, err := readDER(path)
if err != nil {
return nil, err
}
parsed, err := x509.ParsePKIXPublicKey(der)
if err != nil {
return nil, err
}
switch key := parsed.(type) {
case *ecdh.PublicKey:
return key, nil
case *ecdsa.PublicKey:
return key.ECDH()
}
return nil, fmt.Errorf("%s: %T cannot do ECDH", path, parsed)
}
main.go — ECDH, then HKDF-SHA256
package main
import (
"crypto/hkdf"
"crypto/sha256"
"fmt"
"log"
)
func main() {
for _, files := range [][3]string{
{"X25519", "alice-x25519.pem", "bob-x25519.pub.pem"},
{"P-256", "alice-p256.pem", "bob-p256.pub.pem"},
} {
privateKey, err := loadPrivateKey(files[1])
if err != nil {
log.Fatal(err)
}
peerKey, err := loadPublicKey(files[2])
if err != nil {
log.Fatal(err)
}
shared, err := privateKey.ECDH(peerKey) // the raw shared secret
if err != nil {
log.Fatal(err)
}
// HKDF-SHA256 (crypto/hkdf, Go 1.24+): nil salt, info binds the key to its use
key, err := hkdf.Key(sha256.New, shared, nil, "AES-256-GCM key v1", 32)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s shared secret: %x\n", files[0], shared)
fmt.Printf("%s derived key: %x\n", files[0], key)
}
}

How it works

  1. x509.ParsePKCS8PrivateKey returns *ecdh.PrivateKey for X25519 but *ecdsa.PrivateKey for P-256; the type switch converts the latter with its ECDH() method. Public keys work the same way.
  2. privateKey.ECDH(peerKey) returns the raw shared secret and an error; it is the same call for every curve.
  3. hkdf.Key(sha256.New, secret, nil, info, 32) runs extract and expand in one call; a nil salt means the RFC 5869 default.
  4. The program prints the RFC 7748 and RFC 5903 shared secrets and the same derived keys as every other recipe of this task.

Gotchas

  • Asserting the parsed P-256 key as *ecdh.PrivateKey fails — it is a *ecdsa.PrivateKey. Without the conversion, P-256 code works for X25519 keys and breaks on the first EC key, or the other way round.
  • Curves must match: mixing a P-256 private key with an X25519 public key returns crypto/ecdh: private key and public key curves do not match.
  • A low-order X25519 peer key (such as all zeros) returns crypto/ecdh: bad X25519 remote ECDH input: low order point. Always check the error from ECDH; it is not just for malformed input.
  • The older golang.org/x/crypto/hkdf returns an io.Reader and needs io.ReadFull; the standard crypto/hkdf has a different, simpler API. Code examples for one do not compile against the other.

Related recipes