diff --git a/cmd/blobstream/keys/evm/evm.go b/cmd/blobstream/keys/evm/evm.go index 4af6eb8a..38c9fdce 100644 --- a/cmd/blobstream/keys/evm/evm.go +++ b/cmd/blobstream/keys/evm/evm.go @@ -1,11 +1,18 @@ package evm import ( + "bufio" "bytes" + "crypto/ecdsa" + "errors" "fmt" "io" "os" + "github.com/cosmos/cosmos-sdk/client/input" + "github.com/cosmos/cosmos-sdk/crypto/hd" + "github.com/cosmos/go-bip39" + "github.com/ethereum/go-ethereum/accounts/keystore" common2 "github.com/celestiaorg/orchestrator-relayer/cmd/blobstream/keys/common" @@ -18,6 +25,8 @@ import ( "golang.org/x/term" ) +const mnemonicEntropySize = 256 + func Root(serviceName string) *cobra.Command { evmCmd := &cobra.Command{ Use: "evm", @@ -74,6 +83,15 @@ func Add(serviceName string) *cobra.Command { } }(s, logger) + bip39Passphrase, err := GetBIP39Passphrase() + if err != nil { + return err + } + + if bip39Passphrase != "" { + fmt.Println("\nThe provided passphrase will be the 25th word in your mnemonic. Make sure to save it as you won't be able to recover your accounts without it.") + } + passphrase := config.EVMPassphrase // if the passphrase is not specified as a flag, ask for it. if passphrase == "" { @@ -83,11 +101,39 @@ func Add(serviceName string) *cobra.Command { } } - account, err := s.EVMKeyStore.NewAccount(passphrase) + // read entropy seed straight from tmcrypto.Rand and convert to mnemonic + entropySeed, err := bip39.NewEntropy(mnemonicEntropySize) + if err != nil { + return err + } + + mnemonic, err := bip39.NewMnemonic(entropySeed) + if err != nil { + return err + } + + // get the private key using an empty passphrase so that only the mnemonic + // is enough to recover the account + ethPrivKey, err := MnemonicToPrivateKey(mnemonic, bip39Passphrase) if err != nil { return err } + + account, err := s.EVMKeyStore.ImportECDSA(ethPrivKey, passphrase) + if err != nil { + return err + } + logger.Info("account created successfully", "address", account.Address.String()) + + fmt.Println("\n\n**Important** write this mnemonic phrase in a safe place." + + "\nIt is the only way to recover your account if you ever forget your storage password.") + + if bip39Passphrase == "" { + fmt.Printf("\n%s\n\n", mnemonic) + } else { + fmt.Printf("\n%s \n\n", mnemonic) + } return nil }, } @@ -221,6 +267,7 @@ func Import(serviceName string) *cobra.Command { importCmd.AddCommand( ImportFile(serviceName), ImportECDSA(serviceName), + ImportMnemonic(serviceName), ) importCmd.SetHelpCommand(&cobra.Command{}) @@ -379,6 +426,87 @@ func ImportECDSA(serviceName string) *cobra.Command { return keysConfigFlags(&cmd, serviceName) } +func ImportMnemonic(serviceName string) *cobra.Command { + cmd := cobra.Command{ + Use: "mnemonic", + Args: cobra.ExactArgs(0), + Short: "import an EVM private key from a 24 words BIP39 mnemonic phrase", + RunE: func(cmd *cobra.Command, args []string) error { + config, err := parseKeysConfigFlags(cmd, serviceName) + if err != nil { + return err + } + + logger := tmlog.NewTMLogger(os.Stdout) + + initOptions := store.InitOptions{NeedEVMKeyStore: true} + isInit := store.IsInit(logger, config.Home, initOptions) + + // initialize the store if not initialized + if !isInit { + err := store.Init(logger, config.Home, initOptions) + if err != nil { + return err + } + } + + // open store + openOptions := store.OpenOptions{HasEVMKeyStore: true} + s, err := store.OpenStore(logger, config.Home, openOptions) + if err != nil { + return err + } + defer func(s *store.Store, log tmlog.Logger) { + err := s.Close(log, openOptions) + if err != nil { + logger.Error(err.Error()) + } + }(s, logger) + + // get the mnemonic from user input + inBuf := bufio.NewReader(os.Stdin) + mnemonic, err := input.GetString("Enter your bip39 mnemonic", inBuf) + if err != nil { + return err + } + if !bip39.IsMnemonicValid(mnemonic) { + return errors.New("invalid mnemonic") + } + + bip39Passphrase, err := GetBIP39Passphrase() + if err != nil { + return err + } + + // get the passphrase to use for the seed + passphrase := config.EVMPassphrase + // if the passphrase is not specified as a flag, ask for it. + if passphrase == "" { + passphrase, err = GetNewPassphrase() + if err != nil { + return err + } + } + + logger.Info("importing account") + + ethPrivKey, err := MnemonicToPrivateKey(mnemonic, bip39Passphrase) + if err != nil { + return err + } + + account, err := s.EVMKeyStore.ImportECDSA(ethPrivKey, passphrase) + if err != nil { + return err + } + + logger.Info("successfully imported key", "address", account.Address.String()) + return nil + }, + } + return keysConfigFlags(&cmd, serviceName) +} + func Update(serviceName string) *cobra.Command { cmd := cobra.Command{ Use: "update ", @@ -509,20 +637,73 @@ func GetNewPassphrase() (string, error) { var err error var bzPassphrase []byte for { - fmt.Print("please provide the account new passphrase: ") + fmt.Print("\nplease provide the account new passphrase (Note: this is for the store encryption and not the BIP39 passphrase. This means that you can recover your account without providing it): ") + bzPassphrase, err = term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", err + } + fmt.Print("\nenter the same passphrase again: ") + bzPassphraseConfirm, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", err + } + if bytes.Equal(bzPassphrase, bzPassphraseConfirm) { + fmt.Println() + break + } + fmt.Print("\npassphrase and confirmation mismatch.\n") + } + return string(bzPassphrase), nil +} + +func GetBIP39Passphrase() (string, error) { + var err error + var bzPassphrase []byte + for { + fmt.Print("\nplease provide the BIP39 passphrase (leave empty if you don't want to set a BIP39 passphrase, i.e. 25th mnemonic word): ") bzPassphrase, err = term.ReadPassword(int(os.Stdin.Fd())) if err != nil { return "", err } + if len(string(bzPassphrase)) > 100 { + fmt.Println("\n\nThe BIP39 passphrase cannot have more than 100 characters! Please try again.") + continue + } fmt.Print("\nenter the same passphrase again: ") bzPassphraseConfirm, err := term.ReadPassword(int(os.Stdin.Fd())) if err != nil { return "", err } if bytes.Equal(bzPassphrase, bzPassphraseConfirm) { + fmt.Println() break } fmt.Print("\npassphrase and confirmation mismatch.\n") } return string(bzPassphrase), nil } + +// MnemonicToPrivateKey derives a private key from the provided mnemonic. +// It uses the default derivation path, geth.DefaultBaseDerivationPath, i.e. m/44'/60'/0'/0, to generate +// the first private key. The generated account is of path m/44'/60'/0'/0/0. +func MnemonicToPrivateKey(mnemonic string, passphrase string) (*ecdsa.PrivateKey, error) { + // create the master key + seed, err := bip39.NewSeedWithErrorChecking(mnemonic, passphrase) + if err != nil { + return nil, err + } + + secret, chainCode := hd.ComputeMastersFromSeed(seed) + + // derive the first private key from the master key + key, err := hd.DerivePrivateKeyForPath(secret, chainCode, accounts.DefaultBaseDerivationPath.String()) + if err != nil { + return nil, err + } + + ethPrivKey, err := ethcrypto.ToECDSA(key) + if err != nil { + return nil, err + } + return ethPrivKey, nil +} diff --git a/cmd/blobstream/keys/evm/evm_test.go b/cmd/blobstream/keys/evm/evm_test.go new file mode 100644 index 00000000..ca786e3b --- /dev/null +++ b/cmd/blobstream/keys/evm/evm_test.go @@ -0,0 +1,62 @@ +package evm_test + +import ( + "testing" + + "github.com/celestiaorg/orchestrator-relayer/cmd/blobstream/keys/evm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" +) + +// TestMnemonicToPrivateKey tests the generation of private keys using mnemonics. +// The test vectors were generated and verified using a Ledger Nano X with Ethereum accounts. +func TestMnemonicToPrivateKey(t *testing.T) { + tests := []struct { + name string + mnemonic string + passphrase string + expectedError bool + expectedResult string + expectedAddress string + }{ + { + name: "Valid Mnemonic with passphrase", + mnemonic: "eight moment square film same crystal trophy diagram awkward defense crazy garlic exile rabbit coast truck foam broken shed attract bamboo drum dry cage", + passphrase: "abcd", + expectedError: false, + expectedResult: "5dfb97434a8a31cca1d1c2c6b6b9cf09b4946823331ec434894f204acf79d850", + expectedAddress: "0x6Ca3653B3B50892e051Da60b1E14540f2f7EBdBF", + }, + { + name: "Valid Mnemonic without passphrase", + mnemonic: "eight moment square film same crystal trophy diagram awkward defense crazy garlic exile rabbit coast truck foam broken shed attract bamboo drum dry cage", + passphrase: "", + expectedError: false, + expectedResult: "4252916c6e7f80dc96928c66a885be5a362790ad2fb3552ab781cd9112aef3a2", + expectedAddress: "0x33bb23EB923C284fC76D93C26aFd1FdCAf770Ea2", + }, + { + name: "Invalid Mnemonic", + mnemonic: "wrong mnemonic beginning poverty injury cradle wrong smoke sphere trap tumble girl monkey sibling festival mask second agent slice gadget census glare swear recycle", + expectedError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + privateKey, err := evm.MnemonicToPrivateKey(test.mnemonic, test.passphrase) + + if test.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + addr := crypto.PubkeyToAddress(privateKey.PublicKey) + assert.Equal(t, test.expectedAddress, addr.Hex()) + + expectedPrivateKey, err := crypto.HexToECDSA(test.expectedResult) + assert.NoError(t, err) + assert.Equal(t, expectedPrivateKey.D.Bytes(), privateKey.D.Bytes()) + } + }) + } +} diff --git a/go.mod b/go.mod index c9279d40..e60c8ec0 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( require ( github.com/celestiaorg/blobstream-contracts/v3 v3.1.0 github.com/cosmos/cosmos-sdk v0.46.14 + github.com/cosmos/go-bip39 v1.0.0 github.com/dgraph-io/badger/v2 v2.2007.4 github.com/ipfs/boxo v0.15.0 github.com/ipfs/go-datastore v0.6.0 @@ -84,7 +85,6 @@ require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cosmos/btcutil v1.0.5 // indirect github.com/cosmos/cosmos-proto v1.0.0-alpha8 // indirect - github.com/cosmos/go-bip39 v1.0.0 // indirect github.com/cosmos/gogoproto v1.4.11 // indirect github.com/cosmos/gorocksdb v1.2.0 // indirect github.com/cosmos/iavl v0.19.6 // indirect