Skip to content
Sonr Crypto
Esc
navigateopen⌘Jpreview
On this page

ECDSA Utilities

Canonical low-S form, malleability defence, fixed-width signature codecs, and RFC 6979-style deterministic signing on top of the standard library's crypto/ecdsa.

The ecdsa package is a thin layer of utilities over the standard library. It does not define a key type, a curve, or a signature struct — it operates on *ecdsa.PrivateKey, *ecdsa.PublicKey, elliptic.Curve, and raw *big.Int pairs from crypto/ecdsa. Two problems are solved here that the standard library leaves to you:

  1. Malleability. ECDSA signatures are not unique per message. Two different byte strings verify equally well, so signature bytes cannot be used as an identifier.
  2. Nonce dependence. ecdsa.Sign needs entropy at signing time, and a bad or repeated nonce leaks the private key outright.

Reach for this package when you store, index, deduplicate, or compare ECDSA signatures, or when you need signing to be reproducible on a device you do not trust to have a good RNG. Do not reach for it for ordinary sign-and-verify: crypto/ecdsa already does that, correctly and with a constant-time implementation. Everything here is math/big arithmetic and makes no constant-time claim.

import "github.com/sonr-io/crypto/ecdsa"

Malleability, and why canonical form matters

An ECDSA signature is a pair (r, s) over a curve of prime order N. Verification checks a relation that is symmetric in the sign of s:

(r,  s) valid    (r,  Ns) valid(r,\; s) \text{ valid} \iff (r,\; N - s) \text{ valid}

Anyone who observes a valid signature can therefore produce a second, different, equally valid signature for the same message and the same key — without knowing the private key. The consequences are practical, not theoretical:

  • Signature bytes are not an identifier. Keying a database, a replay-protection cache, or a transaction ID on raw signature bytes lets an attacker create an unbounded number of distinct entries for one authorised action. This is the Bitcoin transaction-malleability bug.
  • Byte equality is not signature equality. bytes.Equal(sigA, sigB) == false does not mean two parties signed different things.

The fix everybody converged on is a canonical form: of the two valid s values, always use the smaller one, s <= N/2. This package calls that “canonical” and provides both the coercion and the strict rejection.

PropType
IsCanonical(s, N *big.Int)?bool

True when s <= N/2. Does not range-check s, and returns false for nil inputs.

Typebool
MakeCanonical(r, s, N *big.Int)?(*big.Int, *big.Int)

Returns (r, min(s, N-s)). No validation and no error. Returns its inputs unchanged if any is nil.

Type(*big.Int, *big.Int)
IsSignatureCanonical(r, s *big.Int, curve elliptic.Curve)?bool

Full check: r in [1, N-1] AND s in [1, N/2]. False on any nil argument.

Typebool
CanonicalizeSignature(r, s, curve)?(*big.Int, *big.Int, error)

Range-checks both scalars, then returns copies with s reduced to canonical form. Errors on nil arguments or out-of-range r or s.

Type(*big.Int, *big.Int, error)
NormalizeSignature(r, s, curve)?(*big.Int, *big.Int, error)

Currently a direct pass-through to CanonicalizeSignature. The name suggests more; the body does not do more.

Type(*big.Int, *big.Int, error)
RejectNonCanonical(r, s, curve)?error

Strict mode: returns an error instead of coercing. Use this on ingress when you want to refuse malleated signatures outright.

Typeerror
ValidateAndCanonicalizeSignature(pub, hash, r, s)?(*big.Int, *big.Int, error)

Canonicalizes, then verifies against pub and hash. Falls back to verifying the original pair if the canonical one fails. Errors if neither verifies.

Type(*big.Int, *big.Int, error)
CompareSignatures(r1, s1, r2, s2, curve)?(bool, error)

Canonicalizes both pairs and compares. This is the correct way to ask whether two signatures are the same signature.

Type(bool, error)

MakeCanonical and IsCanonical take a bare *big.Int order rather than a curve, which makes them usable with secp256k1 or any other order you have on hand; the rest take an elliptic.Curve.

Choosing between coerce and reject

CanonicalizeSignature accepts a malleated signature and quietly normalises it. Right for a verifier that must interoperate with signers you do not control, and for anything you are about to store or hash.

RejectNonCanonical refuses. Right for a consensus rule or a protocol where you have declared that only canonical signatures are well-formed — coercion there would let two encodings of the same intent both be “accepted”, which is exactly the ambiguity you set out to remove.

Grounded in TestCanonicalizeSignature and TestIsSignatureCanonical.

package main

import (
	stdecdsa "crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/sha256"
	"fmt"
	"log"
	"math/big"

	"github.com/sonr-io/crypto/ecdsa"
)

func main() {
	curve := elliptic.P256()
	priv, err := stdecdsa.GenerateKey(curve, rand.Reader)
	if err != nil {
		log.Fatal(err)
	}

	digest := sha256.Sum256([]byte("transfer 100 to bob"))
	r, s, err := stdecdsa.Sign(rand.Reader, priv, digest[:])
	if err != nil {
		log.Fatal(err)
	}

	// stdlib Sign does not normalise, so first pin down which of the pair is low-S.
	N := curve.Params().N
	rLow, sLow, err := ecdsa.CanonicalizeSignature(r, s, curve)
	if err != nil {
		log.Fatal(err)
	}

	// Anyone can produce this second, equally valid, non-canonical signature.
	sHigh := new(big.Int).Sub(N, sLow)
	fmt.Println("high-S still verifies:",
		stdecdsa.Verify(&priv.PublicKey, digest[:], rLow, sHigh)) // true

	// Both collapse to the same canonical pair...
	same, err := ecdsa.CompareSignatures(rLow, sLow, rLow, sHigh, curve)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("same signature:", same) // true

	// ...and to the same fixed-width encoding.
	a, err := ecdsa.SignatureBytes(rLow, sLow, curve)
	if err != nil {
		log.Fatal(err)
	}
	b, err := ecdsa.SignatureBytes(rLow, sHigh, curve)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("identical bytes:", string(a) == string(b), len(a)) // true 64

	// Strict ingress: refuse rather than repair.
	fmt.Println("high-S accepted:", ecdsa.IsSignatureCanonical(rLow, sHigh, curve)) // false
	if err := ecdsa.RejectNonCanonical(rLow, sHigh, curve); err != nil {
		fmt.Println("rejected:", err) // signature is not in canonical form
	}
}

Fixed-width codecs

SignatureBytes and SignatureFromBytes are a canonical, length-prefixed-free alternative to ASN.1 DER. The layout is the concatenation of two big-endian, zero-padded scalars:

Field Offset Length
r 0 byteSize
s byteSize byteSize

where byteSize = (curve.Params().BitSize + 7) / 8. For P-256 that is 32, so a signature is exactly 64 bytes; P-384 gives 96, P-521 gives 132.

raw, err := ecdsa.SignatureBytes(r, s, curve)      // canonicalizes, then encodes
r2, s2, err := ecdsa.SignatureFromBytes(raw, curve) // decodes, then canonicalizes

Both directions canonicalize, which is what makes the encoding a stable identifier: (r, s) and (r, N-s) produce byte-identical output, and a decode always yields a canonical pair.

Deterministic signing

DeterministicSign removes the randomness from ECDSA signing. Instead of drawing k from an RNG, it derives k from the private key and the message digest through an HMAC-DRBG construction in the style of RFC 6979, using HMAC-SHA-256 as the fixed underlying primitive.

PropType
DeterministicSign(priv *ecdsa.PrivateKey, hash []byte)?(*big.Int, *big.Int, error)

Derives k deterministically, signs, and returns an already-canonical (low-S) pair. Errors on a nil key, a nil D, or an empty hash.

Type(*big.Int, *big.Int, error)
VerifyDeterministic(pub *ecdsa.PublicKey, hash []byte, r, s *big.Int)?bool

Range-checks r in [1, N-1] and s in [1, N/2], then delegates to crypto/ecdsa.Verify. Rejects a high-S signature that stdlib Verify would accept.

Typebool

Why determinism is worth having:

  • No entropy dependence at signing time. An embedded device, a freshly-booted VM, or a deterministic test environment can sign correctly without a seeded CSPRNG.
  • Reproducibility. The same key and message always yield the same signature, so signatures can be regenerated, diffed, and used as cache keys.
  • No silent RNG failure. A subtly broken RNG produces biased nonces, and nonce bias leaks the private key over enough signatures. Removing the RNG removes that failure mode.

Grounded in TestDeterministicSign and TestCanonicalSignature.

package main

import (
	stdecdsa "crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/sha256"
	"fmt"
	"log"

	"github.com/sonr-io/crypto/ecdsa"
)

func main() {
	priv, err := stdecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		log.Fatal(err)
	}

	digest := sha256.Sum256([]byte("test message for deterministic signing"))

	r1, s1, err := ecdsa.DeterministicSign(priv, digest[:])
	if err != nil {
		log.Fatal(err)
	}
	r2, s2, err := ecdsa.DeterministicSign(priv, digest[:])
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("reproducible:", r1.Cmp(r2) == 0 && s1.Cmp(s2) == 0) // true

	// Output is already low-S.
	fmt.Println("canonical:", ecdsa.IsCanonical(s1, priv.Curve.Params().N)) // true

	// Verifies with the standard library, and with the strict wrapper.
	fmt.Println("stdlib ok:", stdecdsa.Verify(&priv.PublicKey, digest[:], r1, s1))
	fmt.Println("strict ok:", ecdsa.VerifyDeterministic(&priv.PublicKey, digest[:], r1, s1))
}

The output is normalised to low-S inside signWithK before it is returned, so you never need to call MakeCanonical on a DeterministicSign result.

Last updated on September 2, 2026

Was this page helpful?