Skip to content
ecdsa.com

ECDH key agreement · PHP

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

PHP derives a shared key with ECDH using bundled extensions: openssl_pkey_derive computes the P-256 or X25519 shared secret from PEM keys, and hash_hkdf turns it into a key. ext-sodium offers the same X25519 agreement on raw keys, as the run with the RFC 7748 and RFC 5903 test keys shows.

Tested with PHP 8.5.11 (ext-openssl (OpenSSL 3.5.7), ext-sodium, ext-hash).

openssl.php — X25519 and P-256 agreement from PEM files, then HKDF-SHA256
<?php
// ext-openssl reads EC and (since PHP 8.4) Curve25519 keys; the key type comes from the PEM
function agree(string $privateKeyFile, string $peerPublicKeyFile): string
{
$privateKey = openssl_pkey_get_private(file_get_contents($privateKeyFile));
$peerKey = openssl_pkey_get_public(file_get_contents($peerPublicKeyFile));
$secret = openssl_pkey_derive($peerKey, $privateKey); // peer's public key comes first
if ($secret === false) {
throw new RuntimeException(openssl_error_string() ?: 'key agreement failed');
}
return $secret;
}
foreach (['X25519' => 'x25519', 'P-256' => 'p256'] as $curve => $name) {
$secret = agree("alice-$name.pem", "bob-$name.pub.pem");
// hash_hkdf(algo, key material, length, info, salt) — info comes before salt
$key = hash_hkdf('sha256', $secret, 32, 'AES-256-GCM key v1', '');
echo "$curve shared secret: ", bin2hex($secret), PHP_EOL;
echo "$curve derived key: ", bin2hex($key), PHP_EOL;
}
sodium.php — the same X25519 secret with ext-sodium
<?php
// ext-sodium works on raw 32-byte X25519 keys
$alice = openssl_pkey_get_details(openssl_pkey_get_private(file_get_contents('alice-x25519.pem')));
$bob = openssl_pkey_get_details(openssl_pkey_get_public(file_get_contents('bob-x25519.pub.pem')));
$secret = sodium_crypto_scalarmult($alice['x25519']['priv_key'], $bob['x25519']['pub_key']);
echo 'sodium X25519 shared secret: ', bin2hex($secret), PHP_EOL;

How it works

  1. openssl_pkey_derive($peerPublicKey, $privateKey) takes the peer's public key first and returns the raw shared secret, or false.
  2. hash_hkdf('sha256', $secret, 32, $info, $salt) derives the key. An empty salt is the RFC 5869 default.
  3. The same function covers both curves, because the key type comes from the PEM files.
  4. For ext-sodium, openssl_pkey_get_details exposes the raw X25519 bytes (['x25519']['priv_key'], ['pub_key'], PHP 8.4+), and sodium_crypto_scalarmult computes the same secret from them.

Gotchas

  • The argument order is public key first. Swapped arguments return false and leave openssl_error_string() empty, so the failure carries no message at all.
  • hash_hkdf takes info before salt — the reverse of RFC 5869's order and of most other libraries. Passing the purpose string as the fifth argument makes it the salt and derives a different key.
  • Curves must match: a P-256 public key with an X25519 private key fails with error:03000096:digital envelope routines::operation not supported for this keytype.
  • A malicious all-zero X25519 peer key makes openssl_pkey_derive return false (failed during derivation) and makes sodium_crypto_scalarmult throw SodiumException: internal error. Both mean: reject the peer.

Related recipes