Table of Contents

Key agreement with X25519

X25519 is the elliptic-curve Diffie-Hellman (ECDH) key-agreement function of RFC 7748, performing scalar multiplication on the Montgomery form of Curve25519. Two parties each generate a key pair, exchange public keys, and independently arrive at the same 32-byte shared secret without ever transmitting it. This guide is for developers establishing a shared secret to bootstrap a symmetric session.

X25519 is key agreement only - it produces no ciphertext and no signature. To sign messages, see Ed25519; for a quantum-resistant alternative to this exchange, see ML-KEM.

Fixed sizes at a glance

Parameter Size Constant
Private key 32 bytes X25519.KeySizeInBytes
Public key 32 bytes X25519.KeySizeInBytes
Shared secret 32 bytes X25519.SharedSecretSizeInBytes
Security level 128 bits -

Both key halves are 32 bytes; the public key is the little-endian u-coordinate of the scalar multiple of the base point.

The two-party exchange

Each party calls GenerateKey(), sends its public key over the (untrusted) wire, and calls DeriveSharedSecret(ReadOnlySpan<byte>) with the peer's public key. The two derivations produce identical bytes. A span overload, DeriveSharedSecret(peerPublicKey, destination), writes the 32-byte secret into a caller-supplied buffer without allocating - and zeroes that buffer if the peer point is rejected (below).

using Bodu.Security.Cryptography;

using var alice = X25519.Create();
using var bob = X25519.Create();
alice.GenerateKey();
bob.GenerateKey();

// Each side exports its public key and sends it to the other.
byte[] alicePublic = alice.ExportPublicKey();   // 32 bytes
byte[] bobPublic = bob.ExportPublicKey();       // 32 bytes

// Each side derives the secret from the peer's public key.
byte[] aliceShared = alice.DeriveSharedSecret(bobPublic);
byte[] bobShared = bob.DeriveSharedSecret(alicePublic);

// aliceShared and bobShared are identical (32 bytes each).

Importing a peer's public key

In practice you receive the peer's public key as raw bytes. There is no need to construct a peer X25519 instance: DeriveSharedSecret accepts the 32-byte public key directly.

using Bodu.Security.Cryptography;

byte[] peerPublic = ReceivePeerPublicKey();   // 32 bytes off the wire

using var local = X25519.Create();
local.GenerateKey();

byte[] shared = local.DeriveSharedSecret(peerPublic);

If you do hold the peer key as a separate instance - for example to keep it pinned - import it with ImportPublicKey(ReadOnlySpan<byte>). Importing a public key onto an instance discards any private key it held, leaving a public-only instance (HasPublicKey true, HasPrivateKey false) that can export but cannot derive - calling DeriveSharedSecret on it throws CryptographicException.

using var peer = X25519.Create();
peer.ImportPublicKey(peerPublic);
byte[] pinned = peer.ExportPublicKey();   // round-trips byte-for-byte

To persist your own key pair across runs, export the private key and re-import it later:

byte[] storedPrivate = local.ExportPrivateKey();   // keep secret

using var restored = X25519.Create();
restored.ImportPrivateKey(storedPrivate);          // public key is re-derived

Derive the secret, then run a KDF - do not use it directly

The shared secret is a raw curve point coordinate, not uniform key material. Never use it directly as an AES or ChaCha20 key. Pass it through a key derivation function (KDF) - such as HKDF, or Blake2b in keyed mode, or a memory-hard KDF like Argon2 - to produce a uniformly random, context-bound symmetric key.

A salt and an application-specific info / context string bind the derived key to its purpose and prevent the same secret from yielding the same key in two unrelated contexts.

using System.Security.Cryptography;
using Bodu.Security.Cryptography;

using var alice = X25519.Create();
using var bob = X25519.Create();
alice.GenerateKey();
bob.GenerateKey();

byte[] shared = alice.DeriveSharedSecret(bob.ExportPublicKey());

// Stretch the raw secret into a 32-byte session key with HKDF-SHA256.
byte[] sessionKey = Hkdf.DeriveKey(
    HashAlgorithmName.SHA256,
    inputKeyingMaterial: shared,
    outputLength: 32,
    salt: default,
    info: "myapp v1 session key"u8);

// 'sessionKey' is now safe to use with a symmetric AEAD (e.g. ChaCha20-Poly1305).
CryptographicOperations.ZeroMemory(shared);   // wipe the raw secret once stretched

Low-order point rejection

A small set of low-order peer public keys force the shared secret to an all-zero value that an observer can predict without knowing the private key. DeriveSharedSecret applies the RFC 7748 §6.1 strict check and throws CryptographicException rather than returning attacker-predictable key material. You do not need to add your own check; just be prepared for the exception on hostile input.

What X25519 is not

  • Not a signature scheme. It proves nothing about who you agreed a key with. Authenticate the exchange (e.g. sign the public keys with Ed25519) to prevent a man-in-the-middle.
  • Not quantum-resistant. A future quantum computer breaks the discrete-log problem X25519 rests on. For long-lived secrets, combine it with ML-KEM.
  • Not a symmetric key as-is. The raw secret must go through a KDF before use, as shown above.

See also