Skip to content
ecdsa.com

ML-DSA-65 sign/verify · .NET

How to sign and verify with ML-DSA-65 in C# (.NET)

To sign and verify with ML-DSA-65 in C#, use the MLDsa class of .NET 10: MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65), SignData and VerifyData, with an optional context. On Linux it needs OpenSSL 3.5 underneath, which is why this recipe runs in the Alpine 3.24 SDK image; it also verifies a signature OpenSSL made and derives OpenSSL's key from the shared seed.

Tested with .NET 10.0.13 (SDK 10.0.402) (Alpine 3.24, OpenSSL 3.5.9 backend).

sign.cs — key pair, signature, verification, context string
using System.Security.Cryptography;
// .NET 10: on Linux this needs OpenSSL 3.5 or later underneath
if (!MLDsa.IsSupported) throw new PlatformNotSupportedException("ML-DSA is not available on this platform");
using MLDsa key = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
byte[] message = "hello, post-quantum world"u8.ToArray();
byte[] signature = key.SignData(message); // randomized (hedged) signing
Console.WriteLine($"public key {key.ExportMLDsaPublicKey().Length} bytes, signature {signature.Length} bytes");
Console.WriteLine($"verified: {key.VerifyData(message, signature)}");
Console.WriteLine($"tampered message: {key.VerifyData("hello, post-quantum world!"u8.ToArray(), signature)}");
// A context string (up to 255 bytes) binds the signature to one purpose
byte[] context = "invoice-v1"u8.ToArray();
byte[] invoiceSignature = key.SignData(message, context);
Console.WriteLine($"with the context: {key.VerifyData(message, invoiceSignature, context)}, without: {key.VerifyData(message, invoiceSignature)}");
interop.cs — OpenSSL's public key from PEM, and a key from a 32-byte seed
using System.Security.Cryptography;
byte[] message = "hello, post-quantum world"u8.ToArray();
// SPKI / PKCS#8 / PEM methods are marked experimental (SYSLIB5006) in .NET 10
#pragma warning disable SYSLIB5006
using MLDsa openssl = MLDsa.ImportFromPem(File.ReadAllText("openssl-pub.pem"));
#pragma warning restore SYSLIB5006
byte[] opensslSignature = Convert.FromBase64String(File.ReadAllText("openssl-sig.b64"));
Console.WriteLine($"OpenSSL signature verifies: {openssl.VerifyData(message, opensslSignature)}");
// The 32-byte seed is the whole private key: the same seed gives the same key everywhere
byte[] seed = Convert.FromHexString(Environment.GetEnvironmentVariable("SEED_HEX")!);
using MLDsa seeded = MLDsa.ImportMLDsaPrivateSeed(MLDsaAlgorithm.MLDsa65, seed);
Console.WriteLine($"key from the shared seed equals OpenSSL's: {seeded.ExportMLDsaPublicKey().AsSpan().SequenceEqual(openssl.ExportMLDsaPublicKey())}");
Console.WriteLine($"two signatures equal: {seeded.SignData(message).AsSpan().SequenceEqual(seeded.SignData(message))}");

How it works

  1. MLDsa.IsSupported tells whether the platform provides ML-DSA; check it before generating keys.
  2. SignData(message) returns a 3309-byte signature and VerifyData(message, signature) a bool; both take an optional context (up to 255 bytes).
  3. ExportMLDsaPublicKey() returns the raw 1952-byte public key; ImportMLDsaPrivateSeed builds the key from the 32-byte seed — the shared seed gives OpenSSL's exact public key.
  4. MLDsa.ImportFromPem reads OpenSSL's BEGIN PUBLIC KEY file; the run verifies OpenSSL's signature with it.

Gotchas

  • On the Ubuntu 24.04 SDK image (OpenSSL 3.0.13) MLDsa.IsSupported is false; the Alpine 3.24 image ships OpenSSL 3.5 and supports it. The same .NET version behaves differently depending on the OpenSSL it finds.
  • The SPKI, PKCS#8 and PEM methods (ImportFromPem, ExportSubjectPublicKeyInfoPem, ExportPkcs8PrivateKey …) are marked experimental in .NET 10: in a file-based app the build fails with error SYSLIB5006 unless you suppress the diagnostic, as interop.cs does.
  • Signing is randomized: two signatures of the same message differ.
  • A signature made with a context does not verify without it (False in the run).

Related recipes