diff --git a/aggregator/pkg/model/model_mappers.go b/aggregator/pkg/model/model_mappers.go index 1cd279673..a295e6453 100644 --- a/aggregator/pkg/model/model_mappers.go +++ b/aggregator/pkg/model/model_mappers.go @@ -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) } diff --git a/aggregator/tests/commit_verification_api_test.go b/aggregator/tests/commit_verification_api_test.go index 45ae63e12..608780eac 100644 --- a/aggregator/tests/commit_verification_api_test.go +++ b/aggregator/tests/commit_verification_api_test.go @@ -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") @@ -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 @@ -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] { diff --git a/protocol/ecdsa.go b/protocol/ecdsa.go new file mode 100644 index 000000000..031a2c84d --- /dev/null +++ b/protocol/ecdsa.go @@ -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 +} diff --git a/protocol/ecdsa_test.go b/protocol/ecdsa_test.go new file mode 100644 index 000000000..6b5f11087 --- /dev/null +++ b/protocol/ecdsa_test.go @@ -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]) + } +} diff --git a/protocol/signature.go b/protocol/signature.go index f16855419..dbec84b31 100644 --- a/protocol/signature.go +++ b/protocol/signature.go @@ -1,289 +1,219 @@ 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 +// SignatureScheme is a uint8 that defines the identifier byte for the signature scheme used in the canonical encoding. +// Note: This scheme is only used within the CommiteeVerifier off-chain. Other verifiers do NOT need to conform to this specification. +// +// -------------------------------------------------------------------------------------------------------------------------- +// | ID | Scheme | Curve | Signature Size | Public Key Size | Total Size | Notes | +// |-----------|----------------|-----------|----------------|-------------------|------------|-----------------------------| +// | 0x00 | Reserved | - | - | - | - | Placeholder | +// | 0x01 | ECDSA | secp256k1 | 64 bytes | 0 bytes (derived) | 65 bytes | v normalized to 27 | +// | 0x02 | EdDSA | ed25519 | 64 bytes | 32 bytes | 97 bytes | Standard Ed25519 | +// | 0x03-0x7F | Reserved | - | - | - | - | Reserved for future schemes | +// | 0x80-0xFF | Experimental | - | - | - | - | Experimental/private use | +// -------------------------------------------------------------------------------------------------------------------------- +type SignatureScheme = uint8 + +const ( + SchemeReserved SignatureScheme = 0x00 + SchemeReservedString string = "Reserved" + SchemeECDSA SignatureScheme = 0x01 + SchemeECDSAString string = "ECDSA" + SchemeEdDSA SignatureScheme = 0x02 + SchemeEdDSAString string = "EdDSA" +) -// Data represents a signature with its associated signer address. -type Data struct { - R [32]byte - S [32]byte - Signer common.Address -} +const ( + SchemeSize = 1 + // ECDSA signature sizes + ECDSASignatureSize = 64 // R (32) + S (32) + ECDSATotalSize = SchemeSize + ECDSASignatureSize // 65 bytes -// 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)") - } + // EdDSA signature sizes + EdDSASignatureSize = 64 + EdDSAPublicKeySize = 32 + EdDSATotalSize = SchemeSize + EdDSASignatureSize + EdDSAPublicKeySize // 97 bytes - // 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") - } + Keecak256HashSize = 32 +) - // 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") - } - } +var ( + ErrInvalidScheme = errors.New("invalid signature scheme") + ErrInvalidSignatureSize = errors.New("invalid signature size") + ErrInvalidECDSAFormat = errors.New("invalid ECDSA signature format") + ErrInvalidEdDSAFormat = errors.New("invalid EdDSA signature format") + ErrZeroSignature = errors.New("signature cannot be zero") + ErrInvalidPreimageLength = errors.New("invalid preimage length") +) - // Serialize back to fixed 32-byte big-endian - copy(r32[:], leftPad32(r.Bytes())) - copy(s32[:], leftPad32(s.Bytes())) - return r32, s32, nil +type Signature struct { + Scheme SignatureScheme + Signature ByteSlice + PublicKey ByteSlice } -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 +func SchemeString(scheme SignatureScheme) string { + switch scheme { + case SchemeECDSA: + return SchemeECDSAString + case SchemeEdDSA: + return SchemeEdDSAString + default: + return SchemeReservedString } - 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) +// ECDSASignature represents an ECDSA signature in canonical format. +// Note: v is normalized to 27 so is not included in the format. +// ┌──────────┬──────────────────┬──────────────────┐ +// │ Scheme │ R (32 bytes) │ S (32 bytes) │ +// │ (0x01) │ big-endian │ big-endian │ +// └──────────┴──────────────────┴──────────────────┘ +// Total: 65 Bytes +type ECDSASignature struct { + R [32]byte + S [32]byte + PublicKey [20]byte // Derived from signature + preimage. Not included in the encoding } -// 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) +func (e *ECDSASignature) Bytes() []byte { + output := make([]byte, 65) + return output } -// 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 +// EdDSASignature represents an EdDSA signature in canonical format. +// ┌──────────┬─────────────────────────┬───────────────┐ +// │ Scheme │ Signature (64 bytes) │ Public Key │ +// │ (0x02) │ little-endian │ (32 bytes) │ +// └──────────┴─────────────────────────┴───────────────┘ +// Total: 97 bytes +type EdDSASignature struct { + Signature [64]byte + PublicKey [32]byte } -// 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 - }) +func (e *EdDSASignature) Bytes() []byte { + output := make([]byte, 97) + copy(output[0:1], []byte{SchemeEdDSA}) + copy(output[1:65], e.Signature[:]) + copy(output[65:97], e.PublicKey[:]) + return output } -// EncodeSignatures 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 EncodeSignatures(signatures []Data) ([]byte, error) { - if len(signatures) == 0 { - return nil, fmt.Errorf("no signatures provided") +// ToECDSA converts a generic signature structure into an ECDSA signature with the public key additionally included. +func (s *Signature) ToECDSA() (ECDSASignature, error) { + if s.Scheme != SchemeECDSA { + return ECDSASignature{}, ErrInvalidScheme } - // 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 + return ECDSASignature{ + R: [32]byte(s.Signature[0:32]), + S: [32]byte(s.Signature[0:64]), + PublicKey: [20]byte(s.PublicKey), + }, nil } -// DecodeSignatures 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 DecodeSignatures(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") +// ToEdDSA converts a generic signature structure into an EdDSA signature with the public key additionally included. +func (s *Signature) ToEdDSA() (EdDSASignature, error) { + if s.Scheme != SchemeEdDSA { + return EdDSASignature{}, ErrInvalidScheme } - // Read signature length from first 2 bytes (big-endian uint16) - signatureLength := uint16(data[0])<<8 | uint16(data[1]) + return EdDSASignature{ + Signature: [64]byte(s.Signature), + PublicKey: [32]byte(s.PublicKey), + }, nil +} - // 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)) +func (s *Signature) Bytes() ([]byte, error) { + switch s.Scheme { + case SchemeECDSA: + ecdsa, err := s.ToECDSA() + if err != nil { + return nil, err + } + return ecdsa.Bytes(), nil + default: + return nil, ErrInvalidScheme } +} - // 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) +func NewSignature(scheme SignatureScheme, signature ByteSlice, publicKey ByteSlice) Signature { + return Signature{ + Scheme: SchemeECDSA, + Signature: signature, + PublicKey: publicKey, } +} - numSignatures := int(signatureLength) / 64 - if numSignatures == 0 { - return nil, nil, fmt.Errorf("no signatures found") +// DecodeCanonicalSignature converts a []byte into a decoded structure containing both the signature and public key. +// Note: For ECDSA a pre-image must be provided to recover the public key. +func DecodeSignature(data ByteSlice, preimage ByteSlice) (Signature, error) { + if len(data) < 1 { + return Signature{}, ErrInvalidSignatureSize } - // Extract R and S arrays - rs := make([][32]byte, numSignatures) - ss := make([][32]byte, numSignatures) + scheme := SignatureScheme(data[0]) - 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 + switch scheme { + case SchemeECDSA: + return decodeECDSASignatureFromCanonicalFormat(data, preimage) + case SchemeEdDSA: + return decodeEdDSASignatureFromCanonicalFormat(data) + default: + return Signature{}, fmt.Errorf("%w: unknown scheme 0x%02x", ErrInvalidScheme, scheme) } - - return rs, ss, nil } -// RecoverSigners recovers signer addresses from signatures and a hash. -// This is useful after decoding signatures when you need the signer addresses. -func RecoverSigners(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)) +func decodeECDSASignatureFromCanonicalFormat(data ByteSlice, preimage ByteSlice) (Signature, error) { + if len(data) != ECDSATotalSize { + return Signature{}, fmt.Errorf("%w: ECDSA signature must be %d bytes, got %d", ErrInvalidSignatureSize, ECDSASignatureSize, len(data)-1) } - signers := make([]common.Address, len(rs)) - for i := 0; i < len(rs); i++ { - signer, err := RecoverSigner(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 + signatureData := make([]byte, 64) + copy(signatureData[0:64], data[1:65]) + if len(preimage) == 0 { + return Signature{ + Scheme: SchemeECDSA, + Signature: signatureData, + }, nil } - return signers, nil -} - -func RecoverSigner(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) + if len(preimage) != 32 { + return Signature{}, fmt.Errorf("%w: Preimage length must be %d bytes, got %d", ErrInvalidPreimageLength, Keecak256HashSize, len(preimage)) } - // Convert to address - unmarshalledPub, err := crypto.UnmarshalPubkey(pubKey) + address, err := RecoverECDSASigner([32]byte(preimage), [32]byte(signatureData[0:32]), [32]byte(signatureData[32:64])) if err != nil { - return common.Address{}, fmt.Errorf("failed to unmarshal public key for signature: %w", err) + return Signature{}, err } - signer := crypto.PubkeyToAddress(*unmarshalledPub) - - return signer, nil + return Signature{ + Scheme: SchemeECDSA, + Signature: signatureData, + PublicKey: address.Bytes(), + }, nil } -// EncodeSingleSignature encodes a single signature as R||S||Signer (96 bytes). -// This format is used by verifiers when sending individual signatures to the aggregator. -// Format: [32 bytes R][32 bytes S][20 bytes Signer Address]. -func EncodeSingleSignature(sig Data) ([]byte, error) { - if sig.R == [32]byte{} || sig.S == [32]byte{} { - return nil, fmt.Errorf("signature R and S cannot be zero") +func decodeEdDSASignatureFromCanonicalFormat(data ByteSlice) (Signature, error) { + if len(data) != EdDSATotalSize { + return Signature{}, ErrInvalidSignatureSize } - if sig.Signer == (common.Address{}) { - return nil, fmt.Errorf("signer address cannot be zero") - } - - result := make([]byte, 96) - copy(result[0:32], sig.R[:]) - copy(result[32:64], sig.S[:]) - copy(result[64:84], sig.Signer[:]) - - return result, nil -} + signatureData := make([]byte, 64) + copy(signatureData[0:64], data[1:65]) -// DecodeSingleSignature decodes a single signature from R||S||Signer format (96 bytes). -// Returns the R, S components and the signer address. -func DecodeSingleSignature(data []byte) (r, s [32]byte, signer common.Address, err error) { - if len(data) != 96 { - return r, s, signer, fmt.Errorf("signature data must be exactly 96 bytes, got %d", len(data)) - } - - copy(r[:], data[0:32]) - copy(s[:], data[32:64]) - copy(signer[:], data[64:84]) - - if r == [32]byte{} || s == [32]byte{} { - return r, s, signer, fmt.Errorf("signature R and S cannot be zero") - } - - if signer == (common.Address{}) { - return r, s, signer, fmt.Errorf("signer address cannot be zero") - } + publicKey := make([]byte, 32) + copy(publicKey[0:32], data[65:97]) - return r, s, signer, nil + return Signature{ + Scheme: SchemeEdDSA, + Signature: signatureData, + PublicKey: publicKey, + }, nil } diff --git a/protocol/signature_test.go b/protocol/signature_test.go deleted file mode 100644 index fec1e9b19..000000000 --- a/protocol/signature_test.go +++ /dev/null @@ -1,253 +0,0 @@ -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 := RecoverSigners(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]) - } -} - -func TestEncodeSingleSignature(t *testing.T) { - t.Run("valid signature", func(t *testing.T) { - sig := Data{ - R: [32]byte{0x01}, - S: [32]byte{0x02}, - Signer: common.HexToAddress("0x1234567890123456789012345678901234567890"), - } - - encoded, err := EncodeSingleSignature(sig) - require.NoError(t, err) - require.Len(t, encoded, 96) - require.Equal(t, sig.R[:], encoded[0:32]) - require.Equal(t, sig.S[:], encoded[32:64]) - require.Equal(t, sig.Signer[:], encoded[64:84]) - }) - - t.Run("zero R", func(t *testing.T) { - sig := Data{ - R: [32]byte{}, - S: [32]byte{0x02}, - Signer: common.HexToAddress("0x1234567890123456789012345678901234567890"), - } - - _, err := EncodeSingleSignature(sig) - require.Error(t, err) - require.Contains(t, err.Error(), "signature R and S cannot be zero") - }) - - t.Run("zero S", func(t *testing.T) { - sig := Data{ - R: [32]byte{0x01}, - S: [32]byte{}, - Signer: common.HexToAddress("0x1234567890123456789012345678901234567890"), - } - - _, err := EncodeSingleSignature(sig) - require.Error(t, err) - require.Contains(t, err.Error(), "signature R and S cannot be zero") - }) - - t.Run("zero signer", func(t *testing.T) { - sig := Data{ - R: [32]byte{0x01}, - S: [32]byte{0x02}, - Signer: common.Address{}, - } - - _, err := EncodeSingleSignature(sig) - require.Error(t, err) - require.Contains(t, err.Error(), "signer address cannot be zero") - }) -} - -func TestDecodeSingleSignature(t *testing.T) { - t.Run("valid signature", func(t *testing.T) { - expectedR := [32]byte{0x01} - expectedS := [32]byte{0x02} - expectedSigner := common.HexToAddress("0x1234567890123456789012345678901234567890") - - data := make([]byte, 96) - copy(data[0:32], expectedR[:]) - copy(data[32:64], expectedS[:]) - copy(data[64:84], expectedSigner[:]) - - r, s, signer, err := DecodeSingleSignature(data) - require.NoError(t, err) - require.Equal(t, expectedR, r) - require.Equal(t, expectedS, s) - require.Equal(t, expectedSigner, signer) - }) - - t.Run("wrong length", func(t *testing.T) { - data := make([]byte, 95) - _, _, _, err := DecodeSingleSignature(data) - require.Error(t, err) - require.Contains(t, err.Error(), "signature data must be exactly 96 bytes") - }) - - t.Run("zero R", func(t *testing.T) { - data := make([]byte, 96) - s := [32]byte{0x02} - copy(data[32:64], s[:]) - signer := common.HexToAddress("0x1234567890123456789012345678901234567890") - copy(data[64:84], signer[:]) - - _, _, _, err := DecodeSingleSignature(data) - require.Error(t, err) - require.Contains(t, err.Error(), "signature R and S cannot be zero") - }) - - t.Run("zero S", func(t *testing.T) { - data := make([]byte, 96) - r := [32]byte{0x01} - copy(data[0:32], r[:]) - signer := common.HexToAddress("0x1234567890123456789012345678901234567890") - copy(data[64:84], signer[:]) - - _, _, _, err := DecodeSingleSignature(data) - require.Error(t, err) - require.Contains(t, err.Error(), "signature R and S cannot be zero") - }) - - t.Run("zero signer", func(t *testing.T) { - data := make([]byte, 96) - r := [32]byte{0x01} - copy(data[0:32], r[:]) - s := [32]byte{0x02} - copy(data[32:64], s[:]) - - _, _, _, err := DecodeSingleSignature(data) - require.Error(t, err) - require.Contains(t, err.Error(), "signer address cannot be zero") - }) -} - -func TestSingleSignatureRoundTrip(t *testing.T) { - privateKey, err := crypto.GenerateKey() - require.NoError(t, err) - - hash := Keccak256([]byte("test message")) - - r, s, addr, err := SignV27(hash[:], privateKey) - require.NoError(t, err) - - sig := Data{ - R: r, - S: s, - Signer: addr, - } - - encoded, err := EncodeSingleSignature(sig) - require.NoError(t, err) - - decodedR, decodedS, decodedSigner, err := DecodeSingleSignature(encoded) - require.NoError(t, err) - - require.Equal(t, sig.R, decodedR) - require.Equal(t, sig.S, decodedS) - require.Equal(t, sig.Signer, decodedSigner) -} diff --git a/verifier/commit/signer.go b/verifier/commit/signer.go index cf853d9d0..3e4855829 100644 --- a/verifier/commit/signer.go +++ b/verifier/commit/signer.go @@ -59,18 +59,12 @@ func (ecdsa *ECDSASigner) Sign(data []byte) ([]byte, error) { return nil, fmt.Errorf("failed to sign message: %w", err) } - signature := protocol.Data{ - R: r, - S: s, - Signer: signerAddress, - } - - encodedSignature, err := protocol.EncodeSingleSignature(signature) - if err != nil { - return nil, fmt.Errorf("failed to encode signature: %w", err) - } + signatureData := make([]byte, 64) + copy(signatureData[0:32], r[:]) + copy(signatureData[32:64], s[:]) - return encodedSignature, nil + signature := protocol.NewSignature(protocol.SchemeECDSA, signatureData[:], signerAddress[:]) + return signature.Bytes() } // ReadPrivateKeyFromString reads a private key from a string and returns the bytes. @@ -105,16 +99,10 @@ func (s *ECDSASignerWithKeystoreSigner) Sign(data []byte) ([]byte, error) { return nil, fmt.Errorf("failed to sign message: %w", err) } - signature := protocol.Data{ - R: r32, - S: s32, - Signer: addr, - } - - encodedSignature, err := protocol.EncodeSingleSignature(signature) - if err != nil { - return nil, fmt.Errorf("failed to encode signature: %w", err) - } + signatureData := make([]byte, 64) + copy(signatureData[0:32], r32[:]) + copy(signatureData[32:64], s32[:]) - return encodedSignature, nil + signature := protocol.NewSignature(protocol.SchemeECDSA, signatureData[:], addr[:]) + return signature.Bytes() } diff --git a/verifier/commit/signer_test.go b/verifier/commit/signer_test.go index 14588b187..31aa3888e 100644 --- a/verifier/commit/signer_test.go +++ b/verifier/commit/signer_test.go @@ -21,18 +21,21 @@ func TestECDSASigner_Sign(t *testing.T) { hash := crypto.Keccak256([]byte("test message")) - signature, err := signer.Sign(hash) + sigBytes, err := signer.Sign(hash) require.NoError(t, err) - require.Len(t, signature, 96, "signature should be 96 bytes (32 R + 32 S + 20 Signer)") + require.Len(t, sigBytes, 65, "signature should be 65 bytes (1 scheme + 32 R + 32 S)") - r, s, signerAddr, err := protocol.DecodeSingleSignature(signature) + signature, err := protocol.DecodeSignature(sigBytes, hash) require.NoError(t, err) expectedAddr := crypto.PubkeyToAddress(privateKey.PublicKey) - require.Equal(t, expectedAddr, signerAddr, "signer address should match") + require.Equal(t, expectedAddr, signature.PublicKey, "signer address should match") - require.NotEqual(t, [32]byte{}, r, "R should not be zero") - require.NotEqual(t, [32]byte{}, s, "S should not be zero") + ecdsaSig, err := signature.ToECDSA() + require.NoError(t, err) + + require.NotEqual(t, [32]byte{}, ecdsaSig.R, "R should not be zero") + require.NotEqual(t, [32]byte{}, ecdsaSig.S, "S should not be zero") } func TestECDSASigner_SignDifferentMessages(t *testing.T) { @@ -122,16 +125,19 @@ func TestECDSASignerWithKeystoreSigner_Sign(t *testing.T) { hash := crypto.Keccak256([]byte("test message")) - signature, err := signer.Sign(hash) + sigBytes, err := signer.Sign(hash) require.NoError(t, err) - require.Len(t, signature, 96, "signature should be 96 bytes (32 R + 32 S + 20 Signer)") + require.Len(t, sigBytes, 65, "signature should be 65 bytes (1 scheme + 32 R + 32 S)") - r, s, signerAddr, err := protocol.DecodeSingleSignature(signature) + signature, err := protocol.DecodeSignature(sigBytes, hash) require.NoError(t, err) expectedAddr := crypto.PubkeyToAddress(privateKey.PublicKey) - require.Equal(t, expectedAddr, signerAddr, "signer address should match") + require.Equal(t, expectedAddr, signature.PublicKey, "signer address should match") + + ecdsaSig, err := signature.ToECDSA() + require.NoError(t, err) - require.NotEqual(t, [32]byte{}, r, "R should not be zero") - require.NotEqual(t, [32]byte{}, s, "S should not be zero") + require.NotEqual(t, [32]byte{}, ecdsaSig.R, "R should not be zero") + require.NotEqual(t, [32]byte{}, ecdsaSig.S, "S should not be zero") } diff --git a/verifier/commit/verifier.go b/verifier/commit/verifier.go index d80d46e50..e9d67adc1 100644 --- a/verifier/commit/verifier.go +++ b/verifier/commit/verifier.go @@ -212,7 +212,6 @@ func (cv *Verifier) verifyMessage(ctx context.Context, verificationTask verifier cv.lggr.Infow("Message signed successfully", "messageID", msgIDStr, "signer", cv.signerAddress.String(), - "signatureLength", len(encodedSignature), ) // 4. Create CCV node data with all required fields diff --git a/verifier/interfaces.go b/verifier/interfaces.go index 6566ffc27..cc214836f 100644 --- a/verifier/interfaces.go +++ b/verifier/interfaces.go @@ -9,9 +9,8 @@ import ( ) // MessageSigner defines the interface for signing data. -// TODO: revisit this, shouldn't be ECDSA specific? type MessageSigner interface { - // Sign returns an ECDSA signature that is 65 bytes long (R + S + V). + // Sign returns a canonical signature format that works across multiple schemes. Sign(data []byte) (signed []byte, err error) }