diff --git a/cmd/wallets.go b/cmd/wallets.go new file mode 100644 index 0000000..5d11575 --- /dev/null +++ b/cmd/wallets.go @@ -0,0 +1,287 @@ +package cmd + +import ( + "fmt" + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/console/prompt" + "github.com/rovergulf/chain/wallets" + "github.com/spf13/cobra" + "github.com/tyler-smith/go-bip39" + "os" + "path" +) + +var accountManager *wallets.Manager + +// walletsCmd represents the wallet command +func walletsCmd() *cobra.Command { + var walletsCmd = &cobra.Command{ + Use: "wallets", + Short: "Wallet related operations", + Long: ``, + SilenceUsage: true, + TraverseChildren: true, + } + + walletsCmd.AddCommand(walletsNewCmd()) + walletsCmd.AddCommand(walletsUpdateAuthCmd()) + walletsCmd.AddCommand(walletsListCmd()) + walletsCmd.AddCommand(walletsPrintPrivKeyCmd()) + //walletsCmd.AddCommand(walletsImportCmd()) + + return walletsCmd +} + +func walletsListCmd() *cobra.Command { + var walletsListCmd = &cobra.Command{ + Use: "list", + Short: "Lists available wallet addresses.", + PreRunE: prepareWalletsManager, + RunE: func(cmd *cobra.Command, args []string) error { + defer accountManager.Shutdown() + + addresses, err := accountManager.GetAllAddresses() + if err != nil { + return err + } + + return writeOutput(cmd, map[string]interface{}{ + "addresses": addresses, + }) + }, + TraverseChildren: true, + } + + addOutputFormatFlag(walletsListCmd) + + return walletsListCmd +} + +func walletsPrintPrivKeyCmd() *cobra.Command { + var walletsPrintPrivKeyCmd = &cobra.Command{ + Use: "print-pk", + Short: "Unlocks keystore file and prints the Private + Public keys.", + PreRunE: prepareWalletsManager, + RunE: func(cmd *cobra.Command, args []string) error { + defer accountManager.Shutdown() + + address, _ := cmd.Flags().GetString("address") + if !common.IsHexAddress(address) { + return fmt.Errorf("bad address format") + } + + auth, err := getPassPhrase("Enter passphrase do decrypt wallet:", true) + if err != nil { + return err + } + + wallet, err := accountManager.GetWallet(common.HexToAddress(address), auth) + if err != nil { + logger.Errorf("Unable to get wallet: %s", err) + return err + } + + return writeOutput(cmd, wallet.GetKey()) + }, + TraverseChildren: true, + } + + addOutputFormatFlag(walletsPrintPrivKeyCmd) + addAddressFlag(walletsPrintPrivKeyCmd) + + return walletsPrintPrivKeyCmd +} + +func walletsNewCmd() *cobra.Command { + var walletsNewCmd = &cobra.Command{ + Use: "new", + Short: "Creates a new wallet.", + PreRunE: prepareWalletsManager, + RunE: func(cmd *cobra.Command, args []string) error { + defer accountManager.Shutdown() + + useMnemonic, _ := cmd.Flags().GetBool("mnemonic") + + var auth string + + if !useMnemonic { + input, err := getPassPhrase("Enter secret passphrase to encrypt the wallet:", true) + if err != nil { + return err + } + + if len(input) < 6 { + return fmt.Errorf("too weak, min 6 symbols length") + } + + auth = input + } else { + // generate a random Mnemonic in English with 256 bits of entropy + entropy, _ := bip39.NewEntropy(256) + auth, _ = bip39.NewMnemonic(entropy) + + logger.Infof("Random Mnemonic passphrase to unlock wallet: \n\n\t%s\n", auth) + logger.Warn("Save this passphrase to access your wallet.", + "There is no way to recover it, but you can change it") + } + + // do not use mnemonic based seed to create new key, to prevent passphrase leak + // is it a bad idea tho? + key, err := wallets.NewRandomKey() + if err != nil { + return err + } + + wallet, err := accountManager.AddWallet(key, auth) + if err != nil { + return err + } + + logger.Infof("Done! Wallet address: \n\n\t%s\n", wallet.Address()) + return nil + }, + TraverseChildren: true, + } + + walletsNewCmd.Flags().Bool("mnemonic", true, "Use mnemonic passphrase for wallet encrypting") + + return walletsNewCmd +} + +func walletsUpdateAuthCmd() *cobra.Command { + var walletsNewCmd = &cobra.Command{ + Use: "update", + Short: "Change wallet passphrase", + PreRunE: prepareWalletsManager, + RunE: func(cmd *cobra.Command, args []string) error { + defer accountManager.Shutdown() + + flagAddr, _ := cmd.Flags().GetString("address") + if !common.IsHexAddress(flagAddr) { + return fmt.Errorf("invalid address: %s", flagAddr) + } + addr := common.HexToAddress(flagAddr) + + useMnemonic, _ := cmd.Flags().GetBool("mnemonic") + + var newAuth string + auth, err := getPassPhrase("Enter passphrase do decrypt wallet:", false) + if err != nil { + return err + } + + if !useMnemonic { + input, err := getPassPhrase("Enter old password:", false) + if err != nil { + return err + } + + if len(input) < 6 { + return fmt.Errorf("too weak, min 6 symbols length") + } + + newAuth = input + } else { + // generate a random Mnemonic in English with 256 bits of entropy + mnemonic, err := wallets.NewRandomMnemonic() + if err != nil { + return err + } + newAuth = mnemonic + + logger.Infof("Random Mnemonic passphrase to unlock wallet: \n\n\t%s\n", auth) + logger.Warn("Save this passphrase to access your wallet.", + "There is no way to recover it, but you can change it") + } + + w, err := accountManager.GetWallet(addr, auth) + if err != nil { + logger.Errorf("Unable to get wallet: %s", err) + return err + } + + if _, err := accountManager.AddWallet(w.GetKey(), newAuth); err != nil { + return err + } + + logger.Infof("Done! Passphrase for account '%s' has changed!", addr.Hex()) + return nil + }, + TraverseChildren: true, + } + + addAddressFlag(walletsNewCmd) + + walletsNewCmd.Flags().Bool("mnemonic", true, "Use mnemonic passphrase for wallet encrypting") + + return walletsNewCmd +} + +func walletsImportCmd() *cobra.Command { + walletsRecoverCmd := &cobra.Command{ + Use: "import", + Short: "Imports key from specified CryptoJSON file to keystore", + Long: ``, + PreRunE: prepareWalletsManager, + RunE: func(cmd *cobra.Command, args []string) error { + //ctx, cancel := context.WithCancel(context.Background()) + //defer cancel() + defer accountManager.Shutdown() + + auth, err := getPassPhrase("Enter passphrase do decrypt wallet:", false) + if err != nil { + return err + } + + filePath, _ := cmd.Flags().GetString("file") + if path.Ext(filePath) != ".json" { + return fmt.Errorf("file extension must be json") + } + + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + + key, err := keystore.DecryptKey(data, auth) + if err != nil { + return err + } + + w, err := accountManager.AddWallet(key, auth) + if err != nil { + return err + } + + logger.Info("Successfully imported '%s' account into keystore", w.Address()) + return nil + }, + TraverseChildren: true, + } + + walletsRecoverCmd.Flags().StringP("file", "f", "", "Specify key file path to decode") + walletsRecoverCmd.MarkFlagRequired("file") + + return walletsRecoverCmd +} + +func getPassPhrase(message string, confirmation bool) (string, error) { + auth, err := prompt.Stdin.PromptPassword(message) + if err != nil { + return "", err + } + + if confirmation { + confirm, err := prompt.Stdin.PromptPassword("Repeat password: ") + if err != nil { + return "", fmt.Errorf("failed to read passphrase confirmation: %v", err) + } + + if auth != confirm { + return "", fmt.Errorf("passphrases do not match") + } + } + + return auth, nil +} diff --git a/wallets/manager.go b/wallets/manager.go new file mode 100644 index 0000000..1668665 --- /dev/null +++ b/wallets/manager.go @@ -0,0 +1,59 @@ +package wallets + +import ( + "errors" + "github.com/dgraph-io/badger/v3" + "github.com/rovergulf/chain/pkg/logutils" + "github.com/rovergulf/chain/storage/badgerdb" + "github.com/spf13/viper" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" + "path" +) + +const DbWalletFile = "wallets.db" + +var ( + ErrAccountNotExists = errors.New("account not exists") + ErrInvalidAuth = errors.New("invalid authentication code") + ErrAccountIsLocked = errors.New("account is locked") +) + +type Manager struct { + db *badger.DB + logger *zap.SugaredLogger + tracer trace.Tracer + quit chan struct{} +} + +// NewManager returns wallets Manager instance +func NewManager() (*Manager, error) { + logger, err := logutils.NewLogger() + if err != nil { + return nil, err + } + + walletsDbPath := path.Join(viper.GetString("data_dir"), "keystore") + badgerOpts := badger.DefaultOptions(walletsDbPath) + db, err := badgerdb.OpenDB(walletsDbPath, badgerOpts) + if err != nil { + return nil, err + } + + return &Manager{ + db: db, + logger: logger, + }, err +} + +func (m *Manager) DbSize() (int64, int64) { + return m.db.Size() +} + +func (m *Manager) Shutdown() { + if m.db != nil { + if err := m.db.Close(); err != nil { + m.logger.Errorf("Unable to close wallets db: %s", err) + } + } +} diff --git a/wallets/manager_wallets.go b/wallets/manager_wallets.go new file mode 100644 index 0000000..288005b --- /dev/null +++ b/wallets/manager_wallets.go @@ -0,0 +1,100 @@ +package wallets + +import ( + "context" + "github.com/dgraph-io/badger/v3" + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" +) + +func (m *Manager) AddWallet(key *keystore.Key, auth string) (*Wallet, error) { + encryptedKey, err := keystore.EncryptKey(key, auth, keystore.StandardScryptN, keystore.StandardScryptP) + if err != nil { + return nil, err + } + + if err := m.db.Update(func(txn *badger.Txn) error { + return txn.Set(key.Address.Bytes(), encryptedKey) + }); err != nil { + return nil, err + } + + wallet := &Wallet{ + Auth: auth, + KeyData: encryptedKey, + key: key, + } + + return wallet, nil +} + +func (m *Manager) GetAllAddresses() ([]common.Address, error) { + var addresses []common.Address + + if err := m.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.PrefetchSize = 10 + it := txn.NewIterator(opts) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + addresses = append(addresses, common.BytesToAddress(item.Key())) + } + return nil + }); err != nil { + m.logger.Errorw("Unable to iterate db view", "err", err) + return nil, err + } + + return addresses, nil +} + +func (m *Manager) findAccountKey(address common.Address) ([]byte, error) { + var privateKey []byte + if err := m.db.View(func(txn *badger.Txn) error { + item, err := txn.Get(address.Bytes()) + if err != nil { + if err == badger.ErrKeyNotFound { + return ErrAccountNotExists + } + return err + } + + return item.Value(func(val []byte) error { + privateKey = append(privateKey, val...) + return nil + }) + }); err != nil { + return nil, err + } + + return privateKey, nil +} + +func (m *Manager) GetWallet(address common.Address, auth string) (*Wallet, error) { + encryptedKey, err := m.findAccountKey(address) + if err != nil { + return nil, err + } + + key, err := keystore.DecryptKey(encryptedKey, auth) + if err != nil { + return nil, err + } + + return &Wallet{ + Auth: auth, + KeyData: encryptedKey, + key: key, + }, nil +} + +func (m *Manager) Exists(ctx context.Context, address common.Address) error { + return m.db.View(func(txn *badger.Txn) error { + if _, err := txn.Get(address.Bytes()); err != nil { + return err + } else { + return nil + } + }) +} diff --git a/wallets/wallet.go b/wallets/wallet.go new file mode 100644 index 0000000..2fe29c5 --- /dev/null +++ b/wallets/wallet.go @@ -0,0 +1,140 @@ +package wallets + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/gob" + "fmt" + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/google/uuid" + "github.com/tyler-smith/go-bip39" +) + +func init() { + gob.Register(elliptic.P256()) +} + +const ( + WalletStatusLocked = "Locked" + WalletStatusUnlocked = "Unlocked" +) + +type Wallet struct { + Auth string `json:"auth" yaml:"auth"` + KeyData []byte `json:"-" yaml:"-"` // stores encrypted key + key *keystore.Key +} + +func (w *Wallet) Serialize() ([]byte, error) { + var buf bytes.Buffer + encoder := gob.NewEncoder(&buf) + if err := encoder.Encode(w); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func (w *Wallet) Deserialize(data []byte) error { + decoder := gob.NewDecoder(bytes.NewReader(data)) + return decoder.Decode(w) +} + +func (w *Wallet) SignTx(tx *types.Transaction) (*types.Transaction, error) { + if w.key == nil { + return nil, ErrAccountIsLocked + } + + return types.SignTx(tx, types.NewEIP155Signer(tx.ChainId()), w.key.PrivateKey) +} + +func (w *Wallet) Address() common.Address { + return w.key.Address +} + +func (w *Wallet) GetKey() *keystore.Key { + return w.key +} + +func (w *Wallet) Status() string { + if w.key != nil { + return WalletStatusUnlocked + } else { + return WalletStatusLocked + } +} + +func (w *Wallet) Open() error { + key, err := keystore.DecryptKey(w.KeyData, w.Auth) + if err != nil { + return err + } + + w.key = key + return nil +} + +func (w *Wallet) EncryptKey() error { + data, err := keystore.EncryptKey(w.key, w.Auth, keystore.StandardScryptN, keystore.StandardScryptP) + if err != nil { + return err + } + + w.KeyData = data + return nil +} + +func Sign(msg []byte, privKey *ecdsa.PrivateKey) (sig []byte, err error) { + msgHash := sha256.Sum256(msg) + return crypto.Sign(msgHash[:], privKey) +} + +func SignMessage(msg []byte, privKey *ecdsa.PrivateKey) (sig []byte, err error) { + msgHash := accounts.TextHash(msg) + return crypto.Sign(msgHash, privKey) +} + +func Verify(msg, sig []byte) (*ecdsa.PublicKey, error) { + msgHash := sha256.Sum256(msg) + + recoveredPubKey, err := crypto.SigToPub(msgHash[:], sig) + if err != nil { + return nil, fmt.Errorf("unable to verify message signature. %s", err.Error()) + } + + return recoveredPubKey, nil +} + +func NewRandomKey() (*keystore.Key, error) { + privateKeyECDSA, err := ecdsa.GenerateKey(crypto.S256(), rand.Reader) + if err != nil { + return nil, err + } + + key := &keystore.Key{ + Id: uuid.New(), + Address: crypto.PubkeyToAddress(privateKeyECDSA.PublicKey), + PrivateKey: privateKeyECDSA, + } + + return key, nil +} + +func NewRandomMnemonic() (string, error) { + entropy, err := bip39.NewEntropy(256) + if err != nil { + return "", err + } + mnemonic, err := bip39.NewMnemonic(entropy) + if err != nil { + return "", err + } + return mnemonic, nil +} diff --git a/wallets/wallet_test.go b/wallets/wallet_test.go new file mode 100644 index 0000000..5185d06 --- /dev/null +++ b/wallets/wallet_test.go @@ -0,0 +1,42 @@ +package wallets + +import ( + "crypto/elliptic" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/rovergulf/chain/tests" + "testing" +) + +func TestSign(t *testing.T) { + privKey, err := crypto.HexToECDSA(tests.PrivateKey0) + if err != nil { + t.Fatal(err) + } + + pubKey := privKey.PublicKey + pubKeyBytes := elliptic.Marshal(crypto.S256(), pubKey.X, pubKey.Y) + pubKeyBytesHash := crypto.Keccak256(pubKeyBytes[1:]) + + account := common.BytesToAddress(pubKeyBytesHash[12:]) + + msg := []byte("Coin sign function test: 0") + + sig, err := Sign(msg, privKey) + if err != nil { + t.Fatal(err) + } + + recoveredPubKey, err := Verify(msg, sig) + if err != nil { + t.Fatal(err) + } + + recoveredPubKeyBytes := elliptic.Marshal(crypto.S256(), recoveredPubKey.X, recoveredPubKey.Y) + recoveredPubKeyBytesHash := crypto.Keccak256(recoveredPubKeyBytes[1:]) + recoveredAccount := common.BytesToAddress(recoveredPubKeyBytesHash[12:]) + + if account.Hex() != recoveredAccount.Hex() { + t.Fatalf("msg was signed by account %s but signature recovery produced an account %s", account.Hex(), recoveredAccount.Hex()) + } +}