From 9c08c0a1a45a71c45588b4e76a5019651f272d1f Mon Sep 17 00:00:00 2001 From: Jim McDonald Date: Fri, 20 Nov 2020 19:59:49 +0000 Subject: [PATCH] Add "account derive" command --- CHANGELOG.md | 3 +- cmd/account/derive/input.go | 54 ++++++++++++ cmd/account/derive/input_internal_test.go | 78 +++++++++++++++++ cmd/account/derive/output.go | 46 ++++++++++ cmd/account/derive/output_internal_test.go | 79 +++++++++++++++++ cmd/account/derive/process.go | 71 +++++++++++++++ cmd/account/derive/process_internal_test.go | 97 +++++++++++++++++++++ cmd/account/derive/run.go | 49 +++++++++++ cmd/accountderive.go | 65 ++++++++++++++ cmd/root.go | 2 + docs/conversions.md | 16 +++- docs/usage.md | 14 +++ 12 files changed, 569 insertions(+), 5 deletions(-) create mode 100644 cmd/account/derive/input.go create mode 100644 cmd/account/derive/input_internal_test.go create mode 100644 cmd/account/derive/output.go create mode 100644 cmd/account/derive/output_internal_test.go create mode 100644 cmd/account/derive/process.go create mode 100644 cmd/account/derive/process_internal_test.go create mode 100644 cmd/account/derive/run.go create mode 100644 cmd/accountderive.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f3317c6..70569c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ Development: - - add more outpu to "deposit verify" to explain operation + - new "account derive" command to derive keys directly from a mnemonic and derivation path + - add more output to "deposit verify" to explain operation 1.7.1: - fix "store not set" issue 1.7.0: diff --git a/cmd/account/derive/input.go b/cmd/account/derive/input.go new file mode 100644 index 0000000..2a0df24 --- /dev/null +++ b/cmd/account/derive/input.go @@ -0,0 +1,54 @@ +// Copyright © 2020 Weald Technology Trading +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package accountderive + +import ( + "context" + + "github.com/pkg/errors" + "github.com/spf13/viper" +) + +type dataIn struct { + quiet bool + // Derivation information. + mnemonic string + path string + // Output options. + showKey bool +} + +func input(ctx context.Context) (*dataIn, error) { + data := &dataIn{} + + // Quiet. + data.quiet = viper.GetBool("quiet") + + // Mnemonic. + if viper.GetString("mnemonic") == "" { + return nil, errors.New("mnemonic is required") + } + data.mnemonic = viper.GetString("mnemonic") + + // Path. + if viper.GetString("path") == "" { + return nil, errors.New("path is required") + } + data.path = viper.GetString("path") + + // Show key. + data.showKey = viper.GetBool("show-key") + + return data, nil +} diff --git a/cmd/account/derive/input_internal_test.go b/cmd/account/derive/input_internal_test.go new file mode 100644 index 0000000..ab88cd7 --- /dev/null +++ b/cmd/account/derive/input_internal_test.go @@ -0,0 +1,78 @@ +// Copyright © 2020 Weald Technology Trading +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package accountderive + +import ( + "context" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + e2types "github.com/wealdtech/go-eth2-types/v2" +) + +func TestInput(t *testing.T) { + require.NoError(t, e2types.InitBLS()) + + tests := []struct { + name string + vars map[string]interface{} + res *dataIn + err string + }{ + { + name: "MnemonicMissing", + vars: map[string]interface{}{ + "path": "m/12381/3600/0/0", + }, + err: "mnemonic is required", + }, + { + name: "PathMissing", + vars: map[string]interface{}{ + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + }, + err: "path is required", + }, + { + name: "Good", + vars: map[string]interface{}{ + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + "path": "m/12381/3600/0/0", + }, + res: &dataIn{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + path: "m/12381/3600/0/0", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + viper.Reset() + for k, v := range test.vars { + viper.Set(k, v) + } + res, err := input(context.Background()) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + // Cannot compare accounts directly, so need to check each element individually. + require.Equal(t, test.res.mnemonic, res.mnemonic) + require.Equal(t, test.res.path, res.path) + } + }) + } +} diff --git a/cmd/account/derive/output.go b/cmd/account/derive/output.go new file mode 100644 index 0000000..90ebfc9 --- /dev/null +++ b/cmd/account/derive/output.go @@ -0,0 +1,46 @@ +// Copyright © 2020 Weald Technology Trading +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package accountderive + +import ( + "context" + "fmt" + "strings" + + "github.com/pkg/errors" + e2types "github.com/wealdtech/go-eth2-types/v2" +) + +type dataOut struct { + showKey bool + key *e2types.BLSPrivateKey +} + +func output(ctx context.Context, data *dataOut) (string, error) { + if data == nil { + return "", errors.New("no data") + } + if data.key == nil { + return "", errors.New("no key") + } + + builder := strings.Builder{} + + if data.showKey { + builder.WriteString(fmt.Sprintf("Private key: %#x\n", data.key.Marshal())) + } + builder.WriteString(fmt.Sprintf("Public key: %#x", data.key.PublicKey().Marshal())) + + return builder.String(), nil +} diff --git a/cmd/account/derive/output_internal_test.go b/cmd/account/derive/output_internal_test.go new file mode 100644 index 0000000..96d8ca1 --- /dev/null +++ b/cmd/account/derive/output_internal_test.go @@ -0,0 +1,79 @@ +// Copyright © 2020 Weald Technology Trading +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package accountderive + +import ( + "context" + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/require" + e2types "github.com/wealdtech/go-eth2-types/v2" +) + +func blsPrivateKey(input string) *e2types.BLSPrivateKey { + data, err := hex.DecodeString(strings.TrimPrefix(input, "0x")) + key, err := e2types.BLSPrivateKeyFromBytes(data) + if err != nil { + panic(err) + } + return key +} + +func TestOutput(t *testing.T) { + tests := []struct { + name string + dataOut *dataOut + res string + err string + }{ + { + name: "Nil", + err: "no data", + }, + { + name: "KeyMissing", + dataOut: &dataOut{}, + err: "no key", + }, + { + name: "Good", + dataOut: &dataOut{ + key: blsPrivateKey("0x068dce0c90cb428ab37a74af0191eac49648035f1aaef077734b91e05985ec55"), + }, + res: "Public key: 0x99b1f1d84d76185466d86c34bde1101316afddae76217aa86cd066979b19858c2c9d9e56eebc1e067ac54277a61790db", + }, + { + name: "PrivatKey", + dataOut: &dataOut{ + key: blsPrivateKey("0x068dce0c90cb428ab37a74af0191eac49648035f1aaef077734b91e05985ec55"), + showKey: true, + }, + res: "Private key: 0x068dce0c90cb428ab37a74af0191eac49648035f1aaef077734b91e05985ec55\nPublic key: 0x99b1f1d84d76185466d86c34bde1101316afddae76217aa86cd066979b19858c2c9d9e56eebc1e067ac54277a61790db", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + res, err := output(context.Background(), test.dataOut) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.res, res) + } + }) + } +} diff --git a/cmd/account/derive/process.go b/cmd/account/derive/process.go new file mode 100644 index 0000000..dbe2dbc --- /dev/null +++ b/cmd/account/derive/process.go @@ -0,0 +1,71 @@ +// Copyright © 2020 Weald Technology Trading +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package accountderive + +import ( + "context" + "regexp" + "strings" + + "github.com/pkg/errors" + "github.com/tyler-smith/go-bip39" + util "github.com/wealdtech/go-eth2-util" + "golang.org/x/text/unicode/norm" +) + +// pathRegex is the regular expression that matches an HD path. +var pathRegex = regexp.MustCompile("^m/[0-9]+/[0-9]+(/[0-9+])+") + +func process(ctx context.Context, data *dataIn) (*dataOut, error) { + if data == nil { + return nil, errors.New("no data") + } + + // If there are more than 24 words we treat the additional characters as the passphrase. + mnemonicParts := strings.Split(data.mnemonic, " ") + mnemonicPassphrase := "" + if len(mnemonicParts) > 24 { + data.mnemonic = strings.Join(mnemonicParts[:24], " ") + mnemonicPassphrase = strings.Join(mnemonicParts[24:], " ") + } + // Normalise the input. + data.mnemonic = string(norm.NFKD.Bytes([]byte(data.mnemonic))) + mnemonicPassphrase = string(norm.NFKD.Bytes([]byte(mnemonicPassphrase))) + + if !bip39.IsMnemonicValid(data.mnemonic) { + return nil, errors.New("mnemonic is invalid") + } + + // Create seed from mnemonic and passphrase. + seed := bip39.NewSeed(data.mnemonic, mnemonicPassphrase) + + // Ensure the path is valid. + match := pathRegex.Match([]byte(data.path)) + if !match { + return nil, errors.New("path does not match expected format m/…") + } + + // Derive private key from seed and path. + key, err := util.PrivateKeyFromSeedAndPath(seed, data.path) + if err != nil { + return nil, errors.Wrap(err, "failed to generate key") + } + + results := &dataOut{ + showKey: data.showKey, + key: key, + } + + return results, nil +} diff --git a/cmd/account/derive/process_internal_test.go b/cmd/account/derive/process_internal_test.go new file mode 100644 index 0000000..1acd145 --- /dev/null +++ b/cmd/account/derive/process_internal_test.go @@ -0,0 +1,97 @@ +// Copyright © 2020 Weald Technology Trading +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package accountderive + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/wealdtech/ethdo/testutil" + e2types "github.com/wealdtech/go-eth2-types/v2" +) + +func TestProcess(t *testing.T) { + require.NoError(t, e2types.InitBLS()) + + tests := []struct { + name string + dataIn *dataIn + privKey []byte + err string + }{ + { + name: "Nil", + err: "no data", + }, + { + name: "MnemonicMissing", + dataIn: &dataIn{ + path: "m/12381/3600/0/0", + }, + err: "mnemonic is invalid", + }, + { + name: "MnemonicInvalid", + dataIn: &dataIn{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + path: "m/12381/3600/0/0", + }, + err: "mnemonic is invalid", + }, + { + name: "PathMissing", + dataIn: &dataIn{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + }, + err: "path does not match expected format m/…", + }, + { + name: "PathInvalid", + dataIn: &dataIn{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + path: "n/12381/3600/0/0", + }, + err: "path does not match expected format m/…", + }, + { + name: "Good", + dataIn: &dataIn{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + path: "m/12381/3600/0/0", + }, + privKey: testutil.HexToBytes("0x068dce0c90cb428ab37a74af0191eac49648035f1aaef077734b91e05985ec55"), + }, + { + name: "Extended", + dataIn: &dataIn{ + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art extended", + path: "m/12381/3600/0/0", + }, + privKey: testutil.HexToBytes("0x58c8b280ae035de0452797b52fb62555f27f78541ea2f04b23e7bb0fcd0fc2d6"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + res, err := process(context.Background(), test.dataIn) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.privKey, res.key.Marshal()) + } + }) + } +} diff --git a/cmd/account/derive/run.go b/cmd/account/derive/run.go new file mode 100644 index 0000000..2baaaa9 --- /dev/null +++ b/cmd/account/derive/run.go @@ -0,0 +1,49 @@ +// Copyright © 2020 Weald Technology Trading +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package accountderive + +import ( + "context" + + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +// Run runs the account create data command. +func Run(cmd *cobra.Command) (string, error) { + ctx := context.Background() + dataIn, err := input(ctx) + if err != nil { + return "", errors.Wrap(err, "failed to obtain input") + } + + // Further errors do not need a usage report. + cmd.SilenceUsage = true + + dataOut, err := process(ctx, dataIn) + if err != nil { + return "", errors.Wrap(err, "failed to process") + } + + if dataIn.quiet { + return "", nil + } + + results, err := output(ctx, dataOut) + if err != nil { + return "", errors.Wrap(err, "failed to obtain output") + } + + return results, nil +} diff --git a/cmd/accountderive.go b/cmd/accountderive.go new file mode 100644 index 0000000..7ab9075 --- /dev/null +++ b/cmd/accountderive.go @@ -0,0 +1,65 @@ +// Copyright © 2019, 2020 Weald Technology Trading +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + accountderive "github.com/wealdtech/ethdo/cmd/account/derive" +) + +var accountDeriveCmd = &cobra.Command{ + Use: "derive", + Short: "Derive an account", + Long: `Derive an account from a mnemonic and path. For example: + + ethdo account derive --mnemonic="..." --path="m/12381/3600/0/0" + +In quiet mode this will return 0 if the inputs can derive an account account, otherwise 1.`, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := accountderive.Run(cmd) + if err != nil { + return err + } + if viper.GetBool("quiet") { + return nil + } + if res != "" { + fmt.Println(res) + } + return nil + }, +} + +func init() { + accountCmd.AddCommand(accountDeriveCmd) + accountFlags(accountDeriveCmd) + accountDeriveCmd.Flags().String("mnemonic", "", "mnemonic from which to derive the HD seed") + accountDeriveCmd.Flags().String("path", "", "path from which to derive the account") + accountDeriveCmd.Flags().Bool("show-key", false, "show key as well as public key") +} + +func accountDeriveBindings() { + if err := viper.BindPFlag("mnemonic", accountDeriveCmd.Flags().Lookup("mnemonic")); err != nil { + panic(err) + } + if err := viper.BindPFlag("path", accountDeriveCmd.Flags().Lookup("path")); err != nil { + panic(err) + } + if err := viper.BindPFlag("show-key", accountDeriveCmd.Flags().Lookup("show-key")); err != nil { + panic(err) + } +} diff --git a/cmd/root.go b/cmd/root.go index 71b822e..deeb5c4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -65,6 +65,8 @@ func persistentPreRunE(cmd *cobra.Command, args []string) error { switch fmt.Sprintf("%s/%s", cmd.Parent().Name(), cmd.Name()) { case "account/create": accountCreateBindings() + case "account/derive": + accountDeriveBindings() case "account/import": accountImportBindings() case "attester/inclusion": diff --git a/docs/conversions.md b/docs/conversions.md index a5fe8b2..3a6dd39 100644 --- a/docs/conversions.md +++ b/docs/conversions.md @@ -6,7 +6,7 @@ Converting from mnemonics to keys can be confusing. Below are commands that all A seed is a 24-word phrase that is used as the start point of a process called hierarchical derivation. It can be used, in combination with a path, to generate any number of keys. -### I want to be able to create keys from the mnemonic +### I want to be able to create accounts from the mnemonic The first thing you need to do is to create a wallet. To do this run the command below with the following changes: @@ -18,9 +18,9 @@ The first thing you need to do is to create a wallet. To do this run the comman $ ethdo wallet create --type=hd --mnemonic='abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art' --wallet=Wallet --wallet-passphrase=secret ``` -### I want a specific public key. +### I want an account with a specific public key. -To create a specific public key you need to have both the mnemonic and the derivation path. A derivation path looks something like `m/12381/3600/0/0` and is used by `ethdo` to generate a specific private key (from which the public key is in turn derived). +To create an account with a specific public key you need to have both the mnemonic and the derivation path. A derivation path looks something like `m/12381/3600/0/0` and is used by `ethdo` to generate a specific private key (from which the public key is in turn derived). You should first create a wallet as per the previous step. To then create an account run the command below with the following changes: @@ -42,7 +42,7 @@ Path: m/12381/3600/0/0 This process can be repated for any number of paths by changing the `path` and providing a different account name each time. -### I want the private key. +### I want an account's private key. To obtain the private key of an account follow the steps above, then run: @@ -81,3 +81,11 @@ $ ethdo validator depositdata --withdrawalaccount=Wallet/Withdrawal_i_ --validat If you wish to be able to provide this information to the launchpad you can add `--launchpad` to the end of the command. If you wish to have this data for a particular test network you will need to supply the fork version with `--forkversion`. Details on the fork versions of various testnets can be found in the subdirectories of the [testnet site](https://github.com/goerli/medalla). + +### I want keys without creating wallets and accounts + +It is possible to derive keys directly from a mnemonic and path without going through the interim steps. Note that this will _not_ create accounts, and cannot be used to then sign data or requests. This may or not be desirable, depending on your requirements. + +``` +$ ethdo account derive --mnemonic='abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art' --path=m/12381/3600/0/0 +``` diff --git a/docs/usage.md b/docs/usage.md index 092028d..6e88e15 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -121,6 +121,20 @@ For distributed accounts you will also need to supply `--participants` and `--si ```sh $ ethdo account create --account="Personal wallet/Operations" --wallet-passphrase="my wallet secret" --passphrase="my account secret" ``` + +#### `derive` + +`ethdo account derive` provides the ability to derive an account's keys without creating either the wallet or the account. This allows users to quickly obtain or confirm keys without going through a relatively long process, and has the added security benefit of not writing any information to disk. Options for deriving the account include: + + - `mnemonic`: a pre-defined 24-word [BIP-39 seed phrase](https://en.bitcoin.it/wiki/Seed_phrase) to derive the account, along with an additional "seed extension" phrase if required supplied as the 25th word + - `path`: the HD path used to derive the account + - `show-key`: show the private key as well as the public key. **Warning** displaying private keys, especially those derived from seeds held on hardware wallets, can expose your Ether to risk of being stolen. Only use this option if you are sure you understand the risks involved + +```sh +$ ethdo account derive --mnemonic="abandon ... abandon art" --path="m/12381/3600/0/0" +Public key: 0x99b1f1d84d76185466d86c34bde1101316afddae76217aa86cd066979b19858c2c9d9e56eebc1e067ac54277a61790db +``` + #### `import` `ethdo account import` creates a new account by importing its private key. Options for creating the account include: