Ephemeral elliptic curve Diffie-Hellman key agreement in Java

Update 2 (17th May, 2017): I’ve written some notes on correctly validating ECDH public keys

Update (20th April, 2017): I’ve noticed that this article gets by far the most daily hits on my blog. This worries me that people are using this code as a template for building real ECDHE key agreement, when it was only intended as a guide to the Java API. There are a lot of details in safe construction of such a protocol. More secure alternatives than to trying to roll this yourself include the various complete protocols listed at the end of the article. With that said, we’ll get back to the original article:

Diffie-Hellman key agreement (DH) is a way for two parties to agree on a symmetric secret key without explicitly communicating that secret key. As such, it provides a way for the parties to negotiate a shared AES cipher key or HMAC shared secret over a potentially insecure channel. It does not by itself provide authentication, however, so it is vulnerable to man-in-the-middle attacks without additional measures. There are several ways to provide these additional measures (e.g. signing the ephemeral public keys using a CA-issued certificate, or using a protocol like OTR), but we will not discuss them here, or go into the details of how the key agreement works. Java provides support out-of-the-box for both original discrete log DH and elliptic curve (ECDH) key agreement protocols, although the latter may not be supported on all JREs. ECDH should be preferred for any new applications as it provides significantly improved security for reasonable key sizes.

elliptic curve
An elliptic curve defined by y2 = x3 – 2x + 2
As is often the case in Java, the use of these classes can be a bit convoluted. Here we demonstrate simple Java code for ECDH key agreement on the command line. We only demonstrate ephemeral key agreement, in which the two parties generate unique public/private key pairs at the start of the protocol and throw them away once the shared secret has been negotiated. This can form the basis for perfect forward secrecy.

WARNING: the code here is not a complete security protocol and should be used for reference on the Java API only.

The Code

The complete code example is as follows:

import java.security.*;
import java.security.spec.ECParameterSpec;
import java.security.spec.X509EncodedKeySpec;
import java.security.interfaces.ECPublicKey;

import javax.crypto.KeyAgreement;

import java.util.*;
import java.nio.ByteBuffer;
import java.io.Console;

import static javax.xml.bind.DatatypeConverter.printHexBinary;
import static javax.xml.bind.DatatypeConverter.parseHexBinary;

public class ecdh {

  public static void main(String[] args) throws Exception {
    Console console = System.console();
    // Generate ephemeral ECDH keypair
    KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
    kpg.initialize(256);
    KeyPair kp = kpg.generateKeyPair();
    byte[] ourPk = kp.getPublic().getEncoded();

    // Display our public key
    console.printf("Public Key: %s%n", printHexBinary(ourPk));

    // Read other's public key:
    byte[] otherPk = parseHexBinary(console.readLine("Other PK: "));

    KeyFactory kf = KeyFactory.getInstance("EC");
    X509EncodedKeySpec pkSpec = new X509EncodedKeySpec(otherPk);
    PublicKey otherPublicKey = kf.generatePublic(pkSpec);

    // Perform key agreement
    KeyAgreement ka = KeyAgreement.getInstance("ECDH");
    ka.init(kp.getPrivate());
    ka.doPhase(otherPublicKey, true);

    // Read shared secret
    byte[] sharedSecret = ka.generateSecret();
    console.printf("Shared secret: %s%n", printHexBinary(sharedSecret));

    // Derive a key from the shared secret and both public keys
    MessageDigest hash = MessageDigest.getInstance("SHA-256");
    hash.update(sharedSecret);
    // Simple deterministic ordering
    List<ByteBuffer> keys = Arrays.asList(ByteBuffer.wrap(ourPk), ByteBuffer.wrap(otherPk));
    Collections.sort(keys);
    hash.update(keys.get(0));
    hash.update(keys.get(1));

    byte[] derivedKey = hash.digest();
    console.printf("Final key: %s%n", printHexBinary(derivedKey));
  }
}

To use the example, compile it and then run two instances of it (possibly on different computers). It will print out a hex-encoded ephemeral public key value and then wait for the public key from the other instance. Simply copy+paste this values from one to the other (and vice-versa) and then it will print out the computed shared secret, again hex-encoded and finally a secret key derived from the shared secret.

Let’s walk through the example step-by-step. Firstly, we import a vast number of different classes. We’ll discuss what all of these are for when we get to them.

Step 1: Generate ephemeral ECDH key pair

The first step is to generate an ephemeral elliptic curve key pair for use in the algorithm. We do this using the aptly-named KeyPairGenerator using the “EC” algorithm name to select Elliptic Curve key generation:

    // Generate ephemeral ECDH keypair
    KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
    kpg.initialize(256);
    KeyPair kp = kpg.generateKeyPair();
    byte[] ourPk = kp.getPublic().getEncoded();

By setting the key size to 256-bits, Java will select the NIST P-256 curve parameters (secp256r1). For other key sizes, it will choose other NIST standard curves, e.g. P-384, P-521. If you wish to use different parameters, then you must specify them explicitly using the ECGenParameterSpec argument. 

Step 2: Exchange the public keys

The next step is to send our public key to the other party and to receive their public key. In this case, we achieve this by simply printing them out and requiring a human being to perform the communication. No authentication is performed. This means that we cannot be sure that the public key we received is really from the other party and not from a man-in-the-middle.

    // Display our public key
    console.printf("Public Key: %s%n", printHexBinary(ourPk));

    // Read other's public key:
    byte[] otherPk = parseHexBinary(console.readLine("Other PK: "));

    KeyFactory kf = KeyFactory.getInstance("EC");
    X509EncodedKeySpec pkSpec = new X509EncodedKeySpec(otherPk);
    PublicKey otherPublicKey = kf.generatePublic(pkSpec);

We assume that the other party is also using a NIST P-256 curve public key. We also assume that the output of PublicKey.getEncoded() is an X.509-encoded key. This turns out to be true in the Oracle JRE, but I cannot find any documented guarantee of this behaviour. A more robust approach would be to communicate the ECPoint and ECParameterSpec of the public key, and use an ECPublicKeySpec to reconstruct the key, but that is even more work.

Step 3: Perform key agreement

The actual ECDH key agreement is straightforward once we have exchanged public keys.

    // Perform key agreement
    KeyAgreement ka = KeyAgreement.getInstance("ECDH");
    ka.init(kp.getPrivate());
    ka.doPhase(otherPublicKey, true);

We grab an instance of the ECDH key agreement protocol. The first step is to initialise it with our private key. Then we pass it the other party’s public key via the doPhase() method. We pass true as the second argument to indicate that this is the last phase of the agreement (it is the only phase in ECDH). Diffie-Hellman works by calculating a shared secret based on our private key and the other party’s public key, so this is all we need in this case. The magic of DH is that each party will calculate the same value despite having different sets of keys available to them. Nobody listening in on the exchange can calculate the shared secret unless they have access to one of the private keys (which are never communicated).

Step 4: Extract the shared secret and derive keys

The final step is to extract the shared secret and then derive a key from it.

Note that it is not advisable to use the shared secret directly as a symmetric key for various reasons. In particular, while the derived secret is indistinguishable from a randomly selected element from the set of all possible outputs of the elliptic curve group, this is not the same thing as a uniformly random string of bits. Viewed as a string of bits, it will have some structure to it. Put another way, the P-256 curve provides roughly equivalent security to a 128-bit secret key, yet the output shared secret is 256 bits. This reveals that the shared secret does not really provide 256 bits of “random” key data. There are further reasons for not using the shared secret directly, depending on the usage. For instance, the security considerations section of RFC 7748 advises to derive a key from the shared secret plus both public keys if we intend to use the key for authentication (this RFC uses different curves than we use here, but it is good advice regardless):

Designers using these curves should be aware that for each public
key, there are several publicly computable public keys that are
equivalent to it, i.e., they produce the same shared secrets. Thus
using a public key as an identifier and knowledge of a shared secret
as proof of ownership (without including the public keys in the key
derivation) might lead to subtle vulnerabilities.

We adopt the approach described in the libsodium documentation, of deriving a key by hashing the shared secret and both public keys, but using SHA-256 rather than BLAKE2. These choices of algorithms and curves are purely for convenience because they are readily available on the JVM without 3rd party libraries (e.g. Bouncy Castle). The only trickiness is to ensure that we feed the public keys into the hash in the same order on both sides of the agreement protocol. For simplicity, we do this by just sorting them lexicographically.

    // Read shared secret
    byte[] sharedSecret = ka.generateSecret();
    console.printf("Shared secret: %s%n", printHexBinary(sharedSecret));

    // Derive a key from the shared secret and both public keys
    MessageDigest hash = MessageDigest.getInstance("SHA-256");
    hash.update(sharedSecret);
    // Simple deterministic ordering
    List<ByteBuffer> keys = Arrays.asList(ByteBuffer.wrap(ourPk), ByteBuffer.wrap(otherPk));
    Collections.sort(keys);
    hash.update(keys.get(0));
    hash.update(keys.get(1));

    byte[] derivedKey = hash.digest();
    console.printf("Final key: %s%n", printHexBinary(derivedKey));

Et voila! Both parties now have the same derived key, which can be used for an AES cipher or HMAC key or however you wish. A more sophisticated key derivation function, such as HKDF, can be used to derive further keys (for instance, separate keys sending data in each direction, which is recommended).

There are a number of complete protocols that build upon this basic agreement mechanism, adding authentication and other details:

  • TLS includes this as the various ECDHE_ cipher suites, such as ECDHE_ECDSA_WITH_AES256_GCM_SHA384 (in TLS 1.2 or earlier, TLS 1.3 cipher suites only specify the symmetric encryption component, with key agreement and authentication being negotiated via extensions instead), which means ephemeral ECDH where the exchanged ephemeral public keys are signed using long-term ECDSA keys to provide authentication, and then using the derived secret as a 256-bit key for AES-GCM authenticated encryption. TLS 1.3 mandates cipher suites that support forward-secrecy, including ECDHE. It also has some optimisations to reduce the overhead of the TLS ECDHE handshake, including support for “0-RTT” (zero round-trip time) that allows a client that has previously talked to a server to start a new session and send encrypted traffic on the very first message.
  • Protocols like Noise or CurveCP (or CurveZMQ).
  • The JSON Web Token (JWT) specs describe a ECDH-ES (ephemeral-static) key agreement and key derivation approach. In this case the recipient has a long-term (static) public key and the sender uses an ephemeral key-pair. This allows the recipient to immediately send a message, without an interactive handshake (i.e., the recipient can be offline, as in email), and it also authenticates the recipient if the long-term public key can be trusted (e.g. via a CA signature, web of trust, etc). However, it gives up forward secrecy, as any compromise of the recipient’s long-term private key will allow decryption of all previous messages sent to that recipient under that key pair. It should be possible to build something like TLS’s 0-RTT mechanism on top of this, but I don’t know of a fully worked out design (and I wouldn’t attempt it unless you know what you are doing).
Advertisements

Author: Neil Madden

Security Director at ForgeRock. I have approaching 20 years of professional software development experience in commercial, government and academic settings. I have a PhD and 1st-class honours degree in Computer Science.