From 579bac9b26b4799bd13091478f0c3609b395e9d1 Mon Sep 17 00:00:00 2001 From: MalteHerrmann <42640438+MalteHerrmann@users.noreply.github.com> Date: Mon, 5 Aug 2024 13:55:06 +0200 Subject: [PATCH] imp(types): Add required wallet types for integration (#19) --- .clconfig.json | 3 +- CHANGELOG.md | 1 + go.mod | 9 +- go.sum | 1 - wallets/accounts/accounts.go | 80 +++++ wallets/ledger/ledger.go | 190 ++++++++++++ wallets/ledger/ledger_suite_test.go | 157 ++++++++++ wallets/ledger/ledger_test.go | 326 +++++++++++++++++++++ wallets/ledger/mocks/wallet.go | 198 +++++++++++++ wallets/ledger/wallet_test.go | 47 +++ wallets/usbwallet/hub.go | 219 ++++++++++++++ wallets/usbwallet/ledger.go | 433 ++++++++++++++++++++++++++++ wallets/usbwallet/wallet.go | 413 ++++++++++++++++++++++++++ 13 files changed, 2071 insertions(+), 6 deletions(-) create mode 100644 wallets/accounts/accounts.go create mode 100644 wallets/ledger/ledger.go create mode 100644 wallets/ledger/ledger_suite_test.go create mode 100644 wallets/ledger/ledger_test.go create mode 100644 wallets/ledger/mocks/wallet.go create mode 100644 wallets/ledger/wallet_test.go create mode 100644 wallets/usbwallet/hub.go create mode 100644 wallets/usbwallet/ledger.go create mode 100644 wallets/usbwallet/wallet.go diff --git a/.clconfig.json b/.clconfig.json index 2e894b9..6b3dbb9 100644 --- a/.clconfig.json +++ b/.clconfig.json @@ -19,7 +19,8 @@ "proto", "rpc", "staking-precompile", - "tests" + "tests", + "types" ], "change_types": { "API Breaking": "api\\s*breaking", diff --git a/CHANGELOG.md b/CHANGELOG.md index d77ef4a..ad9c428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This changelog was created using the `clu` binary ### Improvements +- (types) [#19](https://github.com/evmos/os/pull/19) Add required wallet types for integration. - (all) [#15](https://github.com/evmos/os/pull/15) Add general types and utils. - (proto) [#14](https://github.com/evmos/os/pull/14) Add Protobufs and adjust scripts. - (eip-712) [#13](https://github.com/evmos/os/pull/13) Add EIP-712 package. diff --git a/go.mod b/go.mod index 5ba46dc..80abbf6 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,15 @@ require ( cosmossdk.io/simapp v0.0.0-20230608160436-666c345ad23d github.com/cometbft/cometbft v0.37.9 github.com/cosmos/cosmos-sdk v0.47.12 + github.com/cosmos/gogoproto v1.4.10 + github.com/cosmos/ibc-go/v7 v7.6.0 github.com/ethereum/go-ethereum v1.11.5 github.com/evmos/evmos/v19 v19.0.0-20240731212153-b36241652b57 github.com/stretchr/testify v1.9.0 github.com/tidwall/gjson v1.17.3 github.com/tidwall/sjson v1.2.5 + github.com/zondax/hid v0.9.2 + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 golang.org/x/text v0.16.0 ) @@ -56,9 +60,7 @@ require ( github.com/cosmos/cosmos-proto v1.0.0-beta.5 // indirect github.com/cosmos/go-bip39 v1.0.0 // indirect github.com/cosmos/gogogateway v1.2.0 // indirect - github.com/cosmos/gogoproto v1.4.10 // indirect github.com/cosmos/iavl v0.21.0-alpha.1.0.20230904092046-df3db2d96583 // indirect - github.com/cosmos/ibc-go/v7 v7.6.0 // indirect github.com/cosmos/ics23/go v0.10.0 // indirect github.com/cosmos/ledger-cosmos-go v0.12.4 // indirect github.com/cosmos/rosetta-sdk-go v0.10.0 // indirect @@ -171,6 +173,7 @@ require ( github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.18.2 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect github.com/tendermint/go-amino v0.16.0 // indirect @@ -183,7 +186,6 @@ require ( github.com/tklauser/numcpus v0.6.0 // indirect github.com/ulikunitz/xz v0.5.11 // indirect github.com/zbiljic/go-filelock v0.0.0-20170914061330-1dbf7103ab7d // indirect - github.com/zondax/hid v0.9.2 // indirect github.com/zondax/ledger-go v0.14.3 // indirect go.etcd.io/bbolt v1.4.0-alpha.0.0.20240404170359-43604f3112c5 // indirect go.opencensus.io v0.24.0 // indirect @@ -194,7 +196,6 @@ require ( go.opentelemetry.io/otel/trace v1.24.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.25.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.27.0 // indirect golang.org/x/oauth2 v0.20.0 // indirect golang.org/x/sync v0.7.0 // indirect diff --git a/go.sum b/go.sum index 6f1d904..a4a9fc8 100644 --- a/go.sum +++ b/go.sum @@ -435,7 +435,6 @@ github.com/evmos/cosmos-sdk v0.47.12-evmos.2 h1:NODyhYKCqu8JNLeR6b6ff0+TS3KYdcBi github.com/evmos/cosmos-sdk v0.47.12-evmos.2/go.mod h1:ADjORYzUQqQv/FxDi0H0K5gW/rAk1CiDR3ZKsExfJV0= github.com/evmos/evmos/v19 v19.0.0-20240731212153-b36241652b57 h1:C+JOScyVYgoASWrdIBmCYPBRbOpQgvGMCc8URWdq+xQ= github.com/evmos/evmos/v19 v19.0.0-20240731212153-b36241652b57/go.mod h1:HEPvi70nAyQyzYaDqtB2x33lwQ80wKVIyTRNnufjTg8= -github.com/evmos/evmos/v19 v19.0.0/go.mod h1:0BtH6AsIRvAaNmSIfIYGH3AaXgWtq8ZBTdmYV08VZjE= github.com/evmos/go-ethereum v1.10.26-evmos-rc4 h1:vwDVMScuB2KSu8ze5oWUuxm6v3bMUp6dL3PWvJNJY+I= github.com/evmos/go-ethereum v1.10.26-evmos-rc4/go.mod h1:/6CsT5Ceen2WPLI/oCA3xMcZ5sWMF/D46SjM/ayY0Oo= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= diff --git a/wallets/accounts/accounts.go b/wallets/accounts/accounts.go new file mode 100644 index 0000000..4fac98d --- /dev/null +++ b/wallets/accounts/accounts.go @@ -0,0 +1,80 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) +package accounts + +import ( + "crypto/ecdsa" + + gethaccounts "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +// Account represents an Ethereum account located at a specific location defined +// by the optional URL field. +type Account struct { + Address common.Address `json:"address"` // Ethereum account address derived from the key + PublicKey *ecdsa.PublicKey `json:"publicKey"` // Public key corresponding to the account address +} + +// Wallet represents a software or hardware wallet that might contain one or more +// accounts (derived from the same seed). +type Wallet interface { + // URL retrieves the canonical path under which this wallet is reachable. It is + // used by upper layers to define a sorting order over all wallets from multiple + // backends. + URL() gethaccounts.URL + + // Status returns a textual status to aid the user in the current state of the + // wallet. It also returns an error indicating any failure the wallet might have + // encountered. + Status() (string, error) + + // Open initializes access to a wallet instance. It is not meant to unlock or + // decrypt account keys, rather simply to establish a connection to hardware + // wallets and/or to access derivation seeds. + // + // The passphrase parameter may or may not be used by the implementation of a + // particular wallet instance. The reason there is no password-less open method + // is to strive towards a uniform wallet handling, oblivious to the different + // backend providers. + // + // Please note, if you open a wallet, you must close it to release any allocated + // resources (especially important when working with hardware wallets). + Open(passphrase string) error + + // Close releases any resources held by an open wallet instance. + Close() error + + // Accounts retrieves the list of signing accounts the wallet is currently aware + // of. For hierarchical deterministic wallets, the list will not be exhaustive, + // rather only contain the accounts explicitly pinned during account derivation. + Accounts() []Account + + // Contains returns whether an account is part of this particular wallet or not. + Contains(account Account) bool + + // Derive attempts to explicitly derive a hierarchical deterministic account at + // the specified derivation path. If requested, the derived account will be added + // to the wallet's tracked account list. + Derive(path gethaccounts.DerivationPath, pin bool) (Account, error) + + // SignTypedData signs a TypedData object using EIP-712 encoding + SignTypedData(account Account, typedData apitypes.TypedData) ([]byte, error) +} + +// Backend is a "wallet provider" that may contain a batch of accounts they can +// sign transactions with and upon request, do so. +type Backend interface { + // Wallets retrieves the list of wallets the backend is currently aware of. + // + // The returned wallets are not opened by default. For software HD wallets this + // means that no base seeds are decrypted, and for hardware wallets that no actual + // connection is established. + // + // The resulting wallet list will be sorted alphabetically based on its internal + // URL assigned by the backend. Since wallets (especially hardware) may come and + // go, the same wallet might appear at a different positions in the list during + // subsequent retrievals. + Wallets() []Wallet +} diff --git a/wallets/ledger/ledger.go b/wallets/ledger/ledger.go new file mode 100644 index 0000000..f84dacf --- /dev/null +++ b/wallets/ledger/ledger.go @@ -0,0 +1,190 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package ledger + +import ( + "encoding/hex" + "errors" + "fmt" + "strings" + + sdkledger "github.com/cosmos/cosmos-sdk/crypto/ledger" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + + "github.com/evmos/os/ethereum/eip712" + "github.com/evmos/os/wallets/accounts" + "github.com/evmos/os/wallets/usbwallet" +) + +// Secp256k1DerivationFn defines the derivation function used on the Cosmos SDK Keyring. +type Secp256k1DerivationFn func() (sdkledger.SECP256K1, error) + +func EvmosLedgerDerivation() Secp256k1DerivationFn { + evmosSECP256K1 := new(EvmosSECP256K1) + + return func() (sdkledger.SECP256K1, error) { + return evmosSECP256K1.connectToLedgerApp() + } +} + +var _ sdkledger.SECP256K1 = &EvmosSECP256K1{} + +// EvmosSECP256K1 defines a wrapper of the Ethereum App to +// for compatibility with Cosmos SDK chains. +type EvmosSECP256K1 struct { + *usbwallet.Hub + PrimaryWallet accounts.Wallet +} + +// Close closes the associated primary wallet. Any requests on +// the object after a successful Close() should not work +func (e EvmosSECP256K1) Close() error { + if e.PrimaryWallet == nil { + return errors.New("could not close Ledger: no wallet found") + } + + return e.PrimaryWallet.Close() +} + +// GetPublicKeySECP256K1 returns the public key associated with the address derived from +// the provided hdPath using the primary wallet +func (e EvmosSECP256K1) GetPublicKeySECP256K1(hdPath []uint32) ([]byte, error) { + if e.PrimaryWallet == nil { + return nil, errors.New("could not get Ledger public key: no wallet found") + } + + // Re-open wallet in case it was closed. Do not handle the error here (see SignSECP256K1) + _ = e.PrimaryWallet.Open("") + + account, err := e.PrimaryWallet.Derive(hdPath, true) + if err != nil { + return nil, errors.New("unable to derive public key, please retry") + } + + pubkeyBz := crypto.FromECDSAPub(account.PublicKey) + + return pubkeyBz, nil +} + +// GetAddressPubKeySECP256K1 takes in the HD path as well as a "Human Readable Prefix" (HRP, e.g. "evmos") +// to return the public key bytes in secp256k1 format as well as the account address. +func (e EvmosSECP256K1) GetAddressPubKeySECP256K1(hdPath []uint32, hrp string) ([]byte, string, error) { + if e.PrimaryWallet == nil { + return nil, "", errors.New("could not get Ledger address: no wallet found") + } + + // Re-open wallet in case it was closed. Ignore the error here (see SignSECP256K1) + _ = e.PrimaryWallet.Open("") + + account, err := e.PrimaryWallet.Derive(hdPath, true) + if err != nil { + return nil, "", errors.New("unable to derive Ledger address, please open the Ethereum app and retry") + } + + address, err := sdk.Bech32ifyAddressBytes(hrp, account.Address.Bytes()) + if err != nil { + return nil, "", err + } + + pubkeyBz := crypto.FromECDSAPub(account.PublicKey) + + return pubkeyBz, address, nil +} + +// SignSECP256K1 returns the signature bytes generated from signing a transaction +// using the EIP712 signature. +func (e EvmosSECP256K1) SignSECP256K1(hdPath []uint32, signDocBytes []byte) ([]byte, error) { + fmt.Printf("Generating payload, please check your Ledger...\n") + + if e.PrimaryWallet == nil { + return nil, errors.New("unable to sign with Ledger: no wallet found") + } + + // Re-open wallet in case it was closed. Since an error occurs if the wallet is already open, + // ignore the error. Any errors due to the wallet being closed will surface later on. + _ = e.PrimaryWallet.Open("") + + // Derive requested account + account, err := e.PrimaryWallet.Derive(hdPath, true) + if err != nil { + return nil, errors.New("unable to derive Ledger address, please open the Ethereum app and retry") + } + + typedData, err := eip712.GetEIP712TypedDataForMsg(signDocBytes) + if err != nil { + return nil, err + } + + // Display EIP-712 message hash for user to verify + if err := e.displayEIP712Hash(typedData); err != nil { + return nil, fmt.Errorf("unable to generate EIP-712 hash for object: %w", err) + } + + // Sign with EIP712 signature + signature, err := e.PrimaryWallet.SignTypedData(account, typedData) + if err != nil { + return nil, fmt.Errorf("error generating signature, please retry: %w", err) + } + + return signature, nil +} + +// displayEIP712Hash is a helper function to display the EIP-712 hashes. +// This allows users to verify the hashed message they are signing via Ledger. +func (e EvmosSECP256K1) displayEIP712Hash(typedData apitypes.TypedData) error { + domainSeparator, err := typedData.HashStruct("EIP712Domain", typedData.Domain.Map()) + if err != nil { + return err + } + typedDataHash, err := typedData.HashStruct(typedData.PrimaryType, typedData.Message) + if err != nil { + return err + } + + fmt.Printf("Signing the following payload with EIP-712:\n") + fmt.Printf("- Domain: %s\n", bytesToHexString(domainSeparator)) + fmt.Printf("- Message: %s\n", bytesToHexString(typedDataHash)) + + return nil +} + +func (e *EvmosSECP256K1) connectToLedgerApp() (sdkledger.SECP256K1, error) { + // Instantiate new Ledger object + ledger, err := usbwallet.NewLedgerHub() + if err != nil { + return nil, err + } + + if ledger == nil { + return nil, errors.New("no hardware wallets detected") + } + + e.Hub = ledger + wallets := e.Wallets() + + // No wallets detected; throw an error + if len(wallets) == 0 { + return nil, errors.New("no hardware wallets detected") + } + + // Default to use first wallet found + primaryWallet := wallets[0] + + // Open wallet for the first time. Unlike with other cases, we want to handle the error here. + if err := primaryWallet.Open(""); err != nil { + return nil, err + } + + e.PrimaryWallet = primaryWallet + + return e, nil +} + +// bytesToHexString is a helper function to convert a slice of bytes to a +// string in hex-format. +func bytesToHexString(bytes []byte) string { + return "0x" + strings.ToUpper(hex.EncodeToString(bytes)) +} diff --git a/wallets/ledger/ledger_suite_test.go b/wallets/ledger/ledger_suite_test.go new file mode 100644 index 0000000..6431be7 --- /dev/null +++ b/wallets/ledger/ledger_suite_test.go @@ -0,0 +1,157 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package ledger_test + +import ( + "encoding/hex" + "fmt" + "regexp" + "testing" + + "cosmossdk.io/math" + "github.com/stretchr/testify/suite" + + "github.com/cosmos/cosmos-sdk/codec" + codecTypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" + cryptoTypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + txTypes "github.com/cosmos/cosmos-sdk/types/tx" + "github.com/cosmos/cosmos-sdk/types/tx/signing" + auxTx "github.com/cosmos/cosmos-sdk/x/auth/tx" + bankTypes "github.com/cosmos/cosmos-sdk/x/bank/types" + + "github.com/evmos/os/testutil" + "github.com/evmos/os/wallets/ledger" + "github.com/evmos/os/wallets/ledger/mocks" + "github.com/evmos/os/wallets/usbwallet" +) + +type LedgerTestSuite struct { + suite.Suite + txAmino []byte + txProtobuf []byte + ledger ledger.EvmosSECP256K1 + mockWallet *mocks.Wallet + hrp string +} + +func TestLedgerTestSuite(t *testing.T) { + suite.Run(t, new(LedgerTestSuite)) +} + +func (suite *LedgerTestSuite) SetupTest() { + suite.hrp = "evmos" + + suite.txAmino = suite.getMockTxAmino() + suite.txProtobuf = suite.getMockTxProtobuf() + + hub, err := usbwallet.NewLedgerHub() + suite.Require().NoError(err) + + mockWallet := new(mocks.Wallet) + suite.mockWallet = mockWallet + suite.ledger = ledger.EvmosSECP256K1{Hub: hub, PrimaryWallet: mockWallet} +} + +func (suite *LedgerTestSuite) newPubKey(pk string) (res cryptoTypes.PubKey) { + pkBytes, err := hex.DecodeString(pk) + suite.Require().NoError(err) + + pubkey := &ed25519.PubKey{Key: pkBytes} + + return pubkey +} + +func (suite *LedgerTestSuite) getMockTxAmino() []byte { + whitespaceRegex := regexp.MustCompile(`\s+`) + tmp := whitespaceRegex.ReplaceAllString(fmt.Sprintf( + `{ + "account_number": "0", + "chain_id":"%s", + "fee":{ + "amount":[{"amount":"150","denom":"atom"}], + "gas":"20000" + }, + "memo":"memo", + "msgs":[{ + "type":"cosmos-sdk/MsgSend", + "value":{ + "amount":[{"amount":"150","denom":"atom"}], + "from_address":"cosmos1r5sckdd808qvg7p8d0auaw896zcluqfd7djffp", + "to_address":"cosmos10t8ca2w09ykd6ph0agdz5stvgau47whhaggl9a" + } + }], + "sequence":"6" + }`, testutil.ExampleChainID), + "", + ) + + return []byte(tmp) +} + +func (suite *LedgerTestSuite) getMockTxProtobuf() []byte { + marshaler := codec.NewProtoCodec(codecTypes.NewInterfaceRegistry()) + + memo := "memo" + msg := bankTypes.NewMsgSend( + sdk.MustAccAddressFromBech32("cosmos1r5sckdd808qvg7p8d0auaw896zcluqfd7djffp"), + sdk.MustAccAddressFromBech32("cosmos10t8ca2w09ykd6ph0agdz5stvgau47whhaggl9a"), + []sdk.Coin{ + { + Denom: "atom", + Amount: math.NewIntFromUint64(150), + }, + }, + ) + + msgAsAny, err := codecTypes.NewAnyWithValue(msg) + suite.Require().NoError(err) + + body := &txTypes.TxBody{ + Messages: []*codecTypes.Any{ + msgAsAny, + }, + Memo: memo, + } + + pubKey := suite.newPubKey("0B485CFC0EECC619440448436F8FC9DF40566F2369E72400281454CB552AFB50") + + pubKeyAsAny, err := codecTypes.NewAnyWithValue(pubKey) + suite.Require().NoError(err) + + signingMode := txTypes.ModeInfo_Single_{ + Single: &txTypes.ModeInfo_Single{ + Mode: signing.SignMode_SIGN_MODE_DIRECT, + }, + } + + signerInfo := &txTypes.SignerInfo{ + PublicKey: pubKeyAsAny, + ModeInfo: &txTypes.ModeInfo{ + Sum: &signingMode, + }, + Sequence: 6, + } + + fee := txTypes.Fee{Amount: sdk.NewCoins(sdk.NewInt64Coin("atom", 150)), GasLimit: 20000} + + authInfo := &txTypes.AuthInfo{ + SignerInfos: []*txTypes.SignerInfo{signerInfo}, + Fee: &fee, + } + + bodyBytes := marshaler.MustMarshal(body) + authInfoBytes := marshaler.MustMarshal(authInfo) + + signBytes, err := auxTx.DirectSignBytes( + bodyBytes, + authInfoBytes, + testutil.ExampleChainID, + 0, + ) + suite.Require().NoError(err) + + return signBytes +} diff --git a/wallets/ledger/ledger_test.go b/wallets/ledger/ledger_test.go new file mode 100644 index 0000000..957214a --- /dev/null +++ b/wallets/ledger/ledger_test.go @@ -0,0 +1,326 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package ledger_test + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + gethaccounts "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/evmos/evmos/v19/app" + "github.com/evmos/evmos/v19/encoding" + "github.com/evmos/os/ethereum/eip712" + "github.com/evmos/os/wallets/accounts" + "github.com/evmos/os/wallets/ledger" +) + +// Test Mnemonic: +// glow spread dentist swamp people siren hint muscle first sausage castle metal cycle abandon accident logic again around mix dial knee organ episode usual + +// Load encoding config for sign doc encoding/decoding +func init() { + config := encoding.MakeConfig(app.ModuleBasics) + eip712.SetEncodingConfig(config) + sdk.GetConfig().SetBech32PrefixForAccount("cosmos", "") +} + +func (suite *LedgerTestSuite) TestEvmosLedgerDerivation() { + testCases := []struct { + name string + mockFunc func() + expPass bool + }{ + { + "fail - no hardware wallets detected", + func() {}, + false, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() // reset + derivationFunc := ledger.EvmosLedgerDerivation() + _, err := derivationFunc() + if tc.expPass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + } + }) + } +} + +func (suite *LedgerTestSuite) TestClose() { + testCases := []struct { + name string + mockFunc func() + expPass bool + }{ + { + "fail - can't find Ledger device", + func() { + suite.ledger.PrimaryWallet = nil + }, + false, + }, + { + "pass - wallet closed successfully", + func() { + RegisterClose(suite.mockWallet) + }, + true, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() // reset + tc.mockFunc() + err := suite.ledger.Close() + if tc.expPass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + } + }) + } +} + +func (suite *LedgerTestSuite) TestSignatures() { + privKey, err := crypto.GenerateKey() + suite.Require().NoError(err) + addr := crypto.PubkeyToAddress(privKey.PublicKey) + account := accounts.Account{ + Address: addr, + PublicKey: &privKey.PublicKey, + } + + testCases := []struct { + name string + tx []byte + mockFunc func() + expPass bool + }{ + { + "fail - can't find Ledger device", + suite.txAmino, + func() { + suite.ledger.PrimaryWallet = nil + }, + false, + }, + { + "fail - unable to derive Ledger address", + suite.txAmino, + func() { + RegisterOpen(suite.mockWallet) + RegisterDeriveError(suite.mockWallet) + }, + false, + }, + { + "fail - error generating signature", + suite.txAmino, + func() { + RegisterOpen(suite.mockWallet) + RegisterDerive(suite.mockWallet, addr, &privKey.PublicKey) + RegisterSignTypedDataError(suite.mockWallet, account, suite.txAmino) + }, + false, + }, + { + "pass - test ledger amino signature", + suite.txAmino, + func() { + RegisterOpen(suite.mockWallet) + RegisterDerive(suite.mockWallet, addr, &privKey.PublicKey) + RegisterSignTypedData(suite.mockWallet, account, suite.txAmino) + }, + true, + }, + { + "pass - test ledger protobuf signature", + suite.txProtobuf, + func() { + RegisterOpen(suite.mockWallet) + RegisterDerive(suite.mockWallet, addr, &privKey.PublicKey) + RegisterSignTypedData(suite.mockWallet, account, suite.txProtobuf) + }, + true, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() // reset + tc.mockFunc() + _, err := suite.ledger.SignSECP256K1(gethaccounts.DefaultBaseDerivationPath, tc.tx) + if tc.expPass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + } + }) + } +} + +func (suite *LedgerTestSuite) TestSignatureEquivalence() { + privKey, err := crypto.GenerateKey() + suite.Require().NoError(err) + addr := crypto.PubkeyToAddress(privKey.PublicKey) + account := accounts.Account{ + Address: addr, + PublicKey: &privKey.PublicKey, + } + + testCases := []struct { + name string + txProtobuf []byte + txAmino []byte + mockFunc func() + expPass bool + }{ + { + "pass - signatures are equivalent", + suite.txProtobuf, + suite.txAmino, + func() { + RegisterOpen(suite.mockWallet) + RegisterDerive(suite.mockWallet, addr, &privKey.PublicKey) + RegisterSignTypedData(suite.mockWallet, account, suite.txProtobuf) + RegisterSignTypedData(suite.mockWallet, account, suite.txAmino) + }, + true, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() // reset + tc.mockFunc() + protoSignature, err := suite.ledger.SignSECP256K1(gethaccounts.DefaultBaseDerivationPath, tc.txProtobuf) + suite.Require().NoError(err) + aminoSignature, err := suite.ledger.SignSECP256K1(gethaccounts.DefaultBaseDerivationPath, tc.txAmino) + suite.Require().NoError(err) + if tc.expPass { + suite.Require().Equal(protoSignature, aminoSignature) + } else { + suite.Require().NotEqual(protoSignature, aminoSignature) + } + }) + } +} + +func (suite *LedgerTestSuite) TestGetAddressPubKeySECP256K1() { + privKey, err := crypto.GenerateKey() + suite.Require().NoError(err) + + addr := crypto.PubkeyToAddress(privKey.PublicKey) + expAddr, err := sdk.Bech32ifyAddressBytes("evmos", common.HexToAddress(addr.String()).Bytes()) + suite.Require().NoError(err) + + testCases := []struct { + name string + expPass bool + mockFunc func() + }{ + { + "fail - can't find Ledger device", + false, + func() { + suite.ledger.PrimaryWallet = nil + }, + }, + { + "fail - unable to derive Ledger address", + false, + func() { + RegisterOpen(suite.mockWallet) + RegisterDeriveError(suite.mockWallet) + }, + }, + { + "fail - bech32 prefix empty", + false, + func() { + suite.hrp = "" + RegisterOpen(suite.mockWallet) + RegisterDerive(suite.mockWallet, addr, &privKey.PublicKey) + }, + }, + { + "pass - get ledger address", + true, + func() { + RegisterOpen(suite.mockWallet) + RegisterDerive(suite.mockWallet, addr, &privKey.PublicKey) + }, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() // reset + tc.mockFunc() + _, addr, err := suite.ledger.GetAddressPubKeySECP256K1(gethaccounts.DefaultBaseDerivationPath, suite.hrp) + if tc.expPass { + suite.Require().NoError(err, "Could not get wallet address") + suite.Require().Equal(expAddr, addr) + } else { + suite.Require().Error(err) + } + }) + } +} + +func (suite *LedgerTestSuite) TestGetPublicKeySECP256K1() { + privKey, err := crypto.GenerateKey() + suite.Require().NoError(err) + addr := crypto.PubkeyToAddress(privKey.PublicKey) + expPubkeyBz := crypto.FromECDSAPub(&privKey.PublicKey) + testCases := []struct { + name string + expPass bool + mockFunc func() + }{ + { + "fail - can't find Ledger device", + false, + func() { + suite.ledger.PrimaryWallet = nil + }, + }, + { + "fail - unable to derive Ledger address", + false, + func() { + RegisterOpen(suite.mockWallet) + RegisterDeriveError(suite.mockWallet) + }, + }, + { + "pass - get ledger public key", + true, + func() { + RegisterOpen(suite.mockWallet) + RegisterDerive(suite.mockWallet, addr, &privKey.PublicKey) + }, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() // reset + tc.mockFunc() + pubKeyBz, err := suite.ledger.GetPublicKeySECP256K1(gethaccounts.DefaultBaseDerivationPath) + if tc.expPass { + suite.Require().NoError(err, "Could not get wallet address") + suite.Require().Equal(expPubkeyBz, pubKeyBz) + } else { + suite.Require().Error(err) + } + }) + } +} diff --git a/wallets/ledger/mocks/wallet.go b/wallets/ledger/mocks/wallet.go new file mode 100644 index 0000000..9454cf3 --- /dev/null +++ b/wallets/ledger/mocks/wallet.go @@ -0,0 +1,198 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) +// Code generated by mockery v2.14.1. DO NOT EDIT. + +package mocks + +import ( + apitypes "github.com/ethereum/go-ethereum/signer/core/apitypes" + accounts "github.com/evmos/os/wallets/accounts" + + big "math/big" + + go_ethereumaccounts "github.com/ethereum/go-ethereum/accounts" + + mock "github.com/stretchr/testify/mock" + + types "github.com/ethereum/go-ethereum/core/types" +) + +// Wallet is an autogenerated mock type for the Wallet type +type Wallet struct { + mock.Mock +} + +// Accounts provides a mock function with given fields: +func (_m *Wallet) Accounts() []accounts.Account { + ret := _m.Called() + + var r0 []accounts.Account + if rf, ok := ret.Get(0).(func() []accounts.Account); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]accounts.Account) + } + } + + return r0 +} + +// Close provides a mock function with given fields: +func (_m *Wallet) Close() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Contains provides a mock function with given fields: account +func (_m *Wallet) Contains(account accounts.Account) bool { + ret := _m.Called(account) + + var r0 bool + if rf, ok := ret.Get(0).(func(accounts.Account) bool); ok { + r0 = rf(account) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Derive provides a mock function with given fields: path, pin +func (_m *Wallet) Derive(path go_ethereumaccounts.DerivationPath, pin bool) (accounts.Account, error) { + ret := _m.Called(path, pin) + + var r0 accounts.Account + if rf, ok := ret.Get(0).(func(go_ethereumaccounts.DerivationPath, bool) accounts.Account); ok { + r0 = rf(path, pin) + } else { + r0 = ret.Get(0).(accounts.Account) + } + + var r1 error + if rf, ok := ret.Get(1).(func(go_ethereumaccounts.DerivationPath, bool) error); ok { + r1 = rf(path, pin) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Open provides a mock function with given fields: passphrase +func (_m *Wallet) Open(passphrase string) error { + ret := _m.Called(passphrase) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(passphrase) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SignTx provides a mock function with given fields: account, tx, chainID +func (_m *Wallet) SignTx(account accounts.Account, tx *types.Transaction, chainID *big.Int) ([]byte, error) { + ret := _m.Called(account, tx, chainID) + + var r0 []byte + if rf, ok := ret.Get(0).(func(accounts.Account, *types.Transaction, *big.Int) []byte); ok { + r0 = rf(account, tx, chainID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(accounts.Account, *types.Transaction, *big.Int) error); ok { + r1 = rf(account, tx, chainID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SignTypedData provides a mock function with given fields: account, typedData +func (_m *Wallet) SignTypedData(account accounts.Account, typedData apitypes.TypedData) ([]byte, error) { + ret := _m.Called(account, typedData) + + var r0 []byte + if rf, ok := ret.Get(0).(func(accounts.Account, apitypes.TypedData) []byte); ok { + r0 = rf(account, typedData) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(accounts.Account, apitypes.TypedData) error); ok { + r1 = rf(account, typedData) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Status provides a mock function with given fields: +func (_m *Wallet) Status() (string, error) { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// URL provides a mock function with given fields: +func (_m *Wallet) URL() go_ethereumaccounts.URL { + ret := _m.Called() + + var r0 go_ethereumaccounts.URL + if rf, ok := ret.Get(0).(func() go_ethereumaccounts.URL); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(go_ethereumaccounts.URL) + } + + return r0 +} + +type mockConstructorTestingTNewWallet interface { + mock.TestingT + Cleanup(func()) +} + +// NewWallet creates a new instance of Wallet. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewWallet(t mockConstructorTestingTNewWallet) *Wallet { + mock := &Wallet{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/wallets/ledger/wallet_test.go b/wallets/ledger/wallet_test.go new file mode 100644 index 0000000..d95acac --- /dev/null +++ b/wallets/ledger/wallet_test.go @@ -0,0 +1,47 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package ledger_test + +import ( + "crypto/ecdsa" + "errors" + + gethaccounts "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/evmos/os/ethereum/eip712" + "github.com/evmos/os/wallets/accounts" + "github.com/evmos/os/wallets/ledger/mocks" +) + +func RegisterDerive(mockWallet *mocks.Wallet, addr common.Address, publicKey *ecdsa.PublicKey) { + mockWallet.On("Derive", gethaccounts.DefaultBaseDerivationPath, true). + Return(accounts.Account{Address: addr, PublicKey: publicKey}, nil) +} + +func RegisterDeriveError(mockWallet *mocks.Wallet) { + mockWallet.On("Derive", gethaccounts.DefaultBaseDerivationPath, true). + Return(accounts.Account{}, errors.New("unable to derive Ledger address, please open the Ethereum app and retry")) +} + +func RegisterOpen(mockWallet *mocks.Wallet) { + mockWallet.On("Open", ""). + Return(nil) +} + +func RegisterClose(mockWallet *mocks.Wallet) { + mockWallet.On("Close"). + Return(nil) +} + +func RegisterSignTypedData(mockWallet *mocks.Wallet, account accounts.Account, typedDataBz []byte) { + typedData, _ := eip712.GetEIP712TypedDataForMsg(typedDataBz) + mockWallet.On("SignTypedData", account, typedData). + Return([]byte{}, nil) +} + +func RegisterSignTypedDataError(mockWallet *mocks.Wallet, account accounts.Account, typedDataBz []byte) { + typedData, _ := eip712.GetEIP712TypedDataForMsg(typedDataBz) + mockWallet.On("SignTypedData", account, typedData). + Return([]byte{}, errors.New("error generating signature, please retry")) +} diff --git a/wallets/usbwallet/hub.go b/wallets/usbwallet/hub.go new file mode 100644 index 0000000..1aabe20 --- /dev/null +++ b/wallets/usbwallet/hub.go @@ -0,0 +1,219 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package usbwallet + +import ( + "errors" + "sync" + "sync/atomic" + "time" + + // runtime is listed as a potential source for non-determinism, but we use it only for checking the OS + // #nosec + "runtime" + + gethaccounts "github.com/ethereum/go-ethereum/accounts" + "github.com/evmos/os/wallets/accounts" + usb "github.com/zondax/hid" +) + +const ( + // LedgerScheme is the protocol scheme prefixing account and wallet URLs. + LedgerScheme = "ledger" + + // onLinux is a boolean value to check if the operating system is Linux-based. + onLinux = runtime.GOOS == "linux" + + // refreshThrottling is the minimum time between wallet refreshes to avoid USB + // trashing. + refreshThrottling = 500 * time.Millisecond +) + +var _ accounts.Backend = &Hub{} + +// Hub is an accounts.Backend that can find and handle generic USB hardware wallets. +type Hub struct { + scheme string // Protocol scheme prefixing account and wallet URLs. + vendorID uint16 // USB vendor identifier used for device discovery + productIDs []uint16 // USB product identifiers used for device discovery + usageID uint16 // USB usage page identifier used for macOS device discovery + endpointID int // USB endpoint identifier used for non-macOS device discovery + makeDriver func() driver // Factory method to construct a vendor specific driver + + refreshed time.Time // Time instance when the list of wallets was last refreshed + wallets []accounts.Wallet // List of USB wallet devices currently tracking + + quit chan chan error + + stateLock sync.RWMutex // Protects the internals of the hub from racey access + + // TODO(karalabe): remove if hotplug lands on Windows + commsPend int // Number of operations blocking enumeration + commsLock sync.Mutex // Lock protecting the pending counter and enumeration + enumFails uint32 // Number of times enumeration has failed +} + +// NewLedgerHub creates a new hardware wallet manager for Ledger devices. +func NewLedgerHub() (*Hub, error) { + return newHub(LedgerScheme, 0x2c97, []uint16{ + // Device definitions taken from + // https://github.com/LedgerHQ/ledger-live/blob/38012bc8899e0f07149ea9cfe7e64b2c146bc92b/libs/ledgerjs/packages/devices/src/index.ts + + // Original product IDs + 0x0000, /* Ledger Blue */ + 0x0001, /* Ledger Nano S */ + 0x0004, /* Ledger Nano X */ + 0x0005, /* Ledger Nano S Plus */ + 0x0006, /* Ledger Nano FTS */ + + 0x0015, /* HID + U2F + WebUSB Ledger Blue */ + 0x1015, /* HID + U2F + WebUSB Ledger Nano S */ + 0x4015, /* HID + U2F + WebUSB Ledger Nano X */ + 0x5015, /* HID + U2F + WebUSB Ledger Nano S Plus */ + 0x6015, /* HID + U2F + WebUSB Ledger Nano FTS */ + + 0x0011, /* HID + WebUSB Ledger Blue */ + 0x1011, /* HID + WebUSB Ledger Nano S */ + 0x4011, /* HID + WebUSB Ledger Nano X */ + 0x5011, /* HID + WebUSB Ledger Nano S Plus */ + 0x6011, /* HID + WebUSB Ledger Nano FTS */ + }, 0xffa0, 0, newLedgerDriver) +} + +// newHub creates a new hardware wallet manager for generic USB devices. +func newHub(scheme string, vendorID uint16, productIDs []uint16, usageID uint16, endpointID int, makeDriver func() driver) (*Hub, error) { + if !usb.Supported() { + return nil, errors.New("unsupported platform") + } + hub := &Hub{ + scheme: scheme, + vendorID: vendorID, + productIDs: productIDs, + usageID: usageID, + endpointID: endpointID, + makeDriver: makeDriver, + quit: make(chan chan error), + } + hub.refreshWallets() + return hub, nil +} + +// Wallets implements accounts.Backend, returning all the currently tracked USB +// devices that appear to be hardware wallets. +func (hub *Hub) Wallets() []accounts.Wallet { + // Make sure the list of wallets is up-to-date + hub.refreshWallets() + + hub.stateLock.RLock() + defer hub.stateLock.RUnlock() + + cpy := make([]accounts.Wallet, len(hub.wallets)) + copy(cpy, hub.wallets) + return cpy +} + +// refreshWallets scans the USB devices attached to the machine and updates the +// list of wallets based on the found devices. +func (hub *Hub) refreshWallets() { + // Don't scan the USB like crazy it the user fetches wallets in a loop + hub.stateLock.RLock() + elapsed := time.Since(hub.refreshed) + hub.stateLock.RUnlock() + + if elapsed < refreshThrottling { + return + } + + // If USB enumeration is continually failing, don't keep trying indefinitely + if atomic.LoadUint32(&hub.enumFails) > 2 { + return + } + + // Retrieve the current list of USB wallet devices + var devices []usb.DeviceInfo + + if onLinux { + // hidapi on Linux opens the device during enumeration to retrieve some infos, + // breaking the Ledger protocol if that is waiting for user confirmation. This + // is a bug acknowledged at Ledger, but it won't be fixed on old devices, so we + // need to prevent concurrent comms ourselves. The more elegant solution would + // be to ditch enumeration in favor of hotplug events, but that don't work yet + // on Windows so if we need to hack it anyway, this is more elegant for now. + hub.commsLock.Lock() + if hub.commsPend > 0 { // A confirmation is pending, don't refresh + hub.commsLock.Unlock() + return + } + } + infos := usb.Enumerate(hub.vendorID, 0) + if infos == nil { + if onLinux { + // See rationale before the enumeration why this is needed and only on Linux. + hub.commsLock.Unlock() + } + return + } + atomic.StoreUint32(&hub.enumFails, 0) + + for _, info := range infos { + for _, id := range hub.productIDs { + // Windows and macOS use UsageID matching, Linux uses Interface matching + if info.ProductID == id && (info.UsagePage == hub.usageID || info.Interface == hub.endpointID) { + devices = append(devices, info) + break + } + } + } + + if onLinux { + // See rationale before the enumeration why this is needed and only on Linux. + hub.commsLock.Unlock() + } + + // Transform the current list of wallets into the new one + hub.stateLock.Lock() + + wallets := make([]accounts.Wallet, 0, len(devices)) + + for _, device := range devices { + url := gethaccounts.URL{ + Scheme: hub.scheme, + Path: device.Path, + } + + // Drop wallets in front of the next device or those that failed for some reason + for len(hub.wallets) > 0 { + // Abort if we're past the current device and found an operational one + _, err := hub.wallets[0].Status() + if hub.wallets[0].URL().Cmp(url) >= 0 || err == nil { + break + } + // Drop the stale and failed devices + hub.wallets = hub.wallets[1:] + } + + // If there are no more wallets or the device is before the next, wrap new wallet + if len(hub.wallets) == 0 || hub.wallets[0].URL().Cmp(url) > 0 { + wallet := &wallet{ + hub: hub, + driver: hub.makeDriver(), + url: &url, + info: device, + } + + wallets = append(wallets, wallet) + continue + } + // If the device is the same as the first wallet, keep it + if hub.wallets[0].URL().Cmp(url) == 0 { + wallets = append(wallets, hub.wallets[0]) + hub.wallets = hub.wallets[1:] + continue + } + } + + hub.refreshed = time.Now().UTC() + hub.wallets = wallets + hub.stateLock.Unlock() +} diff --git a/wallets/usbwallet/ledger.go b/wallets/usbwallet/ledger.go new file mode 100644 index 0000000..aa80751 --- /dev/null +++ b/wallets/usbwallet/ledger.go @@ -0,0 +1,433 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package usbwallet + +import ( + "crypto/ecdsa" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + + gethaccounts "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// ledgerOpcode is an enumeration encoding the supported Ledger opcodes. +type ledgerOpcode byte + +// ledgerParam1 is an enumeration encoding the supported Ledger parameters for +// specific opcodes. The same parameter values may be reused between opcodes. +type ledgerParam1 byte + +// ledgerParam2 is an enumeration encoding the supported Ledger parameters for +// specific opcodes. The same parameter values may be reused between opcodes. +type ledgerParam2 byte + +const ( + ledgerOpRetrieveAddress ledgerOpcode = 0x02 // Returns the public key and Ethereum address for a given BIP 32 path + ledgerOpGetConfiguration ledgerOpcode = 0x06 // Returns specific wallet application configuration + ledgerOpSignTypedMessage ledgerOpcode = 0x0c // Signs an Ethereum message following the EIP 712 specification + + ledgerP1DirectlyFetchAddress ledgerParam1 = 0x00 // Return address directly from the wallet + ledgerP1InitTypedMessageData ledgerParam1 = 0x00 // First chunk of Typed Message data + ledgerP2DiscardAddressChainCode ledgerParam2 = 0x00 // Do not return the chain code along with the address +) + +// errLedgerReplyInvalidHeader is the error message returned by a Ledger data exchange +// if the device replies with a mismatching header. This usually means the device +// is in browser mode. +var errLedgerReplyInvalidHeader = errors.New("ledger: invalid reply header") + +// errLedgerInvalidVersionReply is the error message returned by a Ledger version retrieval +// when a response does arrive, but it does not contain the expected data. +var errLedgerInvalidVersionReply = errors.New("ledger: invalid version reply") + +// ledgerDriver implements the communication with a Ledger hardware wallet. +type ledgerDriver struct { + device io.ReadWriter // USB device connection to communicate through + version [3]byte // Current version of the Ledger firmware (zero if app is offline) + browser bool // Flag whether the Ledger is in browser mode (reply channel mismatch) + failure error // Any failure that would make the device unusable +} + +// newLedgerDriver creates a new instance of a Ledger USB protocol driver. +func newLedgerDriver() driver { + return &ledgerDriver{} +} + +// Status implements usbwallet.driver, returning various states the Ledger can +// currently be in. +func (w *ledgerDriver) Status() (string, error) { + if w.failure != nil { + return fmt.Sprintf("Failed: %v", w.failure), w.failure + } + if w.browser { + return "Ethereum app in browser mode", w.failure + } + if w.offline() { + return "Ethereum app offline", w.failure + } + return fmt.Sprintf("Ethereum app v%d.%d.%d online", w.version[0], w.version[1], w.version[2]), w.failure +} + +// offline returns whether the wallet and the Ethereum app is offline or not. +// +// The method assumes that the state lock is held! +func (w *ledgerDriver) offline() bool { + return w.version == [3]byte{0, 0, 0} +} + +// Open implements usbwallet.driver, attempting to initialize the connection to the +// Ledger hardware wallet. The Ledger does not require a user passphrase, so that +// parameter is silently discarded. +func (w *ledgerDriver) Open(device io.ReadWriter, _ string) error { + w.device, w.failure = device, nil + + _, _, err := w.ledgerDerive(gethaccounts.DefaultBaseDerivationPath) + if err != nil { + // Ethereum app is not running or in browser mode, nothing more to do, return + if err == errLedgerReplyInvalidHeader { + w.browser = true + } + return nil + } + // Try to resolve the Ethereum app's version, will fail prior to v1.0.2 + version, err := w.ledgerVersion() + if err == nil { + w.version = version + } else { + w.version = [3]byte{1, 0, 0} // Assume worst case, can't verify if v1.0.0 or v1.0.1 + } + return nil +} + +// Close implements usbwallet.driver, cleaning up and metadata maintained within +// the Ledger driver. +func (w *ledgerDriver) Close() error { + w.browser, w.version = false, [3]byte{} + return nil +} + +// Heartbeat implements usbwallet.driver, performing a sanity check against the +// Ledger to see if it's still online. +func (w *ledgerDriver) Heartbeat() error { + if _, err := w.ledgerVersion(); err != nil && err != errLedgerInvalidVersionReply { + w.failure = err + return err + } + return nil +} + +// Derive implements usbwallet.driver, sending a derivation request to the Ledger +// and returning the Ethereum address located on that derivation path. +func (w *ledgerDriver) Derive(path gethaccounts.DerivationPath) (common.Address, *ecdsa.PublicKey, error) { + return w.ledgerDerive(path) +} + +// SignTypedMessage implements usbwallet.driver, sending the message to the Ledger and +// waiting for the user to sign or deny the transaction. +// +// Note: this was introduced in the ledger 1.5.0 firmware +func (w *ledgerDriver) SignTypedMessage(path gethaccounts.DerivationPath, domainHash, messageHash []byte) ([]byte, error) { + // If the Ethereum app doesn't run, abort + if w.offline() { + return nil, gethaccounts.ErrWalletClosed + } + // Ensure the wallet is capable of signing the given transaction + if w.version[0] < 1 && w.version[1] < 5 { + //nolint:stylecheck // ST1005 requires error strings to be lowercase but Ledger as a brand name should start with a capital letter + return nil, fmt.Errorf("Ledger version >= 1.5.0 required for EIP-712 signing (found version v%d.%d.%d)", w.version[0], w.version[1], w.version[2]) + } + // All infos gathered and metadata checks out, request signing + return w.ledgerSignTypedMessage(path, domainHash, messageHash) +} + +// ledgerVersion retrieves the current version of the Ethereum wallet app running +// on the Ledger wallet. +// +// The version retrieval protocol is defined as follows: +// +// CLA | INS | P1 | P2 | Lc | Le +// ----+-----+----+----+----+--- +// E0 | 06 | 00 | 00 | 00 | 04 +// +// With no input data, and the output data being: +// +// Description | Length +// ---------------------------------------------------+-------- +// Flags 01: arbitrary data signature enabled by user | 1 byte +// Application major version | 1 byte +// Application minor version | 1 byte +// Application patch version | 1 byte +func (w *ledgerDriver) ledgerVersion() ([3]byte, error) { + // Send the request and wait for the response + reply, err := w.ledgerExchange(ledgerOpGetConfiguration, 0, 0, nil) + if err != nil { + return [3]byte{}, err + } + if len(reply) != 4 { + return [3]byte{}, errLedgerInvalidVersionReply + } + // Cache the version for future reference + var version [3]byte + copy(version[:], reply[1:]) + return version, nil +} + +// ledgerDerive retrieves the currently active Ethereum address from a Ledger +// wallet at the specified derivation path. +// +// The address derivation protocol is defined as follows: +// +// CLA | INS | P1 | P2 | Lc | Le +// ----+-----+----+----+-----+--- +// E0 | 02 | 00 return address +// 01 display address and confirm before returning +// | 00: do not return the chain code +// | 01: return the chain code +// | var | 00 +// +// Where the input data is: +// +// Description | Length +// -------------------------------------------------+-------- +// Number of BIP 32 derivations to perform (max 10) | 1 byte +// First derivation index (big endian) | 4 bytes +// ... | 4 bytes +// Last derivation index (big endian) | 4 bytes +// +// And the output data is: +// +// Description | Length +// ------------------------+------------------- +// Public Key length | 1 byte +// Uncompressed Public Key | arbitrary +// Ethereum address length | 1 byte +// Ethereum address | 40 bytes hex ascii +// Chain code if requested | 32 bytes +func (w *ledgerDriver) ledgerDerive(derivationPath gethaccounts.DerivationPath) (common.Address, *ecdsa.PublicKey, error) { + // Flatten the derivation path into the Ledger request + path := make([]byte, 1+4*len(derivationPath)) + path[0] = byte(len(derivationPath)) + for i, component := range derivationPath { + binary.BigEndian.PutUint32(path[1+4*i:], component) + } + + // Send the request and wait for the response + reply, err := w.ledgerExchange(ledgerOpRetrieveAddress, ledgerP1DirectlyFetchAddress, ledgerP2DiscardAddressChainCode, path) + if err != nil { + return common.Address{}, nil, err + } + + // Verify public key was returned + // #nosec G701 -- gosec will raise a warning on this integer conversion for potential overflow + if len(reply) < 1 || len(reply) < 1+int(reply[0]) { + return common.Address{}, nil, errors.New("reply lacks public key entry") + } + + // #nosec G701 -- gosec will raise a warning on this integer conversion for potential overflow + replyFirstByteAsInt := int(reply[0]) + + pubkeyBz := reply[1 : 1+replyFirstByteAsInt] + + publicKey, err := crypto.UnmarshalPubkey(pubkeyBz) + if err != nil { + return common.Address{}, nil, fmt.Errorf("failed to unmarshal public key: %w", err) + } + + // Discard pubkey after fetching + reply = reply[1+replyFirstByteAsInt:] + + // Extract the Ethereum hex address string + // #nosec G701 -- gosec will raise a warning on this integer conversion for potential overflow + if len(reply) < 1 || len(reply) < 1+int(reply[0]) { + return common.Address{}, nil, errors.New("reply lacks address entry") + } + + // Reset first byte after discarding pubkey from response + // #nosec G701 -- gosec will raise a warning on this integer conversion for potential overflow + replyFirstByteAsInt = int(reply[0]) + + hexStr := reply[1 : 1+replyFirstByteAsInt] + + // Decode the hex string into an Ethereum address and return + var address common.Address + if _, err = hex.Decode(address[:], hexStr); err != nil { + return common.Address{}, nil, err + } + + derivedAddr := crypto.PubkeyToAddress(*publicKey) + if derivedAddr != address { + return common.Address{}, nil, fmt.Errorf("address mismatch, expected %s, got %s", derivedAddr, address) + } + + return address, publicKey, nil +} + +// ledgerSignTypedMessage sends the transaction to the Ledger wallet, and waits for the user +// to confirm or deny the transaction. +// +// The signing protocol is defined as follows: +// +// CLA | INS | P1 | P2 | Lc | Le +// ----+-----+----+-----------------------------+-----+--- +// E0 | 0C | 00 | implementation version : 00 | variable | variable +// +// Where the input is: +// +// Description | Length +// -------------------------------------------------+---------- +// Number of BIP 32 derivations to perform (max 10) | 1 byte +// First derivation index (big endian) | 4 bytes +// ... | 4 bytes +// Last derivation index (big endian) | 4 bytes +// domain hash | 32 bytes +// message hash | 32 bytes +// +// And the output data is: +// +// Description | Length +// ------------+--------- +// signature V | 1 byte +// signature R | 32 bytes +// signature S | 32 bytes +func (w *ledgerDriver) ledgerSignTypedMessage(derivationPath gethaccounts.DerivationPath, domainHash, messageHash []byte) ([]byte, error) { + // Flatten the derivation path into the Ledger request + path := make([]byte, 1+4*len(derivationPath)) + path[0] = byte(len(derivationPath)) + for i, component := range derivationPath { + binary.BigEndian.PutUint32(path[1+4*i:], component) + } + // Create the 712 message + var payload []byte + payload = append(payload, path...) + payload = append(payload, domainHash...) + payload = append(payload, messageHash...) + + // Send the request and wait for the response + var ( + op = ledgerP1InitTypedMessageData + reply []byte + err error + ) + + // Send the message over, ensuring it's processed correctly + reply, err = w.ledgerExchange(ledgerOpSignTypedMessage, op, 0, payload) + if err != nil { + return nil, err + } + + // Extract the Ethereum signature and do a sanity validation + if len(reply) != crypto.SignatureLength { + return nil, errors.New("reply lacks signature") + } + + var signature []byte + signature = append(signature, reply[1:]...) + signature = append(signature, reply[0]) + + return signature, nil +} + +// ledgerExchange performs a data exchange with the Ledger wallet, sending it a +// message and retrieving the response. +// +// The common transport header is defined as follows: +// +// Description | Length +// --------------------------------------+---------- +// Communication channel ID (big endian) | 2 bytes +// Command tag | 1 byte +// Packet sequence index (big endian) | 2 bytes +// Payload | arbitrary +// +// The Communication channel ID allows commands multiplexing over the same +// physical link. It is not used for the time being, and should be set to 0101 +// to avoid compatibility issues with implementations ignoring a leading 00 byte. +// +// The Command tag describes the message content. Use TAG_APDU (0x05) for standard +// APDU payloads, or TAG_PING (0x02) for a simple link test. +// +// The Packet sequence index describes the current sequence for fragmented payloads. +// The first fragment index is 0x00. +// +// APDU Command payloads are encoded as follows: +// +// Description | Length +// ----------------------------------- +// APDU length (big endian) | 2 bytes +// APDU CLA | 1 byte +// APDU INS | 1 byte +// APDU P1 | 1 byte +// APDU P2 | 1 byte +// APDU length | 1 byte +// Optional APDU data | arbitrary +func (w *ledgerDriver) ledgerExchange(opcode ledgerOpcode, p1 ledgerParam1, p2 ledgerParam2, data []byte) ([]byte, error) { + // Construct the message payload, possibly split into multiple chunks + apdu := make([]byte, 2, 7+len(data)) + + //#nosec G701 -- gosec will raise a warning on this integer conversion for potential overflow + binary.BigEndian.PutUint16(apdu, uint16(5+len(data))) + apdu = append(apdu, []byte{0xe0, byte(opcode), byte(p1), byte(p2), byte(len(data))}...) + apdu = append(apdu, data...) + + // Stream all the chunks to the device + header := []byte{0x01, 0x01, 0x05, 0x00, 0x00} // Channel ID and command tag appended + chunk := make([]byte, 64) + space := len(chunk) - len(header) + + for i := 0; len(apdu) > 0; i++ { + // Construct the new message to stream + chunk = append(chunk[:0], header...) + //#nosec G701 -- gosec will raise a warning on this integer conversion for potential overflow + binary.BigEndian.PutUint16(chunk[3:], uint16(i)) + + if len(apdu) > space { + chunk = append(chunk, apdu[:space]...) + apdu = apdu[space:] + } else { + chunk = append(chunk, apdu...) + apdu = nil + } + // Send over to the device + if _, err := w.device.Write(chunk); err != nil { + return nil, err + } + } + // Stream the reply back from the wallet in 64 byte chunks + var reply []byte + chunk = chunk[:64] // Yeah, we surely have enough space + for { + // Read the next chunk from the Ledger wallet + if _, err := io.ReadFull(w.device, chunk); err != nil { + return nil, err + } + + // Make sure the transport header matches + if chunk[0] != 0x01 || chunk[1] != 0x01 || chunk[2] != 0x05 { + return nil, errLedgerReplyInvalidHeader + } + // If it's the first chunk, retrieve the total message length + var payload []byte + + if chunk[3] == 0x00 && chunk[4] == 0x00 { + //#nosec G701 -- gosec will raise a warning on this integer conversion for potential overflow + reply = make([]byte, 0, int(binary.BigEndian.Uint16(chunk[5:7]))) + payload = chunk[7:] + } else { + payload = chunk[5:] + } + // Append to the reply and stop when filled up + if left := cap(reply) - len(reply); left > len(payload) { + reply = append(reply, payload...) + } else { + reply = append(reply, payload[:left]...) + break + } + } + return reply[:len(reply)-2], nil +} diff --git a/wallets/usbwallet/wallet.go b/wallets/usbwallet/wallet.go new file mode 100644 index 0000000..0e6f367 --- /dev/null +++ b/wallets/usbwallet/wallet.go @@ -0,0 +1,413 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package usbwallet + +import ( + "bytes" + "crypto/ecdsa" + "errors" + "fmt" + "io" + "sync" + "time" + + gethaccounts "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/evmos/os/wallets/accounts" + usb "github.com/zondax/hid" +) + +// Maximum time between wallet health checks to detect USB unplugs. +const heartbeatCycle = time.Second + +// driver defines the vendor specific functionality hardware wallets instances +// must implement to allow using them with the wallet lifecycle management. +type driver interface { + // Status returns a textual status to aid the user in the current state of the + // wallet. It also returns an error indicating any failure the wallet might have + // encountered. + Status() (string, error) + + // Open initializes access to a wallet instance. The passphrase parameter may + // or may not be used by the implementation of a particular wallet instance. + Open(device io.ReadWriter, passphrase string) error + + // Close releases any resources held by an open wallet instance. + Close() error + + // Heartbeat performs a sanity check against the hardware wallet to see if it + // is still online and healthy. + Heartbeat() error + + // Derive sends a derivation request to the USB device and returns the Ethereum + // address located on that path. + Derive(path gethaccounts.DerivationPath) (common.Address, *ecdsa.PublicKey, error) + + // SignTypedMessage sends the message to the Ledger and waits for the user to sign + // or deny the transaction. + SignTypedMessage(path gethaccounts.DerivationPath, messageHash []byte, domainHash []byte) ([]byte, error) +} + +// wallet represents the common functionality shared by all USB hardware +// wallets to prevent reimplementing the same complex maintenance mechanisms +// for different vendors. +type wallet struct { + hub *Hub // USB hub scanning + driver driver // Hardware implementation of the low level device operations + url *gethaccounts.URL // Textual URL uniquely identifying this wallet + + info usb.DeviceInfo // Known USB device infos about the wallet + device *usb.Device // USB device advertising itself as a hardware wallet + + accounts []accounts.Account // List of derive accounts pinned on the hardware wallet + paths map[common.Address]gethaccounts.DerivationPath // Known derivation paths for signing operations + + healthQuit chan chan error + + // Locking a hardware wallet is a bit special. Since hardware devices are lower + // performing, any communication with them might take a non-negligible amount of + // time. Worse still, waiting for user confirmation can take arbitrarily long, + // but exclusive communication must be upheld during. Locking the entire wallet + // in the meantime however would stall any parts of the system that don't want + // to communicate, just read some state (e.g. list the accounts). + // + // As such, a hardware wallet needs two locks to function correctly. A state + // lock can be used to protect the wallet's software-side internal state, which + // must not be held exclusively during hardware communication. A communication + // lock can be used to achieve exclusive access to the device itself, this one + // however should allow "skipping" waiting for operations that might want to + // use the device, but can live without too (e.g. account self-derivation). + // + // Since we have two locks, it's important to know how to properly use them: + // - Communication requires the `device` to not change, so obtaining the + // commsLock should be done after having a stateLock. + // - Communication must not disable read access to the wallet state, so it + // must only ever hold a *read* lock to stateLock. + commsLock chan struct{} // Mutex (buf=1) for the USB comms without keeping the state locked + stateLock sync.RWMutex // Protects read and write access to the wallet struct fields +} + +// URL implements accounts.Wallet, returning the URL of the USB hardware device. +func (w *wallet) URL() gethaccounts.URL { + return *w.url // Immutable, no need for a lock +} + +// Status implements accounts.Wallet, returning a custom status message from the +// underlying vendor-specific hardware wallet implementation. +func (w *wallet) Status() (string, error) { + w.stateLock.RLock() // No device communication, state lock is enough + defer w.stateLock.RUnlock() + + status, failure := w.driver.Status() + if w.device == nil { + return "Closed", failure + } + return status, failure +} + +// Open implements accounts.Wallet, attempting to open a USB connection to the +// hardware wallet. +func (w *wallet) Open(passphrase string) error { + w.stateLock.Lock() // State lock is enough since there's no connection yet at this point + defer w.stateLock.Unlock() + + // If the device was already opened once, refuse to try again + if w.paths != nil { + return gethaccounts.ErrWalletAlreadyOpen + } + // Make sure the actual device connection is done only once + if w.device == nil { + device, err := w.info.Open() + if err != nil { + return err + } + w.device = device + w.commsLock = make(chan struct{}, 1) + w.commsLock <- struct{}{} // Enable lock + } + // Delegate device initialization to the underlying driver + if err := w.driver.Open(w.device, passphrase); err != nil { + return err + } + // Connection successful, start life-cycle management + w.paths = make(map[common.Address]gethaccounts.DerivationPath) + + w.healthQuit = make(chan chan error) + + go w.heartbeat() + + return nil +} + +// heartbeat is a health check loop for the USB wallets to periodically verify +// whether they are still present or if they malfunctioned. +func (w *wallet) heartbeat() { + // Execute heartbeat checks until termination or error + var ( + errc chan error + err error + ) + for errc == nil && err == nil { + // Wait until termination is requested or the heartbeat cycle arrives + select { + case errc = <-w.healthQuit: + // Termination requested + continue + case <-time.After(heartbeatCycle): + // Heartbeat time + } + // Execute a tiny data exchange to see responsiveness + w.stateLock.RLock() + if w.device == nil { + // Terminated while waiting for the lock + w.stateLock.RUnlock() + continue + } + <-w.commsLock // Don't lock state while resolving version + err = w.driver.Heartbeat() + w.commsLock <- struct{}{} + w.stateLock.RUnlock() + + if err != nil { + w.stateLock.Lock() // Lock state to tear the wallet down + //#nosec G703 -- ignoring the returned error on purpose here + _ = w.close() + w.stateLock.Unlock() + } + // Ignore non hardware related errors + err = nil + } + // In case of error, wait for termination + if err != nil { + errc = <-w.healthQuit + } + errc <- err +} + +// Close implements accounts.Wallet, closing the USB connection to the device. +func (w *wallet) Close() error { + // Ensure the wallet was opened + w.stateLock.RLock() + hQuit := w.healthQuit + w.stateLock.RUnlock() + + // Terminate the health checks + var herr error + if hQuit != nil { + errc := make(chan error) + hQuit <- errc + herr = <-errc // Save for later, we *must* close the USB + } + + // Terminate the device connection + w.stateLock.Lock() + defer w.stateLock.Unlock() + + w.healthQuit = nil + + if err := w.close(); err != nil { + return err + } + if herr != nil { + return herr + } + return nil +} + +// close is the internal wallet closer that terminates the USB connection and +// resets all the fields to their defaults. +// +// Note, close assumes the state lock is held! +func (w *wallet) close() error { + // Allow duplicate closes, especially for health-check failures + if w.device == nil { + return nil + } + // Close the device, clear everything, then return + //#nosec G703 -- ignoring the returned error on purpose here + _ = w.device.Close() + w.device = nil + + w.accounts, w.paths = nil, nil + return w.driver.Close() +} + +// Accounts implements accounts.Wallet, returning the list of accounts pinned to +// the USB hardware wallet. If self-derivation was enabled, the account list is +// periodically expanded based on current chain state. +func (w *wallet) Accounts() []accounts.Account { + // Return current account list + w.stateLock.RLock() + defer w.stateLock.RUnlock() + + cpy := make([]accounts.Account, len(w.accounts)) + copy(cpy, w.accounts) + return cpy +} + +// Contains implements accounts.Wallet, returning whether a particular account is +// or is not pinned into this wallet instance. Although we could attempt to resolve +// unpinned accounts, that would be a non-negligible hardware operation. +func (w *wallet) Contains(account accounts.Account) bool { + w.stateLock.RLock() + defer w.stateLock.RUnlock() + + _, exists := w.paths[account.Address] + return exists +} + +// Derive implements accounts.Wallet, deriving a new account at the specific +// derivation path. If pin is set to true, the account will be added to the list +// of tracked accounts. +func (w *wallet) Derive(path gethaccounts.DerivationPath, pin bool) (accounts.Account, error) { + formatPathIfNeeded(path) + + // Try to derive the actual account and update its URL if successful + w.stateLock.RLock() // Avoid device disappearing during derivation + + if w.device == nil { + w.stateLock.RUnlock() + return accounts.Account{}, gethaccounts.ErrWalletClosed + } + <-w.commsLock // Avoid concurrent hardware access + address, publicKey, err := w.driver.Derive(path) + w.commsLock <- struct{}{} + + w.stateLock.RUnlock() + + // If an error occurred or no pinning was requested, return + if err != nil { + return accounts.Account{}, err + } + + account := accounts.Account{ + Address: address, + PublicKey: publicKey, + } + if !pin { + return account, nil + } + // Pinning needs to modify the state + w.stateLock.Lock() + defer w.stateLock.Unlock() + + if _, ok := w.paths[address]; !ok { + w.accounts = append(w.accounts, account) + w.paths[address] = make(gethaccounts.DerivationPath, len(path)) + copy(w.paths[address], path) + } + return account, nil +} + +// Format the hd path to harden the first three values (purpose, coinType, account) +// if needed, modifying the array in-place. +func formatPathIfNeeded(path gethaccounts.DerivationPath) { + for i := 0; i < 3; i++ { + if path[i] < 0x80000000 { + path[i] += 0x80000000 + } + } +} + +// signHash implements accounts.Wallet, however signing arbitrary data is not +// supported for hardware wallets, so this method will always return an error. +func (w *wallet) signHash(_ accounts.Account, _ []byte) ([]byte, error) { + return nil, gethaccounts.ErrNotSupported +} + +// SignData signs keccak256(data). The mimetype parameter describes the type of data being signed +func (w *wallet) signData(account accounts.Account, mimeType string, data []byte) ([]byte, error) { + // Unless we are doing 712 signing, simply dispatch to signHash + if !(mimeType == gethaccounts.MimetypeTypedData && len(data) == 66 && data[0] == 0x19 && data[1] == 0x01) { + return w.signHash(account, crypto.Keccak256(data)) + } + + // dispatch to 712 signing if the mimetype is TypedData and the format matches + w.stateLock.RLock() // Comms have own mutex, this is for the state fields + defer w.stateLock.RUnlock() + + // If the wallet is closed, abort + if w.device == nil { + return nil, gethaccounts.ErrWalletClosed + } + // Make sure the requested account is contained within + path, ok := w.paths[account.Address] + if !ok { + return nil, gethaccounts.ErrUnknownAccount + } + // All infos gathered and metadata checks out, request signing + <-w.commsLock + defer func() { w.commsLock <- struct{}{} }() + + // Ensure the device isn't screwed with while user confirmation is pending + // TODO(karalabe): remove if hotplug lands on Windows + w.hub.commsLock.Lock() + w.hub.commsPend++ + w.hub.commsLock.Unlock() + + defer func() { + w.hub.commsLock.Lock() + w.hub.commsPend-- + w.hub.commsLock.Unlock() + }() + // Sign the transaction + signature, err := w.driver.SignTypedMessage(path, data[2:34], data[34:66]) + if err != nil { + return nil, err + } + return signature, nil +} + +func (w *wallet) verifyTypedDataSignature(account accounts.Account, rawData []byte, signature []byte) error { + if len(signature) != crypto.SignatureLength { + return fmt.Errorf("invalid signature length: %d", len(signature)) + } + + // Copy signature as it would otherwise be modified + sigCopy := make([]byte, len(signature)) + copy(sigCopy, signature) + + // Subtract 27 to match ECDSA standard + sigCopy[crypto.RecoveryIDOffset] -= 27 + + hash := crypto.Keccak256(rawData) + + derivedPubkey, err := crypto.Ecrecover(hash, sigCopy) + if err != nil { + return err + } + + accountPK := crypto.FromECDSAPub(account.PublicKey) + + if !bytes.Equal(derivedPubkey, accountPK) { + return errors.New("unauthorized: invalid signature verification") + } + + return nil +} + +// SignTypedData signs a TypedData in EIP-712 format. This method is a wrapper +// to call SignData after hashing and encoding the TypedData input +func (w *wallet) SignTypedData(account accounts.Account, typedData apitypes.TypedData) ([]byte, error) { + _, rawData, err := apitypes.TypedDataAndHash(typedData) + if err != nil { + return nil, err + } + + rawDataBz := []byte(rawData) + + sigBytes, err := w.signData(account, "data/typed", rawDataBz) + if err != nil { + return nil, err + } + + // Verify recovered public key matches expected value + if err = w.verifyTypedDataSignature(account, rawDataBz, sigBytes); err != nil { + return nil, err + } + + return sigBytes, nil +}