Skip to content
ecdsa.com

ECDH key agreement · Rust

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

In Rust, ECDH comes from two RustCrypto-family crates — x25519-dalek for X25519 and p256 for P-256 — and the hkdf crate turns the shared secret into a key. p256 reads PEM keys directly; x25519-dalek works on raw 32-byte keys, so this recipe unwraps the RFC 8410 PEM files with the pkcs8 crate.

Tested with x25519-dalek 3.0.0 (Rust 1.99.1) and p256 0.14.0 (Rust 1.99.1, with hkdf 0.13.0 and pkcs8 0.11.0).

Cargo.toml — pinned crate versions and features
[dependencies]
x25519-dalek = { version = "=3.0.0", features = ["static_secrets"] }
p256 = { version = "=0.14.0", features = ["ecdh", "pem"] }
pkcs8 = { version = "=0.11.0", features = ["pem"] } # X25519 PEM parsing
hkdf = "=0.13.0"
sha2 = "=0.11.1"
hex = "=0.4.3"
src/main.rs — X25519 and P-256 agreement from PEM files, then HKDF-SHA256
use std::error::Error;
use std::fs;
use hkdf::Hkdf;
use p256::pkcs8::{DecodePrivateKey, DecodePublicKey};
use pkcs8::der::asn1::OctetStringRef;
use pkcs8::der::{Document, SecretDocument};
use pkcs8::spki::{ObjectIdentifier, SubjectPublicKeyInfoRef};
use pkcs8::PrivateKeyInfoRef;
use sha2::Sha256;
const X25519_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.101.110");
/// RFC 8410: the PKCS#8 privateKey field wraps the 32 raw bytes in one more OCTET STRING.
fn x25519_private_key(pem: &str) -> Result<x25519_dalek::StaticSecret, Box<dyn Error>> {
let (_, document) = SecretDocument::from_pem(pem)?;
let info: PrivateKeyInfoRef = document.decode_msg()?;
if info.algorithm.oid != X25519_OID {
return Err("not an X25519 private key".into());
}
let inner: &OctetStringRef = info.private_key.decode_into()?;
let raw: [u8; 32] = inner.as_bytes().try_into()?;
Ok(x25519_dalek::StaticSecret::from(raw))
}
fn x25519_public_key(pem: &str) -> Result<x25519_dalek::PublicKey, Box<dyn Error>> {
let (_, document) = Document::from_pem(pem)?;
let spki: SubjectPublicKeyInfoRef = document.decode_msg()?;
if spki.algorithm.oid != X25519_OID {
return Err("not an X25519 public key".into());
}
let raw: [u8; 32] = spki.subject_public_key.as_bytes().ok_or("bad BIT STRING")?.try_into()?;
Ok(x25519_dalek::PublicKey::from(raw))
}
/// HKDF-SHA256, empty salt, 32-byte output.
fn derive_key(shared_secret: &[u8]) -> Result<[u8; 32], hkdf::InvalidLength> {
let mut key = [0u8; 32];
Hkdf::<Sha256>::new(None, shared_secret).expand(b"AES-256-GCM key v1", &mut key)?;
Ok(key)
}
fn main() -> Result<(), Box<dyn Error>> {
let alice = x25519_private_key(&fs::read_to_string("alice-x25519.pem")?)?;
let bob = x25519_public_key(&fs::read_to_string("bob-x25519.pub.pem")?)?;
let shared = alice.diffie_hellman(&bob);
if !shared.was_contributory() {
return Err("X25519 produced the all-zero secret: reject this peer key".into());
}
println!("X25519 shared secret: {}", hex::encode(shared.as_bytes()));
println!("X25519 derived key: {}", hex::encode(derive_key(shared.as_bytes())?));
let alice = p256::SecretKey::from_pkcs8_pem(&fs::read_to_string("alice-p256.pem")?)?;
let bob = p256::PublicKey::from_public_key_pem(&fs::read_to_string("bob-p256.pub.pem")?)?;
let shared = p256::ecdh::diffie_hellman(alice.to_nonzero_scalar(), bob.as_affine());
println!("P-256 shared secret: {}", hex::encode(shared.raw_secret_bytes()));
println!("P-256 derived key: {}", hex::encode(derive_key(shared.raw_secret_bytes())?));
Ok(())
}

How it works

  1. SecretDocument::from_pem and Document::from_pem decode the PEM; PrivateKeyInfoRef and SubjectPublicKeyInfoRef expose the algorithm OID and the key bytes. In an X25519 PKCS#8 key, the 32 bytes are wrapped in one more OCTET STRING (RFC 8410).
  2. StaticSecret::diffie_hellman(&public_key) returns the shared secret; was_contributory() is false when a low-order peer key forced an all-zero result.
  3. For P-256, SecretKey::from_pkcs8_pem and PublicKey::from_public_key_pem read the PEM files, and p256::ecdh::diffie_hellman returns a SharedSecret whose raw_secret_bytes() is the x-coordinate.
  4. Hkdf::<Sha256>::new(None, secret).expand(info, &mut key) derives the key; None means the default all-zero salt.

Gotchas

  • StaticSecret exists only with the static_secrets feature; without it the build fails with cannot find StaticSecret in x25519_dalek. Long-lived keys loaded from files need it — EphemeralSecret cannot be constructed from bytes.
  • x25519-dalek never rejects a low-order peer key: it returns 32 zero bytes. Check was_contributory() before deriving anything, as the code does.
  • Enable ecdh on p256 — the ecdh module and SharedSecret are behind that feature, and pem is needed for the PEM constructors.
  • SharedSecret from p256 also has extract::<Sha256>(salt), which returns an Hkdf directly. It yields the same key; pick one style and use it on both sides.

Related recipes