Skip to content
This repository has been archived by the owner on Apr 15, 2024. It is now read-only.

feat: adds support for BIP39 mnemonics #577

Merged
merged 12 commits into from
Nov 7, 2023
185 changes: 183 additions & 2 deletions cmd/blobstream/keys/evm/evm.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -18,6 +25,8 @@ import (
"golang.org/x/term"
)

const mnemonicEntropySize = 256

func Root(serviceName string) *cobra.Command {
evmCmd := &cobra.Command{
Use: "evm",
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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 <your_bip39_passphrase>\n\n", mnemonic)
}
return nil
},
}
Expand Down Expand Up @@ -221,6 +267,7 @@ func Import(serviceName string) *cobra.Command {
importCmd.AddCommand(
ImportFile(serviceName),
ImportECDSA(serviceName),
ImportMnemonic(serviceName),
)

importCmd.SetHelpCommand(&cobra.Command{})
Expand Down Expand Up @@ -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 <account address in hex>",
Expand Down Expand Up @@ -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
}
62 changes: 62 additions & 0 deletions cmd/blobstream/keys/evm/evm_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading