Merge pull request #685 from nspcc-dev/feature/wallet

cli: implement wallet import/export functionality, part of #26.
This commit is contained in:
Roman Khimov 2020-02-21 12:21:25 +03:00 committed by GitHub
commit ef31d0dd3c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 451 additions and 42 deletions

View file

@ -8,6 +8,8 @@ import (
"strings" "strings"
"syscall" "syscall"
"github.com/CityOfZion/neo-go/pkg/crypto/keys"
"github.com/CityOfZion/neo-go/pkg/encoding/address"
"github.com/CityOfZion/neo-go/pkg/wallet" "github.com/CityOfZion/neo-go/pkg/wallet"
"github.com/urfave/cli" "github.com/urfave/cli"
"golang.org/x/crypto/ssh/terminal" "golang.org/x/crypto/ssh/terminal"
@ -18,6 +20,21 @@ var (
errPhraseMismatch = errors.New("the entered pass-phrases do not match. Maybe you have misspelled them") errPhraseMismatch = errors.New("the entered pass-phrases do not match. Maybe you have misspelled them")
) )
var (
walletPathFlag = cli.StringFlag{
Name: "path, p",
Usage: "Target location of the wallet file.",
}
wifFlag = cli.StringFlag{
Name: "wif",
Usage: "WIF to import",
}
decryptFlag = cli.BoolFlag{
Name: "decrypt, d",
Usage: "Decrypt encrypted keys.",
}
)
// NewCommands returns 'wallet' command. // NewCommands returns 'wallet' command.
func NewCommands() []cli.Command { func NewCommands() []cli.Command {
return []cli.Command{{ return []cli.Command{{
@ -29,28 +46,69 @@ func NewCommands() []cli.Command {
Usage: "create a new wallet", Usage: "create a new wallet",
Action: createWallet, Action: createWallet,
Flags: []cli.Flag{ Flags: []cli.Flag{
cli.StringFlag{ walletPathFlag,
Name: "path, p",
Usage: "Target location of the wallet file.",
},
cli.BoolFlag{ cli.BoolFlag{
Name: "account, a", Name: "account, a",
Usage: "Create a new account", Usage: "Create a new account",
}, },
}, },
}, },
{
Name: "create-account",
Usage: "add an account to the existing wallet",
Action: addAccount,
Flags: []cli.Flag{
walletPathFlag,
},
},
{ {
Name: "dump", Name: "dump",
Usage: "check and dump an existing NEO wallet", Usage: "check and dump an existing NEO wallet",
Action: dumpWallet, Action: dumpWallet,
Flags: []cli.Flag{ Flags: []cli.Flag{
walletPathFlag,
decryptFlag,
},
},
{
Name: "export",
Usage: "export keys for address",
UsageText: "export --path <path> [--decrypt] [<address>]",
Action: exportKeys,
Flags: []cli.Flag{
walletPathFlag,
decryptFlag,
},
},
{
Name: "import",
Usage: "import WIF",
Action: importWallet,
Flags: []cli.Flag{
walletPathFlag,
wifFlag,
cli.StringFlag{ cli.StringFlag{
Name: "path, p", Name: "name, n",
Usage: "Target location of the wallet file.", Usage: "Optional account name",
}, },
cli.BoolFlag{ },
Name: "decrypt, d", },
Usage: "Decrypt encrypted keys.", {
Name: "import-multisig",
Usage: "import multisig contract",
UsageText: "import-multisig --path <path> --wif <wif> --min <n>" +
" [<pubkey1> [<pubkey2> [...]]]",
Action: importMultisig,
Flags: []cli.Flag{
walletPathFlag,
wifFlag,
cli.StringFlag{
Name: "name, n",
Usage: "Optional account name",
},
cli.IntFlag{
Name: "min, m",
Usage: "Minimal number of signatures",
}, },
}, },
}, },
@ -58,24 +116,152 @@ func NewCommands() []cli.Command {
}} }}
} }
func dumpWallet(ctx *cli.Context) error { func addAccount(ctx *cli.Context) error {
path := ctx.String("path") wall, err := openWallet(ctx.String("path"))
if len(path) == 0 { if err != nil {
return cli.NewExitError(errNoPath, 1) return cli.NewExitError(err, 1)
} }
wall, err := wallet.NewWalletFromFile(path)
defer wall.Close()
if err := createAccount(ctx, wall); err != nil {
return cli.NewExitError(err, 1)
}
return nil
}
func exportKeys(ctx *cli.Context) error {
wall, err := openWallet(ctx.String("path"))
if err != nil {
return cli.NewExitError(err, 1)
}
var addr string
decrypt := ctx.Bool("decrypt")
if ctx.NArg() == 0 && decrypt {
return cli.NewExitError(errors.New("address must be provided if '--decrypt' flag is used"), 1)
} else if ctx.NArg() > 0 {
// check address format just to catch possible typos
addr = ctx.Args().First()
_, err := address.StringToUint160(addr)
if err != nil {
return cli.NewExitError(fmt.Errorf("can't parse address: %v", err), 1)
}
}
var wifs []string
loop:
for _, a := range wall.Accounts {
if addr != "" && a.Address != addr {
continue
}
for i := range wifs {
if a.EncryptedWIF == wifs[i] {
continue loop
}
}
wifs = append(wifs, a.EncryptedWIF)
}
for _, wif := range wifs {
if decrypt {
pass, err := readPassword("Enter password > ")
if err != nil {
return cli.NewExitError(err, 1)
}
pk, err := keys.NEP2Decrypt(wif, pass)
if err != nil {
return cli.NewExitError(err, 1)
}
wif = pk.WIF()
}
fmt.Println(wif)
}
return nil
}
func importMultisig(ctx *cli.Context) error {
wall, err := openWallet(ctx.String("path"))
if err != nil {
return cli.NewExitError(err, 1)
}
defer wall.Close()
m := ctx.Int("min")
if ctx.NArg() < m {
return cli.NewExitError(errors.New("insufficient number of public keys"), 1)
}
args := []string(ctx.Args())
pubs := make([]*keys.PublicKey, len(args))
for i := range args {
pubs[i], err = keys.NewPublicKeyFromString(args[i])
if err != nil {
return cli.NewExitError(fmt.Errorf("can't decode public key %d: %v", i, err), 1)
}
}
acc, err := newAccountFromWIF(ctx.String("wif"))
if err != nil {
return cli.NewExitError(err, 1)
}
if err := acc.ConvertMultisig(m, pubs); err != nil {
return cli.NewExitError(err, 1)
}
if err := addAccountAndSave(wall, acc); err != nil {
return cli.NewExitError(err, 1)
}
return nil
}
func importWallet(ctx *cli.Context) error {
wall, err := openWallet(ctx.String("path"))
if err != nil {
return cli.NewExitError(err, 1)
}
defer wall.Close()
acc, err := newAccountFromWIF(ctx.String("wif"))
if err != nil {
return cli.NewExitError(err, 1)
}
acc.Label = ctx.String("name")
if err := addAccountAndSave(wall, acc); err != nil {
return cli.NewExitError(err, 1)
}
return nil
}
func dumpWallet(ctx *cli.Context) error {
wall, err := openWallet(ctx.String("path"))
if err != nil { if err != nil {
return cli.NewExitError(err, 1) return cli.NewExitError(err, 1)
} }
if ctx.Bool("decrypt") { if ctx.Bool("decrypt") {
fmt.Print("Wallet password: ") pass, err := readPassword("Enter wallet password > ")
pass, err := terminal.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {
return cli.NewExitError(err, 1) return cli.NewExitError(err, 1)
} }
for i := range wall.Accounts { for i := range wall.Accounts {
// Just testing the decryption here. // Just testing the decryption here.
err := wall.Accounts[i].Decrypt(string(pass)) err := wall.Accounts[i].Decrypt(pass)
if err != nil { if err != nil {
return cli.NewExitError(err, 1) return cli.NewExitError(err, 1)
} }
@ -109,34 +295,94 @@ func createWallet(ctx *cli.Context) error {
return nil return nil
} }
func createAccount(ctx *cli.Context, wall *wallet.Wallet) error { func readAccountInfo() (string, string, error) {
var (
rawName,
rawPhrase,
rawPhraseCheck []byte
)
buf := bufio.NewReader(os.Stdin) buf := bufio.NewReader(os.Stdin)
fmt.Print("Enter the name of the account > ") fmt.Print("Enter the name of the account > ")
rawName, _ = buf.ReadBytes('\n') rawName, _ := buf.ReadBytes('\n')
fmt.Print("Enter passphrase > ") phrase, err := readPassword("Enter passphrase > ")
rawPhrase, _ = terminal.ReadPassword(int(syscall.Stdin)) if err != nil {
fmt.Print("\nConfirm passphrase > ") return "", "", err
rawPhraseCheck, _ = terminal.ReadPassword(int(syscall.Stdin)) }
phraseCheck, err := readPassword("Confirm passphrase > ")
// Clean data if err != nil {
var ( return "", "", err
name = strings.TrimRight(string(rawName), "\n")
phrase = strings.TrimRight(string(rawPhrase), "\n")
phraseCheck = strings.TrimRight(string(rawPhraseCheck), "\n")
)
if phrase != phraseCheck {
return errPhraseMismatch
} }
if phrase != phraseCheck {
return "", "", errPhraseMismatch
}
name := strings.TrimRight(string(rawName), "\n")
return name, phrase, nil
}
func createAccount(ctx *cli.Context, wall *wallet.Wallet) error {
name, phrase, err := readAccountInfo()
if err != nil {
return err
}
return wall.CreateAccount(name, phrase) return wall.CreateAccount(name, phrase)
} }
func openWallet(path string) (*wallet.Wallet, error) {
if len(path) == 0 {
return nil, errNoPath
}
return wallet.NewWalletFromFile(path)
}
func newAccountFromWIF(wif string) (*wallet.Account, error) {
// note: NEP2 strings always have length of 58 even though
// base58 strings can have different lengths even if slice lengths are equal
if len(wif) == 58 {
pass, err := readPassword("Enter password > ")
if err != nil {
return nil, err
}
return wallet.NewAccountFromEncryptedWIF(wif, pass)
}
acc, err := wallet.NewAccountFromWIF(wif)
if err != nil {
return nil, err
}
fmt.Println("Provided WIF was unencrypted. Wallet can contain only encrypted keys.")
name, pass, err := readAccountInfo()
if err != nil {
return nil, err
}
acc.Label = name
if err := acc.Encrypt(pass); err != nil {
return nil, err
}
return acc, nil
}
func addAccountAndSave(w *wallet.Wallet, acc *wallet.Account) error {
for i := range w.Accounts {
if w.Accounts[i].Address == acc.Address {
return fmt.Errorf("address '%s' is already in wallet", acc.Address)
}
}
w.AddAccount(acc)
return w.Save()
}
func readPassword(prompt string) (string, error) {
fmt.Print(prompt)
rawPass, err := terminal.ReadPassword(syscall.Stdin)
fmt.Println()
if err != nil {
return "", err
}
return strings.TrimRight(string(rawPass), "\n"), nil
}
func fmtPrintWallet(wall *wallet.Wallet) { func fmtPrintWallet(wall *wallet.Wallet) {
b, _ := wall.JSON() b, _ := wall.JSON()
fmt.Println("") fmt.Println("")

View file

@ -2,6 +2,7 @@ package smartcontract
import ( import (
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"strconv" "strconv"
"strings" "strings"
@ -78,6 +79,22 @@ func (pt ParamType) MarshalJSON() ([]byte, error) {
return []byte(`"` + pt.String() + `"`), nil return []byte(`"` + pt.String() + `"`), nil
} }
// UnmarshalJSON implements json.Unmarshaler interface.
func (pt *ParamType) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
p, err := parseParamType(s)
if err != nil {
return err
}
*pt = p
return nil
}
// EncodeBinary implements io.Serializable interface. // EncodeBinary implements io.Serializable interface.
func (pt ParamType) EncodeBinary(w *io.BinWriter) { func (pt ParamType) EncodeBinary(w *io.BinWriter) {
w.WriteB(byte(pt)) w.WriteB(byte(pt))

View file

@ -1,12 +1,16 @@
package wallet package wallet
import ( import (
"bytes"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"github.com/CityOfZion/neo-go/pkg/crypto/hash" "github.com/CityOfZion/neo-go/pkg/crypto/hash"
"github.com/CityOfZion/neo-go/pkg/crypto/keys" "github.com/CityOfZion/neo-go/pkg/crypto/keys"
"github.com/CityOfZion/neo-go/pkg/encoding/address"
"github.com/CityOfZion/neo-go/pkg/smartcontract"
"github.com/CityOfZion/neo-go/pkg/util" "github.com/CityOfZion/neo-go/pkg/util"
) )
@ -50,7 +54,7 @@ type Contract struct {
Script []byte `json:"script"` Script []byte `json:"script"`
// A list of parameters used deploying this contract. // A list of parameters used deploying this contract.
Parameters []interface{} `json:"parameters"` Parameters []contractParam `json:"parameters"`
// Indicates whether the contract has been deployed to the blockchain. // Indicates whether the contract has been deployed to the blockchain.
Deployed bool `json:"deployed"` Deployed bool `json:"deployed"`
@ -62,12 +66,17 @@ type contract struct {
Script string `json:"script"` Script string `json:"script"`
// A list of parameters used deploying this contract. // A list of parameters used deploying this contract.
Parameters []interface{} `json:"parameters"` Parameters []contractParam `json:"parameters"`
// Indicates whether the contract has been deployed to the blockchain. // Indicates whether the contract has been deployed to the blockchain.
Deployed bool `json:"deployed"` Deployed bool `json:"deployed"`
} }
type contractParam struct {
Name string `json:"name"`
Type smartcontract.ParamType `json:"type"`
}
// ScriptHash returns the hash of contract's script. // ScriptHash returns the hash of contract's script.
func (c Contract) ScriptHash() util.Uint160 { func (c Contract) ScriptHash() util.Uint160 {
return hash.Hash160(c.Script) return hash.Hash160(c.Script)
@ -122,7 +131,14 @@ func (a *Account) Decrypt(passphrase string) error {
return errors.New("no encrypted wif in the account") return errors.New("no encrypted wif in the account")
} }
a.privateKey, err = keys.NEP2Decrypt(a.EncryptedWIF, passphrase) a.privateKey, err = keys.NEP2Decrypt(a.EncryptedWIF, passphrase)
return err if err != nil {
return err
}
a.publicKey = a.privateKey.PublicKey().Bytes()
a.wif = a.privateKey.WIF()
return nil
} }
// Encrypt encrypts the wallet's PrivateKey with the given passphrase // Encrypt encrypts the wallet's PrivateKey with the given passphrase
@ -150,6 +166,47 @@ func NewAccountFromWIF(wif string) (*Account, error) {
return newAccountFromPrivateKey(privKey), nil return newAccountFromPrivateKey(privKey), nil
} }
// NewAccountFromEncryptedWIF creates a new Account from the given encrypted WIF.
func NewAccountFromEncryptedWIF(wif string, pass string) (*Account, error) {
priv, err := keys.NEP2Decrypt(wif, pass)
if err != nil {
return nil, err
}
a := newAccountFromPrivateKey(priv)
a.EncryptedWIF = wif
return a, nil
}
// ConvertMultisig sets a's contract to multisig contract with m sufficient signatures.
func (a *Account) ConvertMultisig(m int, pubs []*keys.PublicKey) error {
var found bool
for i := range pubs {
if bytes.Equal(a.publicKey, pubs[i].Bytes()) {
found = true
break
}
}
if !found {
return errors.New("own public key was not found among multisig keys")
}
script, err := smartcontract.CreateMultiSigRedeemScript(m, pubs)
if err != nil {
return err
}
a.Address = address.Uint160ToString(hash.Hash160(script))
a.Contract = &Contract{
Script: script,
Parameters: getContractParams(m),
}
return nil
}
// newAccountFromPrivateKey creates a wallet from the given PrivateKey. // newAccountFromPrivateKey creates a wallet from the given PrivateKey.
func newAccountFromPrivateKey(p *keys.PrivateKey) *Account { func newAccountFromPrivateKey(p *keys.PrivateKey) *Account {
pubKey := p.PublicKey() pubKey := p.PublicKey()
@ -161,7 +218,21 @@ func newAccountFromPrivateKey(p *keys.PrivateKey) *Account {
privateKey: p, privateKey: p,
Address: pubAddr, Address: pubAddr,
wif: wif, wif: wif,
Contract: &Contract{
Script: pubKey.GetVerificationScript(),
Parameters: getContractParams(1),
},
} }
return a return a
} }
func getContractParams(n int) []contractParam {
params := make([]contractParam, n)
for i := range params {
params[i].Name = fmt.Sprintf("parameter%d", i)
params[i].Type = smartcontract.SignatureType
}
return params
}

View file

@ -6,6 +6,7 @@ import (
"testing" "testing"
"github.com/CityOfZion/neo-go/pkg/crypto/hash" "github.com/CityOfZion/neo-go/pkg/crypto/hash"
"github.com/CityOfZion/neo-go/pkg/crypto/keys"
"github.com/CityOfZion/neo-go/pkg/internal/keytestcases" "github.com/CityOfZion/neo-go/pkg/internal/keytestcases"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -49,10 +50,23 @@ func TestNewFromWif(t *testing.T) {
} }
} }
func TestNewAccountFromEncryptedWIF(t *testing.T) {
for _, tc := range keytestcases.Arr {
acc, err := NewAccountFromEncryptedWIF(tc.EncryptedWif, tc.Passphrase)
if tc.Invalid {
assert.Error(t, err)
continue
}
assert.NoError(t, err)
compareFields(t, tc, acc)
}
}
func TestContract_MarshalJSON(t *testing.T) { func TestContract_MarshalJSON(t *testing.T) {
var c Contract var c Contract
data := []byte(`{"script":"0102","parameters":[1],"deployed":false}`) data := []byte(`{"script":"0102","parameters":[{"name":"name0", "type":"Signature"}],"deployed":false}`)
require.NoError(t, json.Unmarshal(data, &c)) require.NoError(t, json.Unmarshal(data, &c))
require.Equal(t, []byte{1, 2}, c.Script) require.Equal(t, []byte{1, 2}, c.Script)
@ -74,6 +88,51 @@ func TestContract_ScriptHash(t *testing.T) {
require.Equal(t, hash.Hash160(script), c.ScriptHash()) require.Equal(t, hash.Hash160(script), c.ScriptHash())
} }
func TestAccount_ConvertMultisig(t *testing.T) {
// test is based on a wallet1_solo.json accounts from neo-local
a, err := NewAccountFromEncryptedWIF("6PYLmjBYJ4wQTCEfqvnznGJwZeW9pfUcV5m5oreHxqryUgqKpTRAFt9L8Y", "one")
require.NoError(t, err)
hexs := []string{
"02b3622bf4017bdfe317c58aed5f4c753f206b7db896046fa7d774bbc4bf7f8dc2", // <- this is our key
"02103a7f7dd016558597f7960d27c516a4394fd968b9e65155eb4b013e4040406e",
"02a7bc55fe8684e0119768d104ba30795bdcc86619e864add26156723ed185cd62",
"03d90c07df63e690ce77912e10ab51acc944b66860237b608c4f8f8309e71ee699",
}
t.Run("invalid number of signatures", func(t *testing.T) {
pubs := convertPubs(t, hexs)
require.Error(t, a.ConvertMultisig(0, pubs))
})
t.Run("account key is missing from multisig", func(t *testing.T) {
pubs := convertPubs(t, hexs[1:])
require.Error(t, a.ConvertMultisig(1, pubs))
})
t.Run("1/1 multisig", func(t *testing.T) {
pubs := convertPubs(t, hexs[:1])
require.NoError(t, a.ConvertMultisig(1, pubs))
require.Equal(t, "AbU69m8WUZJSWanfr1Cy66cpEcsmMcX7BR", a.Address)
})
t.Run("3/4 multisig", func(t *testing.T) {
pubs := convertPubs(t, hexs)
require.NoError(t, a.ConvertMultisig(3, pubs))
require.Equal(t, "AZ81H31DMWzbSnFDLFkzh9vHwaDLayV7fU", a.Address)
})
}
func convertPubs(t *testing.T, hexKeys []string) []*keys.PublicKey {
pubs := make([]*keys.PublicKey, len(hexKeys))
for i := range pubs {
var err error
pubs[i], err = keys.NewPublicKeyFromString(hexKeys[i])
require.NoError(t, err)
}
return pubs
}
func compareFields(t *testing.T, tk keytestcases.Ktype, acc *Account) { func compareFields(t *testing.T, tk keytestcases.Ktype, acc *Account) {
if want, have := tk.Address, acc.Address; want != have { if want, have := tk.Address, acc.Address; want != have {
t.Fatalf("expected %s got %s", want, have) t.Fatalf("expected %s got %s", want, have)

View file

@ -105,6 +105,11 @@ func (w *Wallet) Path() string {
// that is responsible for saving the data. This can // that is responsible for saving the data. This can
// be a buffer, file, etc.. // be a buffer, file, etc..
func (w *Wallet) Save() error { func (w *Wallet) Save() error {
if s, ok := w.rw.(io.Seeker); ok {
if _, err := s.Seek(0, 0); err != nil {
return err
}
}
return json.NewEncoder(w.rw).Encode(w) return json.NewEncoder(w.rw).Encode(w)
} }

View file

@ -90,6 +90,17 @@ func TestSave(t *testing.T) {
openedWallet, err := NewWalletFromFile(wallet.path) openedWallet, err := NewWalletFromFile(wallet.path)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, wallet.Accounts, openedWallet.Accounts) require.Equal(t, wallet.Accounts, openedWallet.Accounts)
t.Run("change and rewrite", func(t *testing.T) {
err := openedWallet.CreateAccount("test", "pass")
require.NoError(t, err)
w2, err := NewWalletFromFile(openedWallet.path)
require.NoError(t, err)
require.Equal(t, 2, len(w2.Accounts))
require.NoError(t, w2.Accounts[1].Decrypt("pass"))
require.Equal(t, openedWallet.Accounts, w2.Accounts)
})
} }
func TestJSONMarshallUnmarshal(t *testing.T) { func TestJSONMarshallUnmarshal(t *testing.T) {