Skip to content
ecdsa.com

ECDH key agreement · Ruby

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

Ruby derives a shared key with ECDH through the openssl default gem: PKey#derive computes the X25519 or P-256 shared secret, and OpenSSL::KDF.hkdf turns it into a key. One method covers both curves, and the RFC 7748 and RFC 5903 test keys produce the published secrets.

Tested with Ruby 4.0.7 (openssl gem 4.0.2, OpenSSL 3.5.7).

agree.rb — X25519 and P-256 agreement from PEM files, then HKDF-SHA256
require "openssl"
# One method for both curves: PKey#derive (openssl gem 3.0+) works for X25519 and EC keys
def agree(private_key_file, peer_public_key_file)
private_key = OpenSSL::PKey.read(File.read(private_key_file))
peer_key = OpenSSL::PKey.read(File.read(peer_public_key_file))
private_key.derive(peer_key)
end
def derive_key(shared_secret)
# salt: is a required keyword; an empty string means the RFC 5869 default
OpenSSL::KDF.hkdf(shared_secret, salt: "", info: "AES-256-GCM key v1", length: 32, hash: "SHA256")
end
{ "X25519" => "x25519", "P-256" => "p256" }.each do |curve, name|
secret = agree("alice-#{name}.pem", "bob-#{name}.pub.pem")
puts "#{curve} shared secret: #{secret.unpack1("H*")}"
puts "#{curve} derived key: #{derive_key(secret).unpack1("H*")}"
end
a live exchange: fresh key pairs, PEM public keys on the wire
require "openssl"
# In a live exchange each side makes a fresh key pair and sends only its public key
alice = OpenSSL::PKey.generate_key("X25519") # or OpenSSL::PKey::EC.generate("prime256v1")
bob = OpenSSL::PKey.generate_key("X25519")
from_alice = alice.public_to_pem # what travels over the wire
from_bob = bob.public_to_pem
alice_secret = alice.derive(OpenSSL::PKey.read(from_bob))
bob_secret = bob.derive(OpenSSL::PKey.read(from_alice))
puts "both sides agree: #{alice_secret == bob_secret}"

How it works

  1. OpenSSL::PKey.read loads both the private key and the peer's public key; X25519 keys come back as generic OpenSSL::PKey::PKey, P-256 keys as OpenSSL::PKey::EC.
  2. private_key.derive(peer_key) returns the raw shared secret for either type.
  3. OpenSSL::KDF.hkdf(secret, salt: "", info: …, length: 32, hash: "SHA256") derives the key; the empty salt is the RFC 5869 default.
  4. For a live exchange, OpenSSL::PKey.generate_key("X25519") creates a key pair and public_to_pem gives the part that is sent.

Gotchas

  • All keywords of OpenSSL::KDF.hkdf are required: leaving out salt: raises ArgumentError: missing keyword: :salt. Pass "" for the default.
  • Older code uses ec_key.dh_compute_key(peer.public_key), which works only for EC keys and takes a point, not a key. derive works for both curves and is the method to use.
  • Keys of different types fail with OpenSSL::PKey::PKeyError: EVP_PKEY_derive_set_peer: operation not supported for this keytype.
  • A malicious all-zero X25519 peer key raises OpenSSL::PKey::PKeyError: EVP_PKEY_derive: failed during derivation; rescue it and reject the peer.

Related recipes