Recursive AI

X25519 key exchange in C#

All onecrypto recipes

.NET’s ECDiffieHellman has no Curve25519. OneCrypto adds X25519 (RFC 7748): X25519PrivateKey.Generate() makes your key, and TryDeriveSharedSecret combines it with the other side’s public key. Here the other side is a key made by OpenSSL, and OpenSSL derives the same secret.

Terminal
dotnet new console -o X25519Demo && cd X25519Demo
dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1
openssl genpkey -algorithm x25519 -out bob.pem
openssl pkey -in bob.pem -pubout -out bob-public.pem

OneCrypto is a preview, so dotnet add package needs --version or --prerelease. Put this in Program.cs and run dotnet run:

C#
using System.Security.Cryptography;
using System.Text;
using RecursiveAI.OneCrypto;
using RecursiveAI.OneCrypto.Pkcs;
using RecursiveAI.OneCrypto.Primitives;
 
// The peer's public key, from a PEM file
using LoadedItems bobFile = Crypto.Load("bob-public.pem");
PublicKeyInfo bobInfo = bobFile.PublicKeys[0];
Console.WriteLine($"Peer key:  {bobInfo.KeyAlgorithm}");
X25519PublicKey bob = X25519PublicKey.FromBytes(bobInfo.KeyBits.Span);
 
// Our key pair; send our public key to the peer
using X25519PrivateKey alice = X25519PrivateKey.Generate();
using (PrivateKeyInfo pkcs8 = PrivateKeyInfo.FromX25519(alice))
    File.WriteAllText("alice-public.pem",
        PemEncoding.WriteString("PUBLIC KEY", pkcs8.GetPublicKey().Encoded.Span));
 
byte[] shared = new byte[X25519.KeySize];
if (!alice.TryDeriveSharedSecret(bob, shared))
    throw new CryptographicException("Low-order peer key: abandon the exchange.");
File.WriteAllBytes("shared-csharp.bin", shared);
Console.WriteLine($"Shared:    {shared.Length} bytes");
 
// Never use the raw secret as a key: derive one with HKDF (built into .NET)
byte[] aesKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, shared, 32,
    info: Encoding.UTF8.GetBytes("example app v1 AES-256-GCM key"));
Console.WriteLine($"AES key:   {aesKey.Length * 8} bits");
CryptographicOperations.ZeroMemory(shared);
Text
Peer key:  X25519
Shared:    32 bytes
AES key:   256 bits

Both sides compute the same 32 bytes from their own private key and the other’s public key. TryDeriveSharedSecret returns false when the peer’s key has low order (the all-zero check of RFC 7748 §6.1); stop the exchange then. HKDF and AES-GCM come from .NET itself.

Check it with OpenSSL

OpenSSL takes Bob’s private key and Alice’s public key and must arrive at the same secret:

Terminal
openssl pkeyutl -derive -inkey bob.pem -peerkey alice-public.pem \
  -out shared-openssl.bin
cmp shared-csharp.bin shared-openssl.bin && echo "Same 32-byte secret"
Text
Same 32-byte secret

Limits

  • Not audited. This library has not been independently audited. It is a preview: 0.1.0-preview.1, and the API can change.
  • Timing. The project’s timing tests cover X25519 only in “earlier, shorter runs”, and it makes “no constant-time claim … for key handling” yet. Details: Conformance.
  • Key exchange only. X25519 gives a shared secret, not an authenticated channel: confirm the peer’s public key some other way, for example with a signature (Ed25519).
  • Speed. Against BouncyCastle 2.7.0, X25519 agreement takes 0.76 of its time on x64 and 0.77 on arm64, from the project’s benchmark table. The x64 run is marked load-flagged.

X448 works the same way with X448PrivateKey. How the curves are implemented and checked: Ed25519, Ed448, X25519 and X448.