Crypto recipe

Key exchange with X25519

Derive a shared secret over a public channel with X25519 Diffie-Hellman.

Key exchange lets two parties who have never met derive a shared secret over a public channel, so an eavesdropper learns nothing. X25519 (ECDH on Curve25519) is the modern default.

Two cautions. Never use the raw Diffie-Hellman output directly as an encryption key — run it through a KDF such as HKDF first. And plain X25519 has no authentication, so on its own it is open to a man-in-the-middle; pair it with signatures or a protocol like Noise or TLS in production.

Get it right

  • Use X25519 for new designs; it sidesteps NIST-curve parameter pitfalls.
  • Never use the raw shared secret as a key — derive with HKDF first.
  • Authenticate the exchange (signatures, certificates) to stop man-in-the-middle.
  • Use ephemeral keys for forward secrecy wherever the protocol allows.

Implementation

Setup Standard library (crypto/ecdh, Go 1.20+).

Go
package main

import (
	"crypto/ecdh"
	"crypto/rand"
	"fmt"
)

func main() {
	curve := ecdh.X25519()

	// Each party generates a key pair and publishes its public key.
	alicePriv, _ := curve.GenerateKey(rand.Reader)
	bobPriv, _ := curve.GenerateKey(rand.Reader)

	// Each derives the same secret from its own private + the peer's public key.
	aliceShared, _ := alicePriv.ECDH(bobPriv.PublicKey())
	bobShared, _ := bobPriv.ECDH(alicePriv.PublicKey())

	fmt.Println(string(aliceShared) == string(bobShared)) // true
	// Do NOT use the raw secret as a key — run it through HKDF first.
}

Setup pip install cryptography.

Python
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey

alice_private = X25519PrivateKey.generate()
bob_private = X25519PrivateKey.generate()

# Each side combines its private key with the peer's public key.
alice_shared = alice_private.exchange(bob_private.public_key())
bob_shared = bob_private.exchange(alice_private.public_key())

assert alice_shared == bob_shared
# Pass the shared secret through HKDF before using it as an encryption key.

Setup Built in (node:crypto; diffieHellman since Node 13.9).

Node.js
import { generateKeyPairSync, diffieHellman } from 'node:crypto';

const alice = generateKeyPairSync('x25519');
const bob = generateKeyPairSync('x25519');

// Each side combines its private key with the peer's public key.
const aliceShared = diffieHellman({ privateKey: alice.privateKey, publicKey: bob.publicKey });
const bobShared = diffieHellman({ privateKey: bob.privateKey, publicKey: alice.publicKey });

// aliceShared.equals(bobShared) === true
// Run the shared secret through HKDF before using it as a key.

Setup dotnet add package NSec.Cryptography. .NET's built-in ECDiffieHellman covers NIST P-curves but not X25519.

.NET
using NSec.Cryptography;

var x25519 = KeyAgreementAlgorithm.X25519;

using Key alice = Key.Create(x25519);
using Key bob = Key.Create(x25519);

using SharedSecret aliceShared = x25519.Agree(alice, bob.PublicKey);

// Derive a real key from the shared secret with HKDF — don't use it directly.
byte[] key = KeyDerivationAlgorithm.HkdfSha256.DeriveBytes(
    aliceShared, ReadOnlySpan<byte>.Empty, ReadOnlySpan<byte>.Empty, 32);

Setup libsodium (crypto_kx wraps X25519 with a built-in KDF).

C++
#include <sodium.h>

int main() {
    if (sodium_init() < 0) return 1;

    unsigned char client_pk[crypto_kx_PUBLICKEYBYTES], client_sk[crypto_kx_SECRETKEYBYTES];
    unsigned char server_pk[crypto_kx_PUBLICKEYBYTES], server_sk[crypto_kx_SECRETKEYBYTES];
    crypto_kx_keypair(client_pk, client_sk);
    crypto_kx_keypair(server_pk, server_sk);

    // crypto_kx derives two session keys (rx/tx) from the X25519 exchange via
    // BLAKE2b — so there's no separate KDF step to remember.
    unsigned char client_rx[crypto_kx_SESSIONKEYBYTES], client_tx[crypto_kx_SESSIONKEYBYTES];
    crypto_kx_client_session_keys(client_rx, client_tx, client_pk, client_sk, server_pk);

    unsigned char server_rx[crypto_kx_SESSIONKEYBYTES], server_tx[crypto_kx_SESSIONKEYBYTES];
    crypto_kx_server_session_keys(server_rx, server_tx, server_pk, server_sk, client_pk);
    // client_tx == server_rx and client_rx == server_tx
}

Setup Built in since Java 11 (XDH, JEP 324).

Java
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import javax.crypto.KeyAgreement;

KeyPairGenerator kpg = KeyPairGenerator.getInstance("X25519");
KeyPair alice = kpg.generateKeyPair();
KeyPair bob = kpg.generateKeyPair();

KeyAgreement ka = KeyAgreement.getInstance("X25519");
ka.init(alice.getPrivate());
ka.doPhase(bob.getPublic(), true);
byte[] sharedSecret = ka.generateSecret();
// Feed sharedSecret into HKDF before using it as a key.

Setup Cargo.toml: x25519-dalek = "2", rand = "0.8".

Rust
use rand::rngs::OsRng;
use x25519_dalek::{EphemeralSecret, PublicKey};

fn main() {
    let alice_secret = EphemeralSecret::random_from_rng(OsRng);
    let alice_public = PublicKey::from(&alice_secret);

    let bob_secret = EphemeralSecret::random_from_rng(OsRng);
    let bob_public = PublicKey::from(&bob_secret);

    let alice_shared = alice_secret.diffie_hellman(&bob_public);
    let bob_shared = bob_secret.diffie_hellman(&alice_public);

    assert_eq!(alice_shared.as_bytes(), bob_shared.as_bytes());
    // Run alice_shared.as_bytes() through HKDF before using it as a key.
}