mirror of
https://github.com/sonr-io/crypto.git
synced 2026-09-17 01:26:23 +00:00
378 lines
16 KiB
Plaintext
378 lines
16 KiB
Plaintext
---
|
||
title: ECDSA Utilities
|
||
description: 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.
|
||
sidebar:
|
||
order: 4
|
||
icon: check-check
|
||
---
|
||
|
||
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.
|
||
|
||
```go
|
||
import "github.com/sonr-io/crypto/ecdsa"
|
||
```
|
||
|
||
:::note
|
||
The import path collides with the standard library's `crypto/ecdsa`. In any file that uses both you
|
||
must alias one — the examples below alias the standard library as `stdecdsa`.
|
||
:::
|
||
|
||
## 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) \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.
|
||
|
||
<TypeTable
|
||
type={{
|
||
"IsCanonical(s, N *big.Int)": {
|
||
type: "bool",
|
||
description: "True when s <= N/2. Does not range-check s, and returns false for nil inputs.",
|
||
},
|
||
"MakeCanonical(r, s, N *big.Int)": {
|
||
type: "(*big.Int, *big.Int)",
|
||
description: "Returns (r, min(s, N-s)). No validation and no error. Returns its inputs unchanged if any is nil.",
|
||
},
|
||
"IsSignatureCanonical(r, s *big.Int, curve elliptic.Curve)": {
|
||
type: "bool",
|
||
description: "Full check: r in [1, N-1] AND s in [1, N/2]. False on any nil argument.",
|
||
},
|
||
"CanonicalizeSignature(r, s, curve)": {
|
||
type: "(*big.Int, *big.Int, error)",
|
||
description: "Range-checks both scalars, then returns copies with s reduced to canonical form. Errors on nil arguments or out-of-range r or s.",
|
||
},
|
||
"NormalizeSignature(r, s, curve)": {
|
||
type: "(*big.Int, *big.Int, error)",
|
||
description: "Currently a direct pass-through to CanonicalizeSignature. The name suggests more; the body does not do more.",
|
||
},
|
||
"RejectNonCanonical(r, s, curve)": {
|
||
type: "error",
|
||
description: "Strict mode: returns an error instead of coercing. Use this on ingress when you want to refuse malleated signatures outright.",
|
||
},
|
||
"ValidateAndCanonicalizeSignature(pub, hash, r, s)": {
|
||
type: "(*big.Int, *big.Int, error)",
|
||
description: "Canonicalizes, then verifies against pub and hash. Falls back to verifying the original pair if the canonical one fails. Errors if neither verifies.",
|
||
},
|
||
"CompareSignatures(r1, s1, r2, s2, curve)": {
|
||
type: "(bool, error)",
|
||
description: "Canonicalizes both pairs and compares. This is the correct way to ask whether two signatures are the same signature.",
|
||
},
|
||
}}
|
||
/>
|
||
|
||
`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`.
|
||
|
||
:::warning[`MakeCanonical` returns `r` by reference]
|
||
The internal helper returns the *same* `*big.Int` you passed for `r`, and returns your original `s`
|
||
pointer unchanged when it was already canonical. Only the flipped case allocates. Mutating the
|
||
result mutates your input. `CanonicalizeSignature` does not have this problem — it copies both
|
||
scalars before touching them.
|
||
:::
|
||
|
||
### Choosing between coerce and reject
|
||
|
||
<Tabs>
|
||
<Tab title="Coerce">
|
||
`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.
|
||
</Tab>
|
||
<Tab title="Reject">
|
||
`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.
|
||
</Tab>
|
||
</Tabs>
|
||
|
||
Grounded in `TestCanonicalizeSignature` and `TestIsSignatureCanonical`.
|
||
|
||
```go canonical.go
|
||
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.
|
||
|
||
```go
|
||
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.
|
||
|
||
:::note[The size comes from `BitSize`, not from `N`]
|
||
`byteSize` is derived from the curve's field bit size, while `r` and `s` are reduced mod `N`. For
|
||
the NIST P-curves these agree. For a curve where the group order is meaningfully shorter than the
|
||
field, the encoding still uses the field width — so do not assume this format matches another
|
||
library's fixed-width convention without checking.
|
||
:::
|
||
|
||
:::warning[Not DER, not `[R || S]` with a recovery byte]
|
||
This is a bare 2×`byteSize` concatenation. It is not ASN.1 DER (what `ecdsa.SignASN1` emits), and it
|
||
carries no recovery id, so you cannot recover the public key from it the way Ethereum's 65-byte
|
||
format allows. Do not feed these bytes to a verifier expecting either of those.
|
||
:::
|
||
|
||
## 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](https://datatracker.ietf.org/doc/html/rfc6979), using **HMAC-SHA-256** as the
|
||
fixed underlying primitive.
|
||
|
||
<TypeTable
|
||
type={{
|
||
"DeterministicSign(priv *ecdsa.PrivateKey, hash []byte)": {
|
||
type: "(*big.Int, *big.Int, error)",
|
||
description: "Derives k deterministically, signs, and returns an already-canonical (low-S) pair. Errors on a nil key, a nil D, or an empty hash.",
|
||
},
|
||
"VerifyDeterministic(pub *ecdsa.PublicKey, hash []byte, r, s *big.Int)": {
|
||
type: "bool",
|
||
description: "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.",
|
||
},
|
||
}}
|
||
/>
|
||
|
||
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`.
|
||
|
||
```go deterministic.go
|
||
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.
|
||
|
||
:::danger[Deterministic does not mean "nonce reuse is now safe"]
|
||
Determinism eliminates the *accidental* nonce collision, not the consequence of one. If the same `k`
|
||
is ever used for two different messages under the same key, both signatures share an `r`, and
|
||
solving the two-equation system recovers the private key immediately:
|
||
|
||
$$
|
||
d = \frac{s_1 k - h_1}{r} \quad\text{with}\quad k = \frac{h_1 - h_2}{s_1 - s_2}
|
||
$$
|
||
|
||
The derivation binds `k` to both the private key and the message digest — `generateK` seeds the DRBG
|
||
with `priv.D` and `hashToInt(hash)` — so two *different* messages under one key can never collide,
|
||
which is the whole point. Two residual hazards remain:
|
||
|
||
- Signing the **same digest** twice returns byte-identical output. That is correct behaviour, but it
|
||
means a signature is a stable fingerprint of `(key, message)`; do not treat repeated signatures as
|
||
evidence of repeated intent.
|
||
- Deterministic signers are the standard target for **fault injection**: an attacker who can glitch
|
||
one of two signings of the same message obtains a correct and a faulted signature sharing `k`, and
|
||
the equation above applies. If your threat model includes physical access, pair determinism with
|
||
a verify-after-sign check.
|
||
:::
|
||
|
||
:::danger[Mostly RFC 6979-conformant — and the exception is silent]
|
||
The derivation implements RFC 6979 steps (a) through (j), but where the RFC specifies
|
||
`bits2octets(H(m))` — a **fixed-width**, mod-`q`-reduced octet string — the code feeds
|
||
`bits2int(H(m)).Bytes()` into steps (f) and (h). `big.Int.Bytes()` drops leading zero bytes and does
|
||
not reduce mod `q`.
|
||
|
||
In the common case that makes no difference, and the implementation reproduces RFC 6979 vectors
|
||
exactly. Checked against RFC 6979 A.2.5 (P-256 / SHA-256 / `"sample"`, key
|
||
`C9AFA9D8…120F6721`), this package returns
|
||
`r = EFD48B2AACB6A8FD1140DD9CD45E81D69D2C877B56AAF991C34D0EA84EAF3716` — the vector's value.
|
||
|
||
It diverges whenever the digest, read as an integer, has fewer than `byteSize` significant
|
||
bytes — that is, whenever `H(m)` begins with a zero byte, roughly one message in 256 on P-256.
|
||
Compared against a reference RFC 6979 derivation on the digest
|
||
`00EEECC1EB031E204A211DEC04B6B42B1F446802058873A1A8F36308FE62EC0D`:
|
||
|
||
```
|
||
rfc6979 r = 69E8682EEF48289BD67EE185E5756BC416F8D02900249AFC3AA19F9F1B28908F
|
||
package r = 5B0E3B459095B47BD231012F545A4300962B6044AC43B3CAC4892C143AA9207B
|
||
```
|
||
|
||
The signature is still perfectly valid ECDSA and verifies everywhere; only the *nonce derivation*
|
||
disagrees. But an intermittent, digest-dependent disagreement is worse than a consistent one: a
|
||
cross-implementation compatibility test will pass 255 times out of 256. `deterministic_test.go`
|
||
contains no RFC 6979 vectors at all — it only checks that repeated signing agrees with itself. Do
|
||
not build a protocol in which two different libraries must derive the same `k`.
|
||
:::
|
||
|
||
:::warning[Not constant time]
|
||
Every operation here is `math/big` arithmetic: `Div`, `Sub`, `Cmp`, `ModInverse`, `Mul`. `math/big`
|
||
makes no constant-time guarantee, and `signWithK` performs the scalar multiplication and the modular
|
||
inversion with ordinary variable-time code. On a machine where an attacker can measure your signing,
|
||
prefer `crypto/ecdsa.SignASN1`, whose P-256 path is constant time. See
|
||
[security notes](/reference/security).
|
||
:::
|
||
|
||
:::note[`VerifyDeterministic` is stricter than `stdecdsa.Verify`]
|
||
It rejects `s > N/2`. A perfectly valid signature produced by a signer that does not normalise will
|
||
fail here. That is deliberate — it is the strict-ingress policy applied to verification — but it
|
||
means `VerifyDeterministic` is not a drop-in replacement for the standard library verifier.
|
||
:::
|
||
|
||
## Related
|
||
|
||
<CardGroup cols={2}>
|
||
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="users">
|
||
Produce an ECDSA signature from key shares that never combine. The canonicalization helpers here
|
||
apply to its output too.
|
||
</Card>
|
||
<Card title="MPC enclave" href="/identity/mpc-enclave" icon="fingerprint">
|
||
The two-party ECDSA wrapper this library ships as its headline API.
|
||
</Card>
|
||
<Card title="Curve abstraction" href="/foundations/curves" icon="git-branch">
|
||
The library's own curve types — distinct from the `crypto/elliptic` types this package uses.
|
||
</Card>
|
||
<Card title="Security notes" href="/reference/security" icon="shield">
|
||
Constant-time gaps and standards deviations across the library.
|
||
</Card>
|
||
</CardGroup>
|