Skip to content
ecdsa.com

ECDH key agreement · Java

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

Java derives a shared key with ECDH using the JDK alone: javax.crypto.KeyAgreement computes the X25519 ("XDH") or P-256 ("ECDH") shared secret, and since Java 25 the javax.crypto.KDF API provides HKDF without Bouncy Castle. The recipe uses the RFC 7748 and RFC 5903 test keys, so the shared secrets match the published values.

Tested with OpenJDK 25.0.4.1 (Eclipse Temurin 25.0.4.1+1, SunEC and SunJCE (javax.crypto.KDF)).

Ecdh.java — X25519 and P-256 agreement from PEM files, then HKDF-SHA256
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.HexFormat;
import javax.crypto.KDF;
import javax.crypto.KeyAgreement;
import javax.crypto.spec.HKDFParameterSpec;
public class Ecdh {
static byte[] pemBody(String file) throws Exception {
return Base64.getMimeDecoder().decode(Files.readString(Path.of(file)).replaceAll("-----[^-]+-----", ""));
}
/** keyType "XDH" + agreement "XDH" for X25519; "EC" + "ECDH" for P-256. */
static byte[] agree(String keyType, String agreement, String privateKeyFile, String peerPublicKeyFile) throws Exception {
KeyFactory factory = KeyFactory.getInstance(keyType);
KeyAgreement ka = KeyAgreement.getInstance(agreement);
ka.init(factory.generatePrivate(new PKCS8EncodedKeySpec(pemBody(privateKeyFile))));
ka.doPhase(factory.generatePublic(new X509EncodedKeySpec(pemBody(peerPublicKeyFile))), true);
return ka.generateSecret(); // the raw shared secret
}
/** HKDF-SHA256 with the KDF API of Java 25 (JEP 510); no salt means an all-zero salt. */
static byte[] deriveKey(byte[] sharedSecret) throws Exception {
KDF hkdf = KDF.getInstance("HKDF-SHA256");
return hkdf.deriveData(HKDFParameterSpec.ofExtract()
.addIKM(sharedSecret)
.thenExpand("AES-256-GCM key v1".getBytes(StandardCharsets.UTF_8), 32));
}
public static void main(String[] args) throws Exception {
HexFormat hex = HexFormat.of();
byte[] x = agree("XDH", "XDH", "alice-x25519.pem", "bob-x25519.pub.pem");
System.out.println("X25519 shared secret: " + hex.formatHex(x));
System.out.println("X25519 derived key: " + hex.formatHex(deriveKey(x)));
byte[] p = agree("EC", "ECDH", "alice-p256.pem", "bob-p256.pub.pem");
System.out.println("P-256 shared secret: " + hex.formatHex(p));
System.out.println("P-256 derived key: " + hex.formatHex(deriveKey(p)));
}
}
derive the key as a SecretKey for AES-GCM instead of bytes
// The same derivation, returned as a SecretKey ready for Cipher.getInstance("AES/GCM/NoPadding")
SecretKey aesKey = KDF.getInstance("HKDF-SHA256").deriveKey("AES",
HKDFParameterSpec.ofExtract().addIKM(sharedSecret)
.thenExpand("AES-256-GCM key v1".getBytes(StandardCharsets.UTF_8), 32));

How it works

  1. X25519 keys use KeyFactory.getInstance("XDH") and KeyAgreement.getInstance("XDH"); P-256 keys use "EC" and "ECDH". Both read PKCS#8 and SPKI through the usual PKCS8EncodedKeySpec and X509EncodedKeySpec.
  2. init(privateKey), then doPhase(peerPublicKey, true), then generateSecret() returns the raw shared secret.
  3. KDF.getInstance("HKDF-SHA256") with HKDFParameterSpec.ofExtract().addIKM(secret).thenExpand(info, 32) runs RFC 5869 extract-and-expand; without addSalt, the salt is the default string of zero bytes.
  4. deriveKey("AES", …) returns the same 32 bytes as a SecretKey, ready for Cipher.getInstance("AES/GCM/NoPadding").

Gotchas

  • javax.crypto.KDF is final in Java 25 (JEP 510) and absent from Java 21 and earlier. There, use Bouncy Castle's HKDFBytesGenerator, or two rounds of Mac.getInstance("HmacSHA256") (extract, then expand).
  • Older tutorials call ka.generateSecret("AES") to get a key straight from the agreement. On current JDKs that throws NoSuchAlgorithmException: Unsupported secret key algorithm: AES for both ECDH and XDH — and it never applied a KDF anyway. Take the raw secret and run HKDF.
  • A low-order X25519 peer key (all zeros) makes doPhase throw java.security.InvalidKeyException: Point has small order; treat it as a rejected peer.
  • KeyFactory.getInstance("EC") cannot read X25519 keys — they are XEC keys in JCA. Use "XDH" (or "X25519") for the factory and the agreement alike.

Related recipes