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)} 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
x509.ParsePKCS8PrivateKeyreturns*ecdh.PrivateKeyfor X25519 but*ecdsa.PrivateKeyfor P-256; the type switch converts the latter with itsECDH()method. Public keys work the same way.privateKey.ECDH(peerKey)returns the raw shared secret and an error; it is the same call for every curve.hkdf.Key(sha256.New, secret, nil, info, 32)runs extract and expand in one call; anilsalt means the RFC 5869 default.- 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.PrivateKeyfails — 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 fromECDH; it is not just for malformed input. - The older
golang.org/x/crypto/hkdfreturns anio.Readerand needsio.ReadFull; the standardcrypto/hkdfhas a different, simpler API. Code examples for one do not compile against the other.