On this page
Why two sides need a shared secret
To encrypt with AES-GCM, both sides need the same secret key. My post AES-GCM and why a nonce must never repeat starts from a key that both sides already have. This post shows how they can get one.
The two sides may never have met, and anyone on the network path can read their traffic. A key exchange solves this. Each side sends one public value, and both sides then compute the same secret. A listener who sees both values cannot compute it.
X25519 is a key exchange of this kind, defined in RFC 7748 (opens in a new tab) in January 2016. TLS 1.3, the protocol behind HTTPS, recommends that implementations support it (RFC 9846, section 9.1 (opens in a new tab)).
The idea in plain words
Whitfield Diffie and Martin Hellman published the idea in 1976, in New Directions in Cryptography (opens in a new tab). It needs an operation that is easy to do and very hard to undo.
Everyone knows a fixed starting point on a curve. Alice picks a large secret number, a. She uses the rules of the curve to "multiply" the starting point by a, and the new point is her public key. Bob does the same with his secret number, b. Going forward is fast. Going back to the secret number is a hard problem, called the elliptic curve discrete logarithm problem.
Now the trick. Alice multiplies Bob's public point by a. Bob multiplies Alice's public point by b. Both get the same point, because a times b equals b times a. A listener has both public points but neither secret, so the last step is out of reach. Figure 1 shows the flow with the values from the example in the RFC.
How X25519 works
X25519 takes two inputs of 32 bytes each. The first is the secret number, called the scalar. The second is a u-coordinate, a single number that names a point on the curve. The output is a new u-coordinate, also 32 bytes.
The curve is Curve25519, from a 2006 paper (opens in a new tab) by Daniel J. Bernstein. All the math is modulo the prime 2 to the power 255 minus 19, which gives the curve its name. Modulo means that you keep only the remainder after you divide by the prime. Bytes are little-endian, so the least significant byte comes first.
The starting point has u = 9: one byte with the value 9, then 31 zero bytes. Section 6.1 of the RFC uses it like this:
- Alice picks 32 random bytes, a, and sends
A = X25519(a, 9). - Bob picks 32 random bytes, b, and sends
B = X25519(b, 9). - Alice computes
X25519(a, B). - Bob computes
X25519(b, A).
Steps 3 and 4 give the same 32 bytes, the shared secret K.
Clamping the private key
Before X25519 uses the 32 random bytes as a number, it changes a few bits. This is called clamping (RFC 7748, section 5 (opens in a new tab)):
- Clear the 3 lowest bits of the first byte. The number becomes a multiple of 8.
- Clear the highest bit of the last byte.
- Set the second highest bit of the last byte.
The multiple of 8 protects against one kind of fake public key. The curve has a few points in small groups of 2, 4 or 8 points. An attacker can send one of them as a public key. The result then depends only on the low bits of the private key. With a multiple of 8, the result is always zero, so nothing leaks (see Bernstein's paper). The fixed top bit gives every key the same length. So a loop that starts at the highest 1 bit always runs the same number of steps.
I checked this with a point from a small group of 8 points, taken from Bernstein's list (opens in a new tab). With clamping, 100 random keys all gave 32 zero bytes. When I kept the 3 low bits, the result fell into four groups, set by those bits. Without the first clamping line, Alice and Bob still agreed with each other, but the RFC tests failed. So you cannot skip clamping.
A readable X25519 in C#
The C# below follows the pseudocode in RFC 7748 line by line. It uses System.Numerics.BigInteger, so it stays short and easy to compare with the RFC.
static class X25519
{
static readonly BigInteger P = BigInteger.Pow(2, 255) - 19;
const int A24 = 121665; // (486662 - 2) / 4
// Clamping, RFC 7748 section 5.
static BigInteger DecodeScalar(ReadOnlySpan<byte> scalar)
{
Span<byte> k = stackalloc byte[32];
scalar.CopyTo(k);
k[0] &= 248; // clear the 3 lowest bits: a multiple of 8
k[31] &= 127; // clear bit 255
k[31] |= 64; // set bit 254
return new BigInteger(k, isUnsigned: true, isBigEndian: false);
}
// A u-coordinate is 32 bytes, little-endian. The top bit is ignored.
static BigInteger DecodeU(ReadOnlySpan<byte> bytes)
{
Span<byte> u = stackalloc byte[32];
bytes.CopyTo(u);
u[31] &= 127;
return new BigInteger(u, isUnsigned: true, isBigEndian: false) % P;
}
static byte[] EncodeU(BigInteger u)
{
byte[] bytes = new byte[32];
u.TryWriteBytes(bytes, out _, isUnsigned: true, isBigEndian: false);
return bytes;
}
static BigInteger Mod(BigInteger v)
{
v %= P;
return v.Sign < 0 ? v + P : v;
}
// The Montgomery ladder, RFC 7748 section 5.
public static byte[] Compute(ReadOnlySpan<byte> scalar, ReadOnlySpan<byte> uBytes)
{
BigInteger k = DecodeScalar(scalar);
BigInteger x1 = DecodeU(uBytes);
BigInteger x2 = 1, z2 = 0, x3 = x1, z3 = 1;
int swap = 0;
for (int t = 254; t >= 0; t--)
{
int kt = (int)((k >> t) & 1);
swap ^= kt;
if (swap == 1) { (x2, x3) = (x3, x2); (z2, z3) = (z3, z2); }
swap = kt;
BigInteger a = Mod(x2 + z2), aa = Mod(a * a);
BigInteger b = Mod(x2 - z2), bb = Mod(b * b);
BigInteger e = Mod(aa - bb);
BigInteger c = Mod(x3 + z3), d = Mod(x3 - z3);
BigInteger da = Mod(d * a), cb = Mod(c * b);
x3 = Mod((da + cb) * (da + cb));
z3 = Mod(x1 * Mod((da - cb) * (da - cb)));
x2 = Mod(aa * bb);
z2 = Mod(e * (aa + A24 * e));
}
if (swap == 1) { (x2, x3) = (x3, x2); (z2, z3) = (z3, z2); }
// x2 divided by z2. z2 to the power p - 2 is the inverse of z2.
return EncodeU(Mod(x2 * BigInteger.ModPow(z2, P - 2, P)));
}
}The loop is called the Montgomery ladder. It reads the key one bit at a time, from bit 254 down to bit 0. At each bit, it may swap two points, and then it does one point doubling and one point addition. At the end, the code must divide by z2. Raising z2 to the power p minus 2 gives 1 divided by z2, modulo p, so a multiplication does the division.
This code is for learning only. The RFC asks for a swap that takes the same time whether the key bit is 0 or 1. My if does not, and neither does BigInteger math. Section 5.1 of the RFC warns that such time differences can leak the key. In production, use a vetted library.
The top of the same file runs the tests from the RFC:
// X25519 from RFC 7748, written to be read. Do not use it in production.
// BigInteger math and the "if" swaps below do not run in constant time.
using System.Numerics;
// RFC 7748, section 5.2: two single tests.
Check("RFC 7748 5.2, test 1",
X25519.Compute(
Hex("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4"),
Hex("e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c")),
"c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552");
Check("RFC 7748 5.2, test 2",
X25519.Compute(
Hex("4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d"),
Hex("e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493")),
"95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957");
// RFC 7748, section 5.2: call the function again and again.
// Each round, k becomes the result and u becomes the old k.
byte[] k = Hex("0900000000000000000000000000000000000000000000000000000000000000");
byte[] u = k;
for (int i = 1; i <= 1000; i++)
{
(k, u) = (X25519.Compute(k, u), k);
if (i == 1)
Check("RFC 7748 5.2, 1 round", k,
"422c8e7a6227d7bca1350b3e2bb7279f7897b87bb6854b783c60e80311ae3079");
}
Check("RFC 7748 5.2, 1,000 rounds", k,
"684cf59ba83309552800ef566f2f4d3c1c3887c49360e3875f2eb94d99532c51");
// RFC 7748, section 6.1: Alice and Bob.
byte[] basePoint = new byte[32];
basePoint[0] = 9; // u = 9, then 31 zero bytes
byte[] alicePrivate = Hex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
byte[] bobPrivate = Hex("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb");
byte[] alicePublic = X25519.Compute(alicePrivate, basePoint);
byte[] bobPublic = X25519.Compute(bobPrivate, basePoint);
byte[] aliceShared = X25519.Compute(alicePrivate, bobPublic);
byte[] bobShared = X25519.Compute(bobPrivate, alicePublic);
Check("Alice public key", alicePublic, "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
Check("Bob public key", bobPublic, "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f");
Check("Alice shared secret", aliceShared, "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
Check("Bob shared secret", bobShared, "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
// A bad public key: u = 0 is a point of small order.
byte[] badPublic = new byte[32];
byte[] badShared = X25519.Compute(alicePrivate, badPublic);
Console.WriteLine($"Shared secret with u = 0: {Convert.ToHexString(badShared).ToLowerInvariant()}");
Console.WriteLine($"All zero, so abort: {IsAllZero(badShared)}");
// RFC 7748, section 6.1: OR all bytes together, with no early exit.
static bool IsAllZero(ReadOnlySpan<byte> secret)
{
int acc = 0;
foreach (byte b in secret) acc |= b;
return acc == 0;
}
static byte[] Hex(string s) => Convert.FromHexString(s);
static void Check(string label, byte[] actual, string expected)
{
string hex = Convert.ToHexString(actual).ToLowerInvariant();
Console.WriteLine($"{label}: {(hex == expected ? "OK" : "FAIL " + hex)}");
}RFC 7748 5.2, test 1: OK
RFC 7748 5.2, test 2: OK
RFC 7748 5.2, 1 round: OK
RFC 7748 5.2, 1,000 rounds: OK
Alice public key: OK
Bob public key: OK
Alice shared secret: OK
Bob shared secret: OK
Shared secret with u = 0: 0000000000000000000000000000000000000000000000000000000000000000
All zero, so abort: TrueIn test 2, the top bit of the u-coordinate is set, so it checks that the code ignores that bit. The RFC also lists a value after 1,000,000 rounds, but I stop at 1,000 to keep the run short.
Turn the shared secret into a key
K is secret, but its bytes are not uniformly random, so it is not a key yet. A key derivation function (KDF) turns such a secret into a key. RFC 7748 suggests a KDF that takes K and both public keys.
HKDF is a KDF built on HMAC, a hash function with a key. Its extract step turns the secret into a short key that looks fully random. It can also take a salt, which is a random value that does not need to be secret. Its expand step makes as many key bytes as you need, tied to a purpose by an input called info. .NET has had the HKDF class (opens in a new tab) since .NET 5. RFC 5869, section 3.3 (opens in a new tab) says that for a Diffie-Hellman value, you should not skip the extract step.
// Turns the raw X25519 shared secret into a key with HKDF (RFC 5869).
using System.Security.Cryptography;
using System.Text;
// First, check .NET's HKDF against RFC 5869, test case A.1.
byte[] okm = HKDF.DeriveKey(HashAlgorithmName.SHA256,
ikm: Hex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"),
outputLength: 42,
salt: Hex("000102030405060708090a0b0c"),
info: Hex("f0f1f2f3f4f5f6f7f8f9"));
bool match = Convert.ToHexString(okm).ToLowerInvariant() ==
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865";
Console.WriteLine($"RFC 5869 test case A.1: {(match ? "OK" : "FAIL")}");
// The values from RFC 7748, section 6.1.
byte[] alicePublic = Hex("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
byte[] bobPublic = Hex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f");
byte[] shared = Hex("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
// info names the purpose and holds both public keys, in a fixed order.
byte[] info = [.. Encoding.ASCII.GetBytes("demo-app v1 aes-256-gcm"), .. alicePublic, .. bobPublic];
// No salt here, so HKDF uses 32 zero bytes.
byte[] key = HKDF.DeriveKey(HashAlgorithmName.SHA256, shared, outputLength: 32, salt: [], info: info);
Console.WriteLine($"AES-256 key: {Convert.ToHexString(key).ToLowerInvariant()}");
// The same secret with another purpose gives an unrelated key.
byte[] info2 = [.. Encoding.ASCII.GetBytes("demo-app v1 hmac-sha256"), .. alicePublic, .. bobPublic];
byte[] macKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, shared, outputLength: 32, salt: [], info: info2);
Console.WriteLine($"HMAC key: {Convert.ToHexString(macKey).ToLowerInvariant()}");
static byte[] Hex(string s) => Convert.FromHexString(s);RFC 5869 test case A.1: OK
AES-256 key: ac98856ab9ef2a201146609fd0d428105241cc858af3b200227bd1176a68bb27
HMAC key: 5f3e0a8d4024a284d8ee854ed0e798663e821bc4b7c65dc8083ec476c150e54dThe first line checks .NET against test case A.1 of RFC 5869. Then info holds a purpose name and both public keys in a fixed order. Both sides must build it the same way. A different purpose gives an unrelated key, so one exchange can feed both AES-GCM and HMAC. TLS 1.3 also passes the shared secret to HKDF-Extract in its key schedule (RFC 9846, section 7.1 (opens in a new tab)).
Check for an all-zero secret
For the small-group points above, X25519 returns 32 zero bytes, whatever the private key is. Then everyone knows the "secret". RFC 7748 says that both sides may check for this and stop (section 6.1). TLS 1.3 requires the check (RFC 9846, section 7.4.2 (opens in a new tab)). The IsAllZero method above joins all bytes with OR and never stops early, as the RFC suggests.
What X25519 does not do
X25519 does not tell either side who is on the other end. A public key is 32 bytes with no name inside. RFC 4949 (opens in a new tab) describes the attack. A man in the middle runs one exchange with Alice and another with Bob. Each of them then shares a key with the attacker, who can read and change every message in between.
The fix is authentication, which means proof of who sent a public key. In TLS 1.3, the server signs the TLS handshake with the key of its certificate, in the CertificateVerify message (RFC 9846, section 4.5.2 (opens in a new tab)). The TLS handshake is the first set of messages, where client and server agree on keys. It includes the key exchange, so a swapped public key breaks the signature.
The second limit is in RFC 7748 itself:
Large quantum computers, if ever created, will break both curve25519 and curve448.
RFC 7748, section 7
For this reason, RFC 10024 (opens in a new tab) defines a TLS 1.3 group that runs X25519 next to ML-KEM, a key exchange designed to resist quantum computers. I explain ML-KEM in A first look at ML-KEM.
X25519 in .NET today
.NET 10 has no X25519 class. On Windows, there is a way in. CNG, the crypto library of Windows, has a named curve (opens in a new tab) called curve25519, and ECDiffieHellman can use it. I tested this on Windows 11 with the .NET 10.0.12 runtime:
// .NET 10 on Windows: X25519 through ECDiffieHellman and the CNG curve "curve25519".
// Tested on Windows only. CNG is the crypto library built into Windows.
using System.Security.Cryptography;
ECCurve curve = ECCurve.CreateFromFriendlyName("curve25519");
// The values from RFC 7748, section 6.1.
byte[] alicePrivate = Hex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
byte[] bobPublic = Hex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f");
// CNG rejects this private key until you clamp it yourself.
byte[] clamped = (byte[])alicePrivate.Clone();
clamped[0] &= 248;
clamped[31] &= 127;
clamped[31] |= 64;
using ECDiffieHellman alice = ECDiffieHellman.Create();
alice.ImportParameters(new ECParameters { Curve = curve, D = clamped });
// The public key is the u-coordinate in X. .NET also wants a Y of the
// same length, which X25519 does not have, so it gets 32 zero bytes.
using ECDiffieHellman bob = ECDiffieHellman.Create();
bob.ImportParameters(new ECParameters
{
Curve = curve,
Q = new ECPoint { X = bobPublic, Y = new byte[32] }
});
byte[] shared = alice.DeriveRawSecretAgreement(bob.PublicKey);
Console.WriteLine($"Shared secret: {Convert.ToHexString(shared).ToLowerInvariant()}");
Try("Import without clamping", () =>
{
using ECDiffieHellman k = ECDiffieHellman.Create();
k.ImportParameters(new ECParameters { Curve = curve, D = alicePrivate });
});
Try("Export as SubjectPublicKeyInfo", () => alice.ExportSubjectPublicKeyInfo());
static void Try(string label, Action action)
{
try { action(); Console.WriteLine($"{label}: works"); }
catch (CryptographicException e) { Console.WriteLine($"{label}: {e.Message}"); }
}
static byte[] Hex(string s) => Convert.FromHexString(s);Shared secret: 4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742
Import without clamping: The requested operation is not supported.
Export as SubjectPublicKeyInfo: No OID value matches this name.The shared secret matches the RFC. On 1,000 random key pairs, this path and my BigInteger code also gave the same public keys and secrets. There are limits, though. CNG refuses a private key that is not clamped. The curve also has no object identifier, the number that names a curve inside standard key formats. So export to a format such as SubjectPublicKeyInfo fails.
.NET 11 adds a real type, X25519DiffieHellman. Its API proposal (opens in a new tab) says that .NET does not expose X25519 in a consistent way today. What's new in .NET 11 (opens in a new tab) lists implementations for Windows, Linux and macOS. As of October 2026, that page covers release candidate 1, a test version close to the final one. I did not run it, since this post uses the .NET 10 SDK.
| Option | Runs on | Notes |
|---|---|---|
The BigInteger code in this post | Any system with .NET 10 | For learning and tests. Its run time can depend on the key. |
ECDiffieHellman with curve25519 | Windows, through CNG | Raw 32-byte keys only. Clamp private keys first. |
X25519DiffieHellman | .NET 11 on Windows, Linux and macOS | The built-in type from .NET 11 on. |
Whichever one you use, check for an all-zero secret, run the secret through HKDF, and authenticate the public keys.
Read more
- RFC 7748 (opens in a new tab), elliptic curves for security
- RFC 5869 (opens in a new tab), the HKDF key derivation function
- RFC 9846 (opens in a new tab), TLS 1.3
- RFC 4949 (opens in a new tab), the internet security glossary
- Curve25519: new Diffie-Hellman speed records (opens in a new tab), by Daniel J. Bernstein
- New Directions in Cryptography (opens in a new tab), by Whitfield Diffie and Martin Hellman
- What's new in .NET 11 libraries (opens in a new tab) on Microsoft Learn
- AES-GCM and why a nonce must never repeat, what to do with the key
- A first look at ML-KEM, the post-quantum key exchange, which pairs ML-KEM with X25519 in TLS