diff --git a/clearnode/erc1271.go b/clearnode/erc1271.go new file mode 100644 index 000000000..dc97fc76f --- /dev/null +++ b/clearnode/erc1271.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// ERC-1271 magic value returned by isValidSignature when the signature is valid. +// bytes4(keccak256("isValidSignature(bytes32,bytes)")) +var erc1271MagicValue = [4]byte{0x16, 0x26, 0xba, 0x7e} + +// isValidSignature(bytes32,bytes) selector +var isValidSignatureSelector = crypto.Keccak256([]byte("isValidSignature(bytes32,bytes)"))[:4] + +// Ethereum interface for ERC-1271 verification (matches existing Ethereum interface in the codebase). +type ERC1271Verifier interface { + CallContract(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) + CodeAt(ctx context.Context, account common.Address, blockNumber *big.Int) ([]byte, error) +} + +// IsContract checks if the given address is a smart contract (has code deployed). +func IsContract(ctx context.Context, client ERC1271Verifier, addr common.Address) (bool, error) { + code, err := client.CodeAt(ctx, addr, nil) + if err != nil { + return false, fmt.Errorf("failed to check contract code: %w", err) + } + return len(code) > 0, nil +} + +// VerifyERC1271Signature calls isValidSignature(bytes32,bytes) on a smart contract +// wallet and returns true if the contract considers the signature valid. +func VerifyERC1271Signature(ctx context.Context, client ERC1271Verifier, contractAddr common.Address, hash []byte, signature []byte) (bool, error) { + // ABI-encode the call: isValidSignature(bytes32 hash, bytes signature) + // Selector (4 bytes) + hash (32 bytes padded) + offset to bytes (32 bytes) + length (32 bytes) + signature data (padded to 32) + callData := make([]byte, 0, 4+32+32+32+len(signature)+32) + + // Function selector + callData = append(callData, isValidSignatureSelector...) + + // bytes32 hash (already 32 bytes) + if len(hash) != 32 { + return false, fmt.Errorf("hash must be 32 bytes, got %d", len(hash)) + } + callData = append(callData, hash...) + + // Offset to bytes parameter (always 64 = 0x40 for two fixed params) + offset := make([]byte, 32) + offset[31] = 64 + callData = append(callData, offset...) + + // Length of signature bytes + sigLen := make([]byte, 32) + bigLen := big.NewInt(int64(len(signature))) + bigLen.FillBytes(sigLen) + callData = append(callData, sigLen...) + + // Signature data (padded to 32-byte boundary) + callData = append(callData, signature...) + if pad := len(signature) % 32; pad != 0 { + callData = append(callData, make([]byte, 32-pad)...) + } + + result, err := client.CallContract(ctx, ethereum.CallMsg{ + To: &contractAddr, + Data: callData, + }, nil) + if err != nil { + return false, fmt.Errorf("isValidSignature call failed: %w", err) + } + + if len(result) < 32 { + return false, fmt.Errorf("isValidSignature returned %d bytes, expected 32", len(result)) + } + + // The magic value is in the first 4 bytes of the 32-byte return value + return result[0] == erc1271MagicValue[0] && + result[1] == erc1271MagicValue[1] && + result[2] == erc1271MagicValue[2] && + result[3] == erc1271MagicValue[3], nil +} diff --git a/clearnode/main.go b/clearnode/main.go index 24cb2cde6..1f1c5a4b0 100644 --- a/clearnode/main.go +++ b/clearnode/main.go @@ -9,6 +9,7 @@ import ( "syscall" "time" + "github.com/ethereum/go-ethereum/ethclient" "github.com/prometheus/client_golang/prometheus/promhttp" ) @@ -61,7 +62,20 @@ func main() { appSessionService := NewAppSessionService(db, wsNotifier) channelService := NewChannelService(db, config.blockchains, &config.assets, signer) - NewRPCRouter(rpcNode, config, signer, appSessionService, channelService, db, authManager, metrics, rpcStore, wsNotifier, logger) + // Create ethClients for ERC-1271 smart wallet signature verification + ethClients := make(map[uint32]*ethclient.Client) + for chainID, blockchain := range config.blockchains { + client, err := ethclient.Dial(blockchain.BlockchainRPC) + if err != nil { + logger.Warn("failed to create ethClient for ERC-1271 verification", "chainID", chainID, "error", err) + continue + } + ethClients[chainID] = client + logger.Info("ethClient for ERC-1271 ready", "chainID", chainID) + } + + router := NewRPCRouter(rpcNode, config, signer, appSessionService, channelService, db, authManager, metrics, rpcStore, wsNotifier, logger) + router.EthClients = ethClients rpcListenAddr := ":8000" rpcListenEndpoint := "/ws" @@ -145,6 +159,12 @@ func main() { logger.Error("failed to shut down RPC server", "error", err) } + // Close ethClients used for ERC-1271 verification + for chainID, client := range ethClients { + client.Close() + logger.Info("ethClient closed", "chainID", chainID) + } + logger.Info("shutdown complete") } diff --git a/clearnode/rpc_router.go b/clearnode/rpc_router.go index 5889bca4a..c56ebed61 100644 --- a/clearnode/rpc_router.go +++ b/clearnode/rpc_router.go @@ -6,6 +6,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" "gorm.io/gorm" ) @@ -25,6 +26,7 @@ type RPCRouter struct { RPCStore *RPCStore wsNotifier *WSNotifier MessageCache *MessageCache + EthClients map[uint32]*ethclient.Client lg Logger } diff --git a/clearnode/rpc_router_auth.go b/clearnode/rpc_router_auth.go index a97a3a6e3..f4c19199b 100644 --- a/clearnode/rpc_router_auth.go +++ b/clearnode/rpc_router_auth.go @@ -3,8 +3,10 @@ package main import ( "context" "fmt" + "strings" "time" + "github.com/ethereum/go-ethereum/common" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/shopspring/decimal" @@ -183,7 +185,9 @@ func (r *RPCRouter) handleAuthJWTVerify(ctx context.Context, authParams AuthVeri }, nil } -// handleAuthJWTVerify verifies the challenge signature and returns the policy, response data and error. +// handleAuthSigVerify verifies the challenge signature and returns the policy, response data and error. +// It first attempts ECDSA recovery. If that fails or the recovered address doesn't match, +// it falls back to ERC-1271 smart contract wallet verification. func (r *RPCRouter) handleAuthSigVerify(ctx context.Context, sig Signature, authParams AuthVerifyParams) (*Policy, any, error) { logger := LoggerFromContext(ctx) @@ -192,7 +196,13 @@ func (r *RPCRouter) handleAuthSigVerify(ctx context.Context, sig Signature, auth logger.Error("failed to get challenge", "error", err) return nil, nil, RPCErrorf("invalid challenge") } - recoveredAddress, err := RecoverAddressFromEip712Signature( + + // Copy sig so ECDSA recovery's V-byte mutation (27/28 → 0/1) doesn't + // corrupt the original for the ERC-1271 fallback path. + ecdsaSig := make(Signature, len(sig)) + copy(ecdsaSig, sig) + + recoveredAddress, ecdsaErr := RecoverAddressFromEip712Signature( challenge.Address, challenge.Token.String(), challenge.SessionKey, @@ -200,10 +210,56 @@ func (r *RPCRouter) handleAuthSigVerify(ctx context.Context, sig Signature, auth challenge.Allowances, challenge.Scope, challenge.SessionKeyExpiresAt, - sig) - if err != nil { - logger.Error("failed to recover address from signature", "error", err) - return nil, nil, RPCErrorf("invalid signature") + ecdsaSig) + + // If ECDSA recovery failed or recovered address doesn't match, try ERC-1271 + ecdsaMatch := ecdsaErr == nil && strings.EqualFold(recoveredAddress, challenge.Address) + if !ecdsaMatch { + erc1271Verified := false + expectedAddr := common.HexToAddress(challenge.Address) + + typedDataHash, hashErr := ComputeAuthTypedDataHash( + challenge.Address, + challenge.Token.String(), + challenge.SessionKey, + challenge.Application, + challenge.Allowances, + challenge.Scope, + challenge.SessionKeyExpiresAt, + ) + + if hashErr == nil { + // Try ERC-1271 verification on all configured chains + for chainID, client := range r.EthClients { + isContract, cErr := IsContract(ctx, client, expectedAddr) + if cErr != nil || !isContract { + continue + } + + verified, vErr := VerifyERC1271Signature(ctx, client, expectedAddr, typedDataHash, sig) + if vErr == nil && verified { + logger.Info("ERC-1271 signature verified for smart wallet", + "address", challenge.Address, "chainID", chainID) + erc1271Verified = true + break + } + } + } else { + logger.Warn("failed to compute typed data hash for ERC-1271 fallback", "error", hashErr) + } + + if !erc1271Verified { + if ecdsaErr != nil { + logger.Error("failed to recover address from signature", "error", ecdsaErr) + } else { + logger.Debug("signature address mismatch and ERC-1271 verification failed", + "expected", challenge.Address, "recovered", recoveredAddress) + } + return nil, nil, RPCErrorf("invalid signature") + } + + // ERC-1271 verified — use the challenge address as the recovered address + recoveredAddress = challenge.Address } if err := r.AuthManager.ValidateChallenge(authParams.Challenge, recoveredAddress); err != nil { diff --git a/clearnode/signer.go b/clearnode/signer.go index 4642214a0..1c1056ed1 100644 --- a/clearnode/signer.go +++ b/clearnode/signer.go @@ -79,7 +79,9 @@ func RecoverAddress(message []byte, sig Signature) (string, error) { return addr.Hex(), nil } -func RecoverAddressFromEip712Signature( +// ComputeAuthTypedDataHash computes the EIP-712 typed data hash for the auth +// challenge. This hash is used both for ECDSA recovery and ERC-1271 verification. +func ComputeAuthTypedDataHash( walletAddress string, challengeToken string, sessionKey string, @@ -87,7 +89,7 @@ func RecoverAddressFromEip712Signature( allowances []Allowance, scope string, expiresAt uint64, - sig Signature) (string, error) { +) ([]byte, error) { convertedAllowances := convertAllowances(allowances) typedData := apitypes.TypedData{ @@ -121,18 +123,37 @@ func RecoverAddressFromEip712Signature( }, } - // 1. Hash the typed data (domain separator + message struct hash) - typedDataHash, _, err := apitypes.TypedDataAndHash(typedData) + hash, _, err := apitypes.TypedDataAndHash(typedData) + if err != nil { + return nil, err + } + return hash, nil +} + +func RecoverAddressFromEip712Signature( + walletAddress string, + challengeToken string, + sessionKey string, + application string, + allowances []Allowance, + scope string, + expiresAt uint64, + sig Signature) (string, error) { + + typedDataHash, err := ComputeAuthTypedDataHash( + walletAddress, challengeToken, sessionKey, application, + allowances, scope, expiresAt, + ) if err != nil { return "", err } - // 2. Fix V if needed (Ethereum uses 27/28, go-ethereum expects 0/1) + // Fix V if needed (Ethereum uses 27/28, go-ethereum expects 0/1) if sig[64] >= 27 { sig[64] -= 27 } - // 3. Recover public key + // Recover public key via ECDSA pubKey, err := crypto.SigToPub(typedDataHash, sig) if err != nil { return "", err