diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 7a76396edc..f9f6a85668 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -31,6 +31,8 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/secp256k1" "github.com/ethereum/go-ethereum/node" "github.com/ethereum/go-ethereum/p2p" ) @@ -141,6 +143,7 @@ type ethFetcher interface { refund(opts *bind.TransactOpts, netID int64, secretHash [32]byte) (*types.Transaction, error) swap(ctx context.Context, from *accounts.Account, secretHash [32]byte) (*swap.ETHSwapSwap, error) unlock(ctx context.Context, pw string, acct *accounts.Account) error + signData(addr common.Address, data []byte) ([]byte, error) } // Check that ExchangeWallet satisfies the asset.Wallet interface. @@ -630,10 +633,30 @@ func (*ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, } // SignMessage signs the message with the private key associated with the -// specified funding Coin. A slice of pubkeys required to spend the Coin and a -// signature for each pubkey are returned. -func (*ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, sigs []dex.Bytes, err error) { - return nil, nil, asset.ErrNotImplemented +// specified funding Coin. Only a coin that came from the address this wallet +// is initialized with can be used to sign. +func (e *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, sigs []dex.Bytes, err error) { + ethCoin, err := decodeCoinID(coin.ID()) + if err != nil { + return nil, nil, err + } + + if !bytes.Equal(ethCoin.id.Address.Bytes(), e.acct.Address.Bytes()) { + return nil, nil, fmt.Errorf("SignMessage: coin address: %v != wallet address: %v", + ethCoin.id.Address, e.acct.Address) + } + + sig, err := e.node.signData(e.acct.Address, msg) + if err != nil { + return nil, nil, fmt.Errorf("SignMessage: error signing data: %v", err) + } + + pubKey, err := secp256k1.RecoverPubkey(crypto.Keccak256(msg), sig) + if err != nil { + return nil, nil, fmt.Errorf("SignMessage: error recovering pubkey %v", err) + } + + return []dex.Bytes{pubKey}, []dex.Bytes{sig}, nil } // AuditContract retrieves information about a swap contract on the diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index 593b17989c..26c3c9e4c5 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -7,6 +7,7 @@ package eth import ( "context" + "crypto/ecdsa" "encoding/hex" "errors" "math/big" @@ -23,6 +24,8 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/secp256k1" "github.com/ethereum/go-ethereum/node" "github.com/ethereum/go-ethereum/p2p" ) @@ -33,23 +36,25 @@ var ( ) type testNode struct { - connectErr error - bestHdr *types.Header - bestHdrErr error - bestBlkHash common.Hash - bestBlkHashErr error - blk *types.Block - blkErr error - blkNum uint64 - blkNumErr error - syncProg *ethereum.SyncProgress - syncProgErr error - peerInfo []*p2p.PeerInfo - peersErr error - bal *big.Int - balErr error - initGas uint64 - initGasErr error + connectErr error + bestHdr *types.Header + bestHdrErr error + bestBlkHash common.Hash + bestBlkHashErr error + blk *types.Block + blkErr error + blkNum uint64 + blkNumErr error + syncProg *ethereum.SyncProgress + syncProgErr error + peerInfo []*p2p.PeerInfo + peersErr error + bal *big.Int + balErr error + initGas uint64 + initGasErr error + signDataErr error + privKeyForSigning *ecdsa.PrivateKey } func (n *testNode) connect(ctx context.Context, node *node.Node, addr *common.Address) error { @@ -127,6 +132,17 @@ func (n *testNode) peers(ctx context.Context) ([]*p2p.PeerInfo, error) { func (n *testNode) estimateGas(ctx context.Context, callMsg ethereum.CallMsg) (uint64, error) { return n.initGas, n.initGasErr } +func (n *testNode) signData(addr common.Address, data []byte) ([]byte, error) { + if n.signDataErr != nil { + return nil, n.signDataErr + } + + if n.privKeyForSigning == nil { + return nil, nil + } + + return crypto.Sign(crypto.Keccak256(data), n.privKeyForSigning) +} func TestLoadConfig(t *testing.T) { tests := []struct { @@ -1019,3 +1035,76 @@ func TestMaxOrder(t *testing.T) { } } } + +func TestSignMessage(t *testing.T) { + node := &testNode{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + privKey, _ := crypto.HexToECDSA("9447129055a25c8496fca9e5ee1b9463e47e6043ff0c288d07169e8284860e34") + node.privKeyForSigning = privKey + address := "2b84C791b79Ee37De042AD2ffF1A253c3ce9bc27" + account := accounts.Account{ + Address: common.HexToAddress(address), + } + eth := &ExchangeWallet{ + node: node, + ctx: ctx, + log: tLogger, + acct: &account, + } + + msg := []byte("msg") + + // Error due to coin with unparsable ID + var badCoin badCoin + _, _, err := eth.SignMessage(&badCoin, msg) + if err == nil { + t.Fatalf("expected error for signing message with bad coin") + } + + // Error due to coin from with account than wallet + differentAddress := common.HexToAddress("345853e21b1d475582E71cC269124eD5e2dD3422") + nonce := [8]byte{} + coinDifferentAddress := coin{ + id: dexeth.AmountCoinID{ + Address: differentAddress, + Amount: 100, + Nonce: nonce, + }, + } + _, _, err = eth.SignMessage(&coinDifferentAddress, msg) + if err == nil { + t.Fatalf("expected error for signing message with different address than wallet") + } + + coin := coin{ + id: dexeth.AmountCoinID{ + Address: account.Address, + Amount: 100, + Nonce: nonce, + }, + } + + // SignData error + node.signDataErr = errors.New("") + _, _, err = eth.SignMessage(&coin, msg) + if err == nil { + t.Fatalf("expected error due to error in rpcclient signData") + } + node.signDataErr = nil + + // Test no error + pubKeys, sigs, err := eth.SignMessage(&coin, msg) + if err != nil { + t.Fatalf("unexpected error signing message: %v", err) + } + if len(pubKeys) != 1 { + t.Fatalf("expected 1 pubKey but got %v", len(pubKeys)) + } + if len(sigs) != 1 { + t.Fatalf("expected 1 signature but got %v", len(sigs)) + } + if !secp256k1.VerifySignature(pubKeys[0], crypto.Keccak256(msg), sigs[0][:len(sigs[0])-1]) { + t.Fatalf("failed to verify signature") + } +} diff --git a/client/asset/eth/rpcclient.go b/client/asset/eth/rpcclient.go index c93dedb053..80ec02e24c 100644 --- a/client/asset/eth/rpcclient.go +++ b/client/asset/eth/rpcclient.go @@ -237,6 +237,20 @@ func (c *rpcclient) wallet(acct accounts.Account) (accounts.Wallet, error) { return wallet, nil } +// signData uses the private key of the address to sign a piece of data. +// The address must have been imported and unlocked to use this function. +func (c *rpcclient) signData(addr common.Address, data []byte) ([]byte, error) { + account := accounts.Account{Address: addr} + wallet, err := c.wallet(account) + if err != nil { + return nil, err + } + + // The mime type argument to SignData is not used in the keystore wallet in geth. + // It treats any data like plain text. + return wallet.SignData(account, accounts.MimetypeTextPlain, data) +} + func (c *rpcclient) addSignerToOpts(txOpts *bind.TransactOpts, netID int64) error { wallet, err := c.wallet(accounts.Account{Address: txOpts.From}) if err != nil { diff --git a/client/asset/eth/rpcclient_harness_test.go b/client/asset/eth/rpcclient_harness_test.go index bdc71c0ec9..bb7d2c4a7b 100644 --- a/client/asset/eth/rpcclient_harness_test.go +++ b/client/asset/eth/rpcclient_harness_test.go @@ -25,6 +25,8 @@ package eth import ( "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" "crypto/sha256" "encoding/hex" "errors" @@ -40,16 +42,16 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" - dexeth "decred.org/dcrdex/dex/networks/eth" swap "decred.org/dcrdex/dex/networks/eth" "decred.org/dcrdex/internal/eth/reentryattack" - "decred.org/dcrdex/server/asset/eth" "github.com/davecgh/go-spew/spew" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/secp256k1" ) const ( @@ -59,21 +61,23 @@ const ( ) var ( - gasPrice = big.NewInt(82e9) - homeDir = os.Getenv("HOME") - contractAddrFile = filepath.Join(homeDir, "dextest", "eth", "contract_addr.txt") - testDir = filepath.Join(homeDir, "dextest", "eth", "client_rpc_tests") - alphaNodeDir = filepath.Join(homeDir, "dextest", "eth", "alpha", "node") - ethClient = new(rpcclient) - ctx context.Context - tLogger = dex.StdOutLogger("ETHTEST", dex.LevelTrace) - simnetAddr = common.HexToAddress("2b84C791b79Ee37De042AD2ffF1A253c3ce9bc27") - simnetAcct = &accounts.Account{Address: simnetAddr} - participantAddr = common.HexToAddress("345853e21b1d475582E71cC269124eD5e2dD3422") - participantAcct = &accounts.Account{Address: participantAddr} - contractAddr common.Address - simnetID = int64(42) - newTxOpts = func(ctx context.Context, from *common.Address, value *big.Int) *bind.TransactOpts { + gasPrice = big.NewInt(82e9) + homeDir = os.Getenv("HOME") + contractAddrFile = filepath.Join(homeDir, "dextest", "eth", "contract_addr.txt") + testDir = filepath.Join(homeDir, "dextest", "eth", "client_rpc_tests") + alphaNodeDir = filepath.Join(homeDir, "dextest", "eth", "alpha", "node") + ethClient = new(rpcclient) + ctx context.Context + tLogger = dex.StdOutLogger("ETHTEST", dex.LevelTrace) + simnetPrivKey = "9447129055a25c8496fca9e5ee1b9463e47e6043ff0c288d07169e8284860e34" + simnetAddr = common.HexToAddress("2b84C791b79Ee37De042AD2ffF1A253c3ce9bc27") + simnetAcct = &accounts.Account{Address: simnetAddr} + participantPrivKey = "0695b9347a4dc096ae5c6f1935380ceba550c70b112f1323c211bade4d11651a" + participantAddr = common.HexToAddress("345853e21b1d475582E71cC269124eD5e2dD3422") + participantAcct = &accounts.Account{Address: participantAddr} + contractAddr common.Address + simnetID = int64(42) + newTxOpts = func(ctx context.Context, from *common.Address, value *big.Int) *bind.TransactOpts { return &bind.TransactOpts{ GasPrice: gasPrice, GasLimit: 1e6, @@ -237,8 +241,7 @@ func TestImportAccounts(t *testing.T) { fmt.Println("Skipping TestImportAccounts because accounts are already imported.") t.Skip() } - // The address of this will be 2b84C791b79Ee37De042AD2ffF1A253c3ce9bc27. - privB, err := hex.DecodeString("9447129055a25c8496fca9e5ee1b9463e47e6043ff0c288d07169e8284860e34") + privB, err := hex.DecodeString(simnetPrivKey) if err != nil { t.Fatal(err) } @@ -247,8 +250,7 @@ func TestImportAccounts(t *testing.T) { t.Fatal(err) } spew.Dump(acct) - // The address of this will be 345853e21b1d475582E71cC269124eD5e2dD3422. - privB, err = hex.DecodeString("0695b9347a4dc096ae5c6f1935380ceba550c70b112f1323c211bade4d11651a") + privB, err = hex.DecodeString(participantPrivKey) if err != nil { t.Fatal(err) } @@ -938,3 +940,28 @@ func TestGetCodeAt(t *testing.T) { t.Fatal("Contract on chain does not match one in code") } } + +func TestSignMessage(t *testing.T) { + msg := []byte("test message") + err := ethClient.unlock(ctx, pw, simnetAcct) + if err != nil { + t.Fatalf("error unlocking account: %v", err) + } + signature, err := ethClient.signData(simnetAddr, msg) + if err != nil { + t.Fatalf("error signing text: %v", err) + } + pubKey, err := secp256k1.RecoverPubkey(crypto.Keccak256(msg), signature) + x, y := elliptic.Unmarshal(secp256k1.S256(), pubKey) + recoveredAddress := crypto.PubkeyToAddress(ecdsa.PublicKey{ + Curve: secp256k1.S256(), + X: x, + Y: y, + }) + if !bytes.Equal(recoveredAddress.Bytes(), simnetAcct.Address.Bytes()) { + t.Fatalf("recovered address: %v != simnet account address: %v", recoveredAddress, simnetAcct.Address) + } + if !secp256k1.VerifySignature(pubKey, crypto.Keccak256(msg), signature[:len(signature)-1]) { + t.Fatalf("failed to verify signature") + } +}