Add "account derive" command

This commit is contained in:
Jim McDonald
2020-11-20 19:59:49 +00:00
parent b2360fa2f6
commit 9c08c0a1a4
12 changed files with 569 additions and 5 deletions

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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())
}
})
}
}

49
cmd/account/derive/run.go Normal file
View File

@@ -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
}

65
cmd/accountderive.go Normal file
View File

@@ -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)
}
}

View File

@@ -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":

View File

@@ -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
```

View File

@@ -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: