Skip to content
ecdsa.com

ECDH key agreement · .NET

How to derive a shared key with ECDH and HKDF in C#

In C#, P-256 ECDH and HKDF are built into System.Security.Cryptography: ECDiffieHellman.DeriveRawSecretAgreement returns the shared secret and HKDF.DeriveKey turns it into a key. X25519 is not in .NET 10's cryptography namespace, so this recipe uses NSec.Cryptography for it — with the RFC 7748 and RFC 5903 test keys, both produce the published secrets.

Tested with .NET 10.0.402 (SDK, System.Security.Cryptography on OpenSSL 3.0.13) and NSec.Cryptography 26.4.0 (libsodium).

P-256 with System.Security.Cryptography: raw secret, then HKDF
#:package NSec.Cryptography@26.4.0
using System.Security.Cryptography;
using System.Text;
using NSec.Cryptography;
byte[] info = Encoding.UTF8.GetBytes("AES-256-GCM key v1");
// P-256: built into System.Security.Cryptography
using var alice = ECDiffieHellman.Create();
alice.ImportFromPem(File.ReadAllText("alice-p256.pem"));
using var bob = ECDiffieHellman.Create();
bob.ImportFromPem(File.ReadAllText("bob-p256.pub.pem")); // public key only
byte[] p256Secret = alice.DeriveRawSecretAgreement(bob.PublicKey); // .NET 8+
byte[] p256Key = HKDF.DeriveKey(HashAlgorithmName.SHA256, p256Secret, 32, salt: [], info: info);
X25519 with NSec, which also has HKDF built in
// X25519: not in .NET 10's System.Security.Cryptography; NSec (libsodium) has it
var x25519 = KeyAgreementAlgorithm.X25519;
using var aliceX = NSec.Cryptography.Key.Import(x25519, File.ReadAllBytes("alice-x25519.pem"), KeyBlobFormat.PkixPrivateKeyText);
var bobX = NSec.Cryptography.PublicKey.Import(x25519, File.ReadAllBytes("bob-x25519.pub.pem"), KeyBlobFormat.PkixPublicKeyText);
// Agree returns null when the result is the all-zero value (a malicious or broken peer key)
using var secret = x25519.Agree(aliceX, bobX,
new SharedSecretCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport })
?? throw new CryptographicException("X25519 agreement failed");
byte[] x25519Secret = secret.Export(SharedSecretBlobFormat.RawSharedSecret); // only to print it
byte[] x25519Key = KeyDerivationAlgorithm.HkdfSha256.DeriveBytes(secret, ReadOnlySpan<byte>.Empty, info, 32);
Console.WriteLine($"X25519 shared secret: {Convert.ToHexStringLower(x25519Secret)}");
Console.WriteLine($"X25519 derived key: {Convert.ToHexStringLower(x25519Key)}");
Console.WriteLine($"P-256 shared secret: {Convert.ToHexStringLower(p256Secret)}");
Console.WriteLine($"P-256 derived key: {Convert.ToHexStringLower(p256Key)}");

How it works

  1. ECDiffieHellman.Create() plus ImportFromPem loads either the private key or, for the peer, just the public key.
  2. DeriveRawSecretAgreement(peer.PublicKey) (.NET 8+) returns the raw shared secret, and HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 32, salt, info) derives the key.
  3. With NSec, KeyAgreementAlgorithm.X25519.Agree returns a SharedSecret that stays in unmanaged memory; KeyDerivationAlgorithm.HkdfSha256.DeriveBytes derives the key from it directly.
  4. The secret is exported here only so the output can be compared with the RFCs; production code passes the SharedSecret straight to the KDF.

Gotchas

  • DeriveKeyMaterial(peer.PublicKey) does not return the shared secret: by default it returns SHA-256 of it (0519dc09… instead of d6840f6b… for the RFC 5903 keys). Other platforms will not derive the same key — use DeriveRawSecretAgreement with HKDF.
  • NSec's Agree returns null when the result is all zeros, which is what a malicious low-order X25519 key produces. The ?? throw is the check; without it, the next line throws a NullReferenceException.
  • A SharedSecret cannot be exported by default (InvalidOperationException: The key cannot be exported.); the export policy in SharedSecretCreationParameters allows it.
  • ECDiffieHellman is a front end for the platform's cryptography: OpenSSL on Linux (3.0.13 in the image used here), CNG on Windows, Apple's frameworks on macOS. The derived bytes are the same everywhere; exception messages are not, so do not match on them.

Related recipes