How to Sign and Verify Messages with ECDSA and Schnorr

A digital signature lets a receiver check that a message came from the holder of a private key and has not been changed. Using one in .NET usually means committing to a single algorithm's classes, with its own key types, encodings and signing calls, which makes it hard to support a second scheme or to let stored data name the scheme that signed it.

Sphere10 Framework defines one contract for signature schemes and a registry that selects a scheme by an enumeration value. This article signs and verifies with ECDSA and BIP-340 Schnorr from the Sphere10.Framework.CryptoEx package, first by constructing a scheme directly, then through the Signers registry, and finally by signing a serialized object.

A message digest signed with a private key and checked against the matching public key.
A message digest signed with a private key and checked against the matching public key.
💡 In short. Sign and verify with ECDSA and BIP-340 Schnorr in Sphere10.Framework.CryptoEx, select a scheme by DSS value through Signers, and sign serialized objects with ItemSigner.

1. Sign with ECDSA

Construct ECDSA with the curve, generate a private key and derive its public key.


var ecdsa = new ECDSA(ECDSAKeyType.SECP256K1);
var privateKey = ecdsa.GeneratePrivateKey();
var publicKey = ecdsa.DerivePublicKey(privateKey);
var signature = ecdsa.Sign(privateKey, message);
Require(ecdsa.Verify(signature, message, publicKey), "A genuine ECDSA signature should verify.");
Require(!ecdsa.Verify(signature, tampered, publicKey), "A changed message should not verify.");

Sign and Verify take the whole message and hash it with the scheme's digest function, which is SHA2-256 unless the constructor is given another CHF value. ECDSAKeyType offers SECP256K1, SECP384R1, SECP521R1 and SECT283K1, and every curve has the same API.

Three properties of this implementation are worth knowing.

  • Signatures are deterministic. Nonces are derived as RFC 6979 describes, so signing the same message with the same key gives the same bytes.
  • Public keys are compressed points, 33 bytes on secp256k1. Uncompressed keys are rejected.
  • Verification rejects high-S signatures, so a signature cannot be altered into a second valid form.

Keys travel as raw bytes. RawBytes on a key gives the bytes to store or send, and TryParsePublicKey and TryParsePrivateKey turn them back into keys after validating them.


Require(ecdsa.TryParsePublicKey(publicKey.RawBytes, out var parsedPublicKey), "Stored public key bytes should parse.");
Require(ecdsa.Verify(signature, message, parsedPublicKey), "A parsed public key should verify.");

2. How the signing types are organised

Every scheme implements IDigitalSignatureScheme. The contract covers key creation and parsing, public-key derivation, digest calculation, signing a digest and verifying one. ECDSA and Schnorr are two implementations, and Signers is a static registry that creates a scheme for a DSS value such as DSS.ECDSA_SECP256k1.

Diagram source
classDiagram
    direction TB
    class Signers {
      +Create(dss)
      +Sign()
      +Verify()
    }
    class IDigitalSignatureScheme {
      +DerivePublicKey()
      +CalculateMessageDigest()
      +SignDigest()
      +VerifyDigest()
    }
    class ECDSA
    class Schnorr
    Signers ..> IDigitalSignatureScheme
    IDigitalSignatureScheme <|.. ECDSA
    IDigitalSignatureScheme <|.. Schnorr

Signing is always two steps, whichever scheme is used. The message is reduced to a digest, and the digest is signed. The verifier repeats the first step and checks the signature against the digest and the public key.

Diagram source
---
config:
  sequence:
    actorMargin: 8
    width: 96
    mirrorActors: false
    messageMargin: 28
    noteMargin: 8
---
sequenceDiagram
    participant S as Signer
    participant V as Verifier
    Note over S: CalculateMessageDigest
    Note over S: SignDigest(private key)
    S->>V: message, signature
    Note over V: CalculateMessageDigest
    Note over V: VerifyDigest(public key)

3. Sign with Schnorr

Schnorr implements BIP-340 on secp256k1 and works on a 32-byte digest.


var schnorr = new Schnorr(ECDSAKeyType.SECP256K1);
var schnorrKey = schnorr.GeneratePrivateKey();
var schnorrPublicKey = schnorr.DerivePublicKey(schnorrKey);
var digest = schnorr.CalculateMessageDigest(message);
var schnorrSignature = schnorr.SignDigest(schnorrKey, digest);
Require(schnorr.VerifyDigest(schnorrSignature, digest, schnorrPublicKey.RawBytes), "A genuine Schnorr signature should verify.");

A Schnorr public key is a 32-byte x-only key and a signature is 64 bytes. SignDigest mixes fresh random bytes into each signature, as BIP-340 recommends, so two signatures of the same digest differ and both verify. SignDigestWithAuxRandomData takes those bytes from the caller when the result must be reproducible.

4. Select the scheme through Signers

Code that should not depend on one scheme uses the registry. The DSS value can come from configuration or from the data being verified.


ModuleConfiguration.InitializeInternal();
foreach (var dss in new[] { DSS.ECDSA_SECP256k1, DSS.ECDSA_SECP384R1, DSS.SCHNORR_SECP256k1 }) {
	var key = Signers.GeneratePrivateKey(dss);
	var registryPublicKey = Signers.DerivePublicKey(dss, key, 0UL);
	var registrySignature = Signers.Sign(dss, key, message, 0UL);
	Require(Signers.Verify(dss, registrySignature, message, registryPublicKey), $"{dss} should verify.");
	Require(!Signers.Verify(dss, registrySignature, tampered, registryPublicKey), $"{dss} should reject a changed message.");
	Console.WriteLine($"Signers {dss}: passed");
}

The ECDSA and Schnorr entries exist in Signers only after the CryptoEx module has registered them. ModuleConfiguration.InitializeInternal() does that in a program without an application host, and an application built on Sphere10.Framework.Application adds the module at startup with AddModule<Sphere10.Framework.CryptoEx.ModuleConfiguration>().

The last argument of DerivePublicKey and Sign is the signer nonce. ECDSA and Schnorr are stateless and ignore it. The Framework's hash-based schemes use it to select a one-time key, which is why the contract carries it.

Signers.Create(dss) returns the scheme object. Each static Signers method creates a scheme for the call, so hold one from Create when signing or verifying in a loop.

5. Sign a serialized object

ItemSigner<TItem> signs an object by serializing it, hashing the bytes and signing the digest, so the three steps cannot drift apart between signer and verifier.


var signer = new ItemSigner<Transfer>(SerializerBuilder.FactoryAssemble<Transfer>(), CHF.SHA2_256, DSS.ECDSA_SECP256k1);
var transfer = new Transfer { Account = 7, Units = 10 };
var signerKey = Signers.GeneratePrivateKey(DSS.ECDSA_SECP256k1);
var signerPublicKey = Signers.DerivePublicKey(DSS.ECDSA_SECP256k1, signerKey, 0UL);
var itemSignature = signer.Sign(transfer, signerKey, 0UL);
Require(signer.Verify(transfer, signerPublicKey, itemSignature), "The signed object should verify.");
transfer.Units = 99;
Require(!signer.Verify(transfer, signerPublicKey, itemSignature), "A changed object should not verify.");

Both sides must construct the signer with the same serializer, hash function and scheme. SignedItem<T> pairs an item with its signature when the two are stored or sent together.

6. Limits to plan for

⚠️ Keep these limits in mind.
  • A valid signature proves possession of a key, not identity. The verifier must already trust that the public key belongs to the expected signer.
  • Schnorr is secp256k1 only. The other DSS.SCHNORR_* values have no registered scheme.
  • Private keys and seeds are secrets. GeneratePrivateKey(seed) is deterministic from its seed, so the seed needs the same protection as the key.
  • These schemes are not post-quantum. The core package's DSS.PQC_WAMS schemes are hash-based alternatives with stateful keys.

7. Get the package and run the sample

The contracts and the registry are in Sphere10.Framework. ECDSA and Schnorr are in Sphere10.Framework.CryptoEx. The sample targets .NET 10.


dotnet new console --framework net10.0 --name ArticleSample
cd ArticleSample
dotnet add package Sphere10.Framework --version 3.1.3
dotnet add package Sphere10.Framework.CryptoEx --version 3.1.3

The complete MIT-licensed sample contains the program shown above. Extract the download and run it.


dotnet run -c Release

Each line is a check that throws on failure.


ECDSA secp256k1: 32-byte private key, 33-byte public key
ECDSA accepts the message and rejects a changed one: passed
Schnorr secp256k1: 32-byte public key, 64-byte signature: passed
Signers SCHNORR_SECP256k1: passed
ItemSigner accepts the object and rejects a changed one: passed

The CryptoEx guide documents ECDSA, Schnorr and MuSig multi-signatures, and the Cryptography guide documents Signers, DSS and the scheme contract.


sample.zip
Download the complete worked example and build instructions (ZIP).

The Sphere10 Framework overview describes the rest of the toolkit.

Author: Herman Schoenfeld