Skip to content
ecdsa.com

ECDH key agreement · Python

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

The cryptography package (pyca) derives a shared key with ECDH in two steps: exchange produces the raw X25519 or P-256 shared secret, and the HKDF class turns it into a key. The two curves use slightly different exchange signatures, which this recipe shows side by side with the RFC 7748 and RFC 5903 test keys.

Tested with cryptography 50.0.2 (Python 3.14.8, OpenSSL 3.5.7).

agree.py — X25519 and P-256 agreement from PEM files, then HKDF-SHA256
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.serialization import load_pem_private_key, load_pem_public_key
def load_pair(private_key_file: str, peer_public_key_file: str):
with open(private_key_file, "rb") as f:
private_key = load_pem_private_key(f.read(), password=None)
with open(peer_public_key_file, "rb") as f:
peer_public_key = load_pem_public_key(f.read())
return private_key, peer_public_key
def derive_key(shared_secret: bytes) -> bytes:
# An HKDF object derives once; create a new one for every key
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b"AES-256-GCM key v1")
return hkdf.derive(shared_secret)
# X25519: exchange(peer_public_key)
private_key, peer = load_pair("alice-x25519.pem", "bob-x25519.pub.pem")
shared = private_key.exchange(peer)
print("X25519 shared secret:", shared.hex())
print("X25519 derived key:", derive_key(shared).hex())
# NIST curves: exchange(ec.ECDH(), peer_public_key)
private_key, peer = load_pair("alice-p256.pem", "bob-p256.pub.pem")
shared = private_key.exchange(ec.ECDH(), peer)
print("P-256 shared secret:", shared.hex())
print("P-256 derived key:", derive_key(shared).hex())
a live exchange: fresh X25519 key pairs, raw 32-byte public keys on the wire
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
# In a live exchange each side makes a fresh key pair and sends only its public key
alice = X25519PrivateKey.generate()
bob = X25519PrivateKey.generate()
# On the wire: 32 raw bytes per X25519 public key
from_alice = alice.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
from_bob = bob.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
alice_secret = alice.exchange(X25519PublicKey.from_public_bytes(from_bob))
bob_secret = bob.exchange(X25519PublicKey.from_public_bytes(from_alice))
print("both sides agree:", alice_secret == bob_secret)

How it works

  1. load_pem_private_key returns an X25519PrivateKey or an EllipticCurvePrivateKey depending on the PEM; load_pem_public_key does the same for the peer's key.
  2. X25519 is private_key.exchange(peer_public_key). NIST curves need the algorithm object: private_key.exchange(ec.ECDH(), peer_public_key).
  3. HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=…) with derive(secret) produces the key; salt=None means the RFC 5869 default, a string of zero bytes.
  4. For a live exchange, X25519 public keys travel as 32 raw bytes (Encoding.Raw, PublicFormat.Raw) and are read back with from_public_bytes.

Gotchas

  • The two signatures are not interchangeable: ec_key.exchange(peer) raises TypeError: ECPrivateKey.exchange() missing 1 required positional argument: 'peer_public_key', and x25519_key.exchange(ec.ECDH(), peer) raises TypeError: X25519PrivateKey.exchange() takes 1 positional arguments but 2 were given.
  • An HKDF instance derives once. A second derive call raises AlreadyFinalized: Context was already finalized. — create a new instance per key, as derive_key does.
  • Keys on different curves raise ValueError: Error computing shared key., and so does a malicious all-zero X25519 peer key. Treat that exception as a rejected peer, not as a transient error.
  • The raw shared secret is not a key: use HKDF with a purpose-specific info rather than slicing or hashing the secret ad hoc, so both sides derive the same key the same way.

Related recipes