Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion aggregator/pkg/model/model_mappers.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func MapAggregatedReportToVerifierResultProto(report *CommitAggregatedReport, c

signatures := findAllSignaturesValidInConfig(addressSignatures, quorumConfig)

encodedSignatures, err := protocol.EncodeSignatures(signatures)
encodedSignatures, err := protocol.EncodeECDSASignatures(signatures)
if err != nil {
return nil, fmt.Errorf("failed to encode signatures: %w", err)
}
Expand Down
6 changes: 3 additions & 3 deletions aggregator/tests/commit_verification_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ func validateSignatures(t *assert.CollectT, ccvData []byte, messageId protocol.B

// Decode the signature data
// We need to exclude the verifier version to get the simple signature data (i.e. length + sigs)
rs, ss, err := protocol.DecodeSignatures(ccvData[committee.VerifierVersionLength:])
rs, ss, err := protocol.DecodeECDSASignatures(ccvData[committee.VerifierVersionLength:])
require.NoError(t, err, "failed to decode CCV signature data")
require.Equal(t, len(rs), len(ss), "rs and ss arrays should have the same length")

Expand All @@ -362,7 +362,7 @@ func validateSignatures(t *assert.CollectT, ccvData []byte, messageId protocol.B
// Recover signer addresses from the aggregated signatures
hash, err := committee.NewSignableHash(messageId, ccvData)
require.NoError(t, err, "failed to create signed hash")
recoveredAddresses, err := protocol.RecoverSigners(hash, rs, ss)
recoveredAddresses, err := protocol.RecoverECDSASigners(hash, rs, ss)
require.NoError(t, err, "failed to recover signer addresses")

// Create a map of expected signer addresses for easier lookup
Expand Down Expand Up @@ -396,7 +396,7 @@ func validateSignatures(t *assert.CollectT, ccvData []byte, messageId protocol.B
if len(config.expectActualCCVData) > 0 {
for _, expectedSig := range config.expectActualCCVData {
found := false
expectedR, expectedS, err := protocol.DecodeSignatures(expectedSig)
expectedR, expectedS, err := protocol.DecodeECDSASignatures(expectedSig)
require.NoError(t, err, "failed to decode expected signature")
for i := range rs {
if rs[i] == expectedR[0] && ss[i] == expectedS[0] {
Expand Down
247 changes: 247 additions & 0 deletions protocol/ecdsa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package protocol

import (
"crypto/ecdsa"
"errors"
"fmt"
"math/big"
"sort"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
)

// curve order n for secp256k1.
var secpN = crypto.S256().Params().N

// Data represents a signature with its associated signer address.
type Data struct {
R [32]byte
S [32]byte
Signer common.Address
}

// NormalizeToV27 takes a standard 65-byte Ethereum signature (R||S||V) and
// rewrites it so that it is valid for ecrecover(hash, 27, r, s) on-chain.
// If V == 28 (or == 1 if your signer returns 0/1), we flip s := n - s and set V := 27.
// Output r,s are 32-byte big-endian scalars suitable for Solidity bytes32.
func NormalizeToV27(sig65 []byte) (r32, s32 [32]byte, err error) {
if len(sig65) != 65 {
return r32, s32, errors.New("signature must be 65 bytes")
}
r := new(big.Int).SetBytes(sig65[0:32])
s := new(big.Int).SetBytes(sig65[32:64])
v := uint64(sig65[64])

// Accept both conventions: 27/28 or 0/1
switch v {
case 0, 1:
v += 27
case 27, 28:
// ok
default:
return r32, s32, errors.New("invalid v (expected 0/1/27/28)")
}

// Basic scalar checks (defense in depth)
if r.Sign() == 0 || s.Sign() == 0 || r.Cmp(secpN) >= 0 || s.Cmp(secpN) >= 0 {
return r32, s32, errors.New("invalid r or s")
}

// If v == 28, flip s and set v = 27 so on-chain ecrecover(hash, 27, r, s) will work.
if v == 28 {
s.Sub(secpN, s)
if s.Sign() == 0 {
return r32, s32, errors.New("s became zero after flip")
}
}

// Serialize back to fixed 32-byte big-endian
copy(r32[:], leftPad32(r.Bytes()))
copy(s32[:], leftPad32(s.Bytes()))
return r32, s32, nil
}

func normalizeAndVerify(sig, hash []byte) (r32, s32 [32]byte, addr common.Address, err error) {
r32, s32, err = NormalizeToV27(sig)
if err != nil {
return r32, s32, common.Address{}, err
}

// Verify our normalization actually recovers the expected address on-chain semantics.
// We emulate ecrecover(hash,27,r,s) by reconstructing a 65B sig with v=0 and running SigToPub.
check := make([]byte, 65)
copy(check[0:32], r32[:])
copy(check[32:64], s32[:])
check[64] = 0 // SigToPub expects 0/1, not 27/28

pub, err := crypto.SigToPub(hash, check)
if err != nil {
return r32, s32, common.Address{}, err
}
return r32, s32, crypto.PubkeyToAddress(*pub), nil
}

// SignV27WithKeystoreSigner signs hash with the provided keystore signer and returns (r,s) such that on-chain ecrecover(hash,27,r,s) recovers the signer.
// This is equivalent to signing normally, then applying NormalizeToV27.
func SignV27WithKeystoreSigner(hash []byte, keystoreSigner interface {
Sign(data []byte) ([]byte, error)
},
) (r32, s32 [32]byte, addr common.Address, err error) {
sig, err := keystoreSigner.Sign(hash)
if err != nil {
return r32, s32, common.Address{}, err
}

return normalizeAndVerify(sig, hash)
}

// SignV27 signs hash with priv and returns (r,s) such that on-chain ecrecover(hash,27,r,s) recovers the signer.
// This is equivalent to signing normally, then applying NormalizeToV27.
func SignV27(hash []byte, priv *ecdsa.PrivateKey) (r32, s32 [32]byte, addr common.Address, err error) {
// go-ethereum's crypto.Sign returns 65 bytes: R||S||V, where V is 0/1 (recovery id).
sig, err := crypto.Sign(hash, priv)
if err != nil {
return r32, s32, common.Address{}, err
}

return normalizeAndVerify(sig, hash)
}

// Helper: left-pad a big-endian slice to 32 bytes.
func leftPad32(b []byte) []byte {
out := make([]byte, 32)
copy(out[32-len(b):], b)
return out
}

// SortSignaturesBySigner sorts signatures by signer address in ascending order.
// This is required for onchain validation which expects ordered signatures.
func SortSignaturesBySigner(signatures []Data) {
sort.Slice(signatures, func(i, j int) bool {
// Compare addresses as big integers (uint160)
addrI := signatures[i].Signer.Big()
addrJ := signatures[j].Signer.Big()
return addrI.Cmp(addrJ) < 0
})
}

// EncodeECDSASignatures encodes signatures in the simple format expected by CCIP v1.7 onchain validation.
// The format is: [2 bytes signature length][concatenated R,S pairs].
func EncodeECDSASignatures(signatures []Data) ([]byte, error) {
if len(signatures) == 0 {
return nil, fmt.Errorf("no signatures provided")
}

// Sort signatures by signer address for onchain compatibility
sortedSignatures := make([]Data, len(signatures))
copy(sortedSignatures, signatures)
SortSignaturesBySigner(sortedSignatures)

// Calculate signature length (each signature is 64 bytes: 32 R + 32 S)
//nolint:gosec // disable G115
signatureLength := uint16(len(sortedSignatures) * 64)

// Create result buffer
result := make([]byte, 2+int(signatureLength))

// Write signature length as first 2 bytes (big-endian uint16)
result[0] = byte(signatureLength >> 8)
result[1] = byte(signatureLength)

// Write concatenated R,S pairs
offset := 2
for _, sig := range sortedSignatures {
copy(result[offset:offset+32], sig.R[:])
offset += 32
copy(result[offset:offset+32], sig.S[:])
offset += 32
}

return result, nil
}

// DecodeECDSASignatures decodes simple-format signature data.
// The format is: [2 bytes signature length][concatenated R,S pairs]
// Returns rs, ss arrays in the same order as they appear in the data.
func DecodeECDSASignatures(data []byte) ([][32]byte, [][32]byte, error) {
if len(data) < 2 {
return nil, nil, fmt.Errorf("signature data too short: need at least 2 bytes for length")
}

// Read signature length from first 2 bytes (big-endian uint16)
signatureLength := uint16(data[0])<<8 | uint16(data[1])

// Validate data length
expectedLength := 2 + int(signatureLength)
if len(data) < expectedLength {
return nil, nil, fmt.Errorf("signature data too short: expected %d bytes, got %d", expectedLength, len(data))
}

// Validate signature length is multiple of 64 (32 R + 32 S per signature)
if signatureLength%64 != 0 {
return nil, nil, fmt.Errorf("invalid signature length: %d is not a multiple of 64", signatureLength)
}

numSignatures := int(signatureLength) / 64
if numSignatures == 0 {
return nil, nil, fmt.Errorf("no signatures found")
}

// Extract R and S arrays
rs := make([][32]byte, numSignatures)
ss := make([][32]byte, numSignatures)

offset := 2
for i := 0; i < numSignatures; i++ {
copy(rs[i][:], data[offset:offset+32])
offset += 32
copy(ss[i][:], data[offset:offset+32])
offset += 32
}

return rs, ss, nil
}

// RecoverECDSASigners recovers signer addresses from signatures and a hash.
// This is useful after decoding signatures when you need the signer addresses.
func RecoverECDSASigners(hash [32]byte, rs, ss [][32]byte) ([]common.Address, error) {
if len(rs) != len(ss) {
return nil, fmt.Errorf("rs and ss arrays have different lengths: %d vs %d", len(rs), len(ss))
}

signers := make([]common.Address, len(rs))
for i := 0; i < len(rs); i++ {
signer, err := RecoverECDSASigner(hash, rs[i], ss[i])
if err != nil {
return nil, fmt.Errorf("failed to recover signer for signature %d: %w", i, err)
}
signers[i] = signer
}

return signers, nil
}

func RecoverECDSASigner(hash, r, s [32]byte) (common.Address, error) {
// Create signature with v=0 (crypto.Ecrecover expects 0/1, not 27/28)
sig := make([]byte, 65)
copy(sig[0:32], r[:])
copy(sig[32:64], s[:])
sig[64] = 0 // Always use v=0 since we normalize all signatures to v=27

// Recover public key
pubKey, err := crypto.Ecrecover(hash[:], sig)
if err != nil {
return common.Address{}, fmt.Errorf("failed to recover public key for signature: %w", err)
}

// Convert to address
unmarshalledPub, err := crypto.UnmarshalPubkey(pubKey)
if err != nil {
return common.Address{}, fmt.Errorf("failed to unmarshal public key for signature: %w", err)
}

signer := crypto.PubkeyToAddress(*unmarshalledPub)

return signer, nil
}
112 changes: 112 additions & 0 deletions protocol/ecdsa_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package protocol

import (
"crypto/ecdsa"
"testing"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/require"
)

func TestSignV27(t *testing.T) {
privateKey, err := crypto.GenerateKey()
require.NoError(t, err)

hash := Keccak256([]byte("test message"))

// Sign with V27 compatibility
r, s, addr, err := SignV27(hash[:], privateKey)
require.NoError(t, err)

// Verify the returned address matches the expected one
expectedAddress := crypto.PubkeyToAddress(privateKey.PublicKey)
require.Equal(t, expectedAddress, addr)

// Verify the signature works with v=0 (which corresponds to v=27 onchain)
sig := make([]byte, 65)
copy(sig[0:32], r[:])
copy(sig[32:64], s[:])
sig[64] = 0 // SigToPub expects 0/1, not 27/28

pubKey, err := crypto.SigToPub(hash[:], sig)
require.NoError(t, err)

// Verify it's the correct signer
actualAddress := crypto.PubkeyToAddress(*pubKey)
require.Equal(t, expectedAddress, actualAddress)
}

func TestSortSignaturesBySigner(t *testing.T) {
// Create test signatures with different signer addresses
signatures := []Data{
{
R: [32]byte{0x03},
S: [32]byte{0x04},
Signer: common.HexToAddress("0x0000000000000000000000000000000000000003"),
},
{
R: [32]byte{0x01},
S: [32]byte{0x02},
Signer: common.HexToAddress("0x0000000000000000000000000000000000000001"),
},
{
R: [32]byte{0x05},
S: [32]byte{0x06},
Signer: common.HexToAddress("0x0000000000000000000000000000000000000002"),
},
}

// Sort signatures
SortSignaturesBySigner(signatures)

// Verify they are sorted by signer address
require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000001"), signatures[0].Signer)
require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000002"), signatures[1].Signer)
require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000003"), signatures[2].Signer)

// Verify the corresponding r,s values moved with their signers
require.Equal(t, [32]byte{0x01}, signatures[0].R)
require.Equal(t, [32]byte{0x02}, signatures[0].S)
require.Equal(t, [32]byte{0x05}, signatures[1].R)
require.Equal(t, [32]byte{0x06}, signatures[1].S)
require.Equal(t, [32]byte{0x03}, signatures[2].R)
require.Equal(t, [32]byte{0x04}, signatures[2].S)
}

func TestRecoverSigners(t *testing.T) {
// Create multiple test private keys
privateKeys := make([]*ecdsa.PrivateKey, 3)
expectedAddresses := make([]common.Address, 3)
for i := 0; i < 3; i++ {
pk, err := crypto.GenerateKey()
require.NoError(t, err)
privateKeys[i] = pk
expectedAddresses[i] = crypto.PubkeyToAddress(pk.PublicKey)
}

// Create a test hash
hash := Keccak256([]byte("test message"))
var hashArray [32]byte
copy(hashArray[:], hash[:])

// Sign with each private key using V27 compatibility
rs := make([][32]byte, 0)
ss := make([][32]byte, 0)
for _, pk := range privateKeys {
r, s, _, err := SignV27(hashArray[:], pk)
require.NoError(t, err)
rs = append(rs, r)
ss = append(ss, s)
}

// Recover signers
recoveredAddresses, err := RecoverECDSASigners(hashArray, rs, ss)
require.NoError(t, err)
require.Len(t, recoveredAddresses, 3)

// Verify all addresses match
for i, expected := range expectedAddresses {
require.Equal(t, expected, recoveredAddresses[i])
}
}
Loading
Loading