Files
zeekayandHanzo Dev 813ec4a75f fix(ecdsa): make SigEthereum correct, non-mutating, and tested
SigEthereum had ZERO callers (test or otherwise) and two latent hazards that
made it unsafe to wire: it mutated its receiver's S (Negate) and R
(UnmarshalBinary) in place — curve.Scalar/Point are pointer-backed, so a
value receiver still aliases — and it did a pointless post-hoc R rewrite.

Rewrite: copy S before low-S normalization (no aliasing), validate the R/S
encodings, and derive V from the compressed-R Y-parity byte (flipped when S is
negated). Output is the canonical 65-byte R.x‖S‖V (V in {0,1}), EIP-2 low-S,
directly accepted by luxfi/evm ecrecover.

Tests (SDKROOT="$(xcrun --show-sdk-path)" go test ./pkg/ecdsa):
  - EVMRecoverable: 256 random signatures — each emits low-S, recovers to the
    signer's pubkey via decred secp256k1 RecoverCompact (the routine luxfi/mpc
    and the EVM's ecrecover use), and leaves the receiver unmutated.
  - NormalizesHighS: forces the high-S branch and proves the low-S flip still
    recovers to the signer.

This is the shared helper the mpcd signing sessions now wire for canonical
EVM-ready threshold-ECDSA signatures.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-20 15:31:49 -07:00

82 lines
2.3 KiB
Go

package ecdsa
import (
"fmt"
"github.com/luxfi/threshold/pkg/math/curve"
)
type Signature struct {
R curve.Point
S curve.Scalar
}
// EmptySignature returns a new signature with a given curve, ready to be unmarshalled.
func EmptySignature(group curve.Curve) Signature {
return Signature{R: group.NewPoint(), S: group.NewScalar()}
}
// Verify is a custom signature format using curve data.
func (sig Signature) Verify(X curve.Point, hash []byte) bool {
group := X.Curve()
r := sig.R.XScalar()
if r.IsZero() || sig.S.IsZero() {
return false
}
m := curve.FromHash(group, hash)
sInv := group.NewScalar().Set(sig.S).Invert()
mG := m.ActOnBase()
rX := r.Act(X)
R2 := mG.Add(rX)
R2 = sInv.Act(R2)
return R2.Equal(sig.R)
}
// SigEthereum returns the signature as a 65-byte Ethereum-format compact
// signature: R.x (32) ‖ S (32) ‖ V (1).
//
// It enforces EIP-2 low-S: an S above secp256k1n/2 is negated and the
// recovery parity flipped, so the output is always canonical and accepted by
// luxfi/geth (go-ethereum) ecrecover. A high-S signature is otherwise
// rejected as "invalid sender". V is the recovery id (0 or 1), derived from
// the compressed-R Y-parity byte.
//
// The receiver signature is left unmodified — curve.Scalar/Point are
// pointer-backed, so S is copied before normalization to avoid aliasing the
// caller's value.
func (sig Signature) SigEthereum() ([]byte, error) {
group := sig.R.Curve()
s := group.NewScalar().Set(sig.S)
overHalf := s.IsOverHalfOrder() // s-values greater than secp256k1n/2 are non-canonical
if overHalf {
s.Negate()
}
rBytes, err := sig.R.MarshalBinary() // compressed point: 0x02|0x03 ‖ X(32)
if err != nil {
return nil, err
}
if len(rBytes) != 33 {
return nil, fmt.Errorf("ecdsa: unexpected compressed R length %d, want 33", len(rBytes))
}
sBytes, err := s.MarshalBinary()
if err != nil {
return nil, err
}
if len(sBytes) != 32 {
return nil, fmt.Errorf("ecdsa: unexpected S length %d, want 32", len(sBytes))
}
out := make([]byte, 65)
copy(out[0:32], rBytes[1:]) // X coordinate (drop the parity prefix)
copy(out[32:64], sBytes) // low-S
v := rBytes[0] - 2 // 0x02 -> 0, 0x03 -> 1: Y parity is the recovery id
if overHalf {
v ^= 1 // negating S flips the recovery parity
}
out[64] = v
return out, nil
}