diff --git a/cli/testdata/wallets/testwallet_NEO2.json b/cli/testdata/wallets/testwallet_NEO2.json new file mode 100644 index 000000000..d03726fd9 --- /dev/null +++ b/cli/testdata/wallets/testwallet_NEO2.json @@ -0,0 +1 @@ +{"name":"wallet1","version":"1.0","scrypt":{"n":16384,"r":8,"p":8},"accounts":[{"address":"AKkkumHbBipZ46UMZJoFynJMXzSRnBvKcs","label":null,"isDefault":false,"lock":false,"key":"6PYLmjBYJ4wQTCEfqvnznGJwZeW9pfUcV5m5oreHxqryUgqKpTRAFt9L8Y","contract":{"script":"2102b3622bf4017bdfe317c58aed5f4c753f206b7db896046fa7d774bbc4bf7f8dc2ac","parameters":[{"name":"parameter0","type":"Signature"}],"deployed":false},"extra":null},{"address":"AZ81H31DMWzbSnFDLFkzh9vHwaDLayV7fU","label":null,"isDefault":false,"lock":false,"key":"6PYLmjBYJ4wQTCEfqvnznGJwZeW9pfUcV5m5oreHxqryUgqKpTRAFt9L8Y","contract":{"script":"532102103a7f7dd016558597f7960d27c516a4394fd968b9e65155eb4b013e4040406e2102a7bc55fe8684e0119768d104ba30795bdcc86619e864add26156723ed185cd622102b3622bf4017bdfe317c58aed5f4c753f206b7db896046fa7d774bbc4bf7f8dc22103d90c07df63e690ce77912e10ab51acc944b66860237b608c4f8f8309e71ee69954ae","parameters":[{"name":"parameter0","type":"Signature"},{"name":"parameter1","type":"Signature"},{"name":"parameter2","type":"Signature"}],"deployed":false},"extra":null}],"extra":null} \ No newline at end of file diff --git a/cli/testdata/wallets/testwallet_NEO3.json b/cli/testdata/wallets/testwallet_NEO3.json new file mode 100644 index 000000000..27a48a3ed --- /dev/null +++ b/cli/testdata/wallets/testwallet_NEO3.json @@ -0,0 +1,55 @@ +{ + "version": "3.0", + "accounts": [ + { + "address": "NbTiM6h8r99kpRtb428XcsUk1TzKed2gTc", + "key": "6PYN7LvaWqBNw7Xb7a52LSbPnP91kyuzYi3HncGvQwQoYAY2W8DncTgpux", + "label": "", + "contract": { + "script": "DCECs2Ir9AF73+MXxYrtX0x1PyBrfbiWBG+n13S7xL9/jcILQZVEDXg=", + "parameters": [ + { + "name": "parameter0", + "type": "Signature" + } + ], + "deployed": false + }, + "lock": false, + "isdefault": false + }, + { + "address": "NUVPACMnKFhpuHjsRjhUvXz1XhqfGZYVtY", + "key": "6PYN7LvaWqBNw7Xb7a52LSbPnP91kyuzYi3HncGvQwQoYAY2W8DncTgpux", + "label": "", + "contract": { + "script": "EwwhAhA6f33QFlWFl/eWDSfFFqQ5T9loueZRVetLAT5AQEBuDCECp7xV/oaE4BGXaNEEujB5W9zIZhnoZK3SYVZyPtGFzWIMIQKzYiv0AXvf4xfFiu1fTHU/IGt9uJYEb6fXdLvEv3+NwgwhA9kMB99j5pDOd5EuEKtRrMlEtmhgI3tgjE+PgwnnHuaZFAtBE43vrw==", + "parameters": [ + { + "name": "parameter0", + "type": "Signature" + }, + { + "name": "parameter1", + "type": "Signature" + }, + { + "name": "parameter2", + "type": "Signature" + } + ], + "deployed": false + }, + "lock": false, + "isdefault": false + } + ], + "scrypt": { + "n": 16384, + "r": 8, + "p": 8 + }, + "extra": { + "Tokens": null + } +} diff --git a/cli/wallet/legacy.go b/cli/wallet/legacy.go new file mode 100644 index 000000000..163353513 --- /dev/null +++ b/cli/wallet/legacy.go @@ -0,0 +1,165 @@ +package wallet + +import ( + "crypto/elliptic" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "os" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neo-go/pkg/encoding/address" + "github.com/nspcc-dev/neo-go/pkg/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/wallet" +) + +type ( + walletV2 struct { + Version string `json:"version"` + Accounts []accountV2 `json:"accounts"` + Scrypt keys.ScryptParams `json:"scrypt"` + Extra wallet.Extra `json:"extra"` + } + accountV2 struct { + Address string `json:"address"` + EncryptedWIF string `json:"key"` + Label string `json:"label"` + Contract *struct { + Script string `json:"script"` + Parameters []wallet.ContractParam `json:"parameters"` + Deployed bool `json:"deployed"` + } `json:"contract"` + Locked bool `json:"lock"` + Default bool `json:"isdefault"` + } +) + +// newWalletV2FromFile reads NEO2 wallet from file. +// This should be used read-only, no operations are supported on returned wallet. +func newWalletV2FromFile(path string) (*walletV2, error) { + file, err := os.OpenFile(path, os.O_RDWR, os.ModeAppend) + if err != nil { + return nil, err + } + defer file.Close() + + wall := new(walletV2) + return wall, json.NewDecoder(file).Decode(wall) +} + +const simpleSigLen = 35 + +func (a *accountV2) convert(pass string) (*wallet.Account, error) { + address.Prefix = address.NEO2Prefix + priv, err := keys.NEP2Decrypt(a.EncryptedWIF, pass) + if err != nil { + return nil, err + } + + address.Prefix = address.NEO3Prefix + newAcc, err := wallet.NewAccountFromWIF(priv.WIF()) + if err != nil { + return nil, err + } + if a.Contract != nil { + script, err := hex.DecodeString(a.Contract.Script) + if err != nil { + return nil, err + } + // If it is simple signature script, newAcc does already have it. + if len(script) != simpleSigLen { + nsigs, pubs, ok := parseMultisigContract(script) + if !ok { + return nil, errors.New("invalid multisig contract") + } + script, err := smartcontract.CreateMultiSigRedeemScript(nsigs, pubs) + if err != nil { + return nil, errors.New("can't create new multisig contract") + } + newAcc.Contract.Script = script + newAcc.Contract.Parameters = a.Contract.Parameters + newAcc.Contract.Deployed = a.Contract.Deployed + } + } + newAcc.Address = address.Uint160ToString(newAcc.Contract.ScriptHash()) + newAcc.Default = a.Default + newAcc.Label = a.Label + newAcc.Locked = a.Locked + return newAcc, newAcc.Encrypt(pass) +} + +const ( + opPush1 = 0x51 + opPush16 = 0x60 + opPushBytes1 = 0x01 + opPushBytes2 = 0x02 + opPushBytes33 = 0x21 + opCheckMultisig = 0xAE + opRet = 0x66 +) + +func getNumOfThingsFromInstr(script []byte) (int, int, bool) { + var op = script[0] + switch { + case opPush1 <= op && op <= opPush16: + return int(op-opPush1) + 1, 1, true + case op == opPushBytes1 && len(script) >= 2: + return int(script[1]), 2, true + case op == opPushBytes2 && len(script) >= 3: + return int(binary.LittleEndian.Uint16(script[1:])), 3, true + default: + return 0, 0, false + } +} + +const minMultisigLen = 37 + +// parseMultisigContract accepts multisig verification script from NEO2 +// and returns list of public keys in the same order as in script.. +func parseMultisigContract(script []byte) (int, keys.PublicKeys, bool) { + // It should contain at least 1 public key. + if len(script) < minMultisigLen { + return 0, nil, false + } + + nsigs, offset, ok := getNumOfThingsFromInstr(script) + if !ok { + return 0, nil, false + } + var pubs [][]byte + var nkeys int + for offset < len(script) && script[offset] == opPushBytes33 { + if len(script[offset:]) < 34 { + return 0, nil, false + } + pubs = append(pubs, script[offset+1:offset+34]) + nkeys++ + offset += 34 + } + if nkeys < nsigs || offset >= len(script) { + return 0, nil, false + } + nkeys2, off, ok := getNumOfThingsFromInstr(script[offset:]) + if !ok || nkeys2 != nkeys { + return 0, nil, false + } + end := script[offset+off:] + switch { + case len(end) == 1 && end[0] == opCheckMultisig: + case len(end) == 2 && end[0] == opCheckMultisig && end[1] == opRet: + default: + return 0, nil, false + } + + ret := make(keys.PublicKeys, len(pubs)) + for i := range pubs { + pub, err := keys.NewPublicKeyFromBytes(pubs[i], elliptic.P256()) + if err != nil { + return 0, nil, false + } + ret[i] = pub + + } + return nsigs, ret, true +} diff --git a/cli/wallet/legacy_test.go b/cli/wallet/legacy_test.go new file mode 100644 index 000000000..af952fff5 --- /dev/null +++ b/cli/wallet/legacy_test.go @@ -0,0 +1,111 @@ +package wallet + +import ( + "crypto/elliptic" + "encoding/hex" + "testing" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/stretchr/testify/require" +) + +func testParseMultisigContract(t *testing.T, s []byte, nsigs int, keys ...*keys.PublicKey) { + ns, ks, ok := parseMultisigContract(s) + if len(keys) == 0 { + require.False(t, ok) + return + } + require.True(t, ok) + require.Equal(t, nsigs, ns) + require.Equal(t, len(keys), len(ks)) + for i := range keys { + require.Equal(t, keys[i], ks[i]) + } +} + +func TestParseMultisigContract(t *testing.T) { + t.Run("single multisig", func(t *testing.T) { + s := fromHex(t, "512102b3622bf4017bdfe317c58aed5f4c753f206b7db896046fa7d774bbc4bf7f8dc251ae") + pub := pubFromHex(t, "02b3622bf4017bdfe317c58aed5f4c753f206b7db896046fa7d774bbc4bf7f8dc2") + t.Run("good, no ret", func(t *testing.T) { + testParseMultisigContract(t, s, 1, pub) + }) + t.Run("good, with ret", func(t *testing.T) { + s := append(s, opRet) + testParseMultisigContract(t, s, 1, pub) + }) + t.Run("bad, no check multisig", func(t *testing.T) { + sBad := make([]byte, len(s)) + copy(sBad, s) + sBad[len(sBad)-1] ^= 0xFF + testParseMultisigContract(t, sBad, 0) + }) + t.Run("bad, invalid number of keys", func(t *testing.T) { + sBad := make([]byte, len(s)) + copy(sBad, s) + sBad[len(sBad)-2] = opPush1 + 1 + testParseMultisigContract(t, sBad, 0) + }) + t.Run("bad, invalid first instruction", func(t *testing.T) { + sBad := make([]byte, len(s)) + copy(sBad, s) + sBad[0] = 0xFF + testParseMultisigContract(t, sBad, 0) + }) + t.Run("bad, invalid public key", func(t *testing.T) { + sBad := make([]byte, len(s)) + copy(sBad, s) + sBad[2] = 0xFF + testParseMultisigContract(t, sBad, 0) + }) + t.Run("bad, many sigs", func(t *testing.T) { + sBad := make([]byte, len(s)) + copy(sBad, s) + sBad[0] = opPush1 + 1 + testParseMultisigContract(t, sBad, 0) + }) + t.Run("empty, no panic", func(t *testing.T) { + testParseMultisigContract(t, []byte{}, 0) + }) + }) + t.Run("3/4 multisig", func(t *testing.T) { + // From privnet consensus wallet. + s := fromHex(t, "532102103a7f7dd016558597f7960d27c516a4394fd968b9e65155eb4b013e4040406e2102a7bc55fe8684e0119768d104ba30795bdcc86619e864add26156723ed185cd622102b3622bf4017bdfe317c58aed5f4c753f206b7db896046fa7d774bbc4bf7f8dc22103d90c07df63e690ce77912e10ab51acc944b66860237b608c4f8f8309e71ee69954ae") + ks := keys.PublicKeys{ + pubFromHex(t, "02103a7f7dd016558597f7960d27c516a4394fd968b9e65155eb4b013e4040406e"), + pubFromHex(t, "02a7bc55fe8684e0119768d104ba30795bdcc86619e864add26156723ed185cd62"), + pubFromHex(t, "02b3622bf4017bdfe317c58aed5f4c753f206b7db896046fa7d774bbc4bf7f8dc2"), + pubFromHex(t, "03d90c07df63e690ce77912e10ab51acc944b66860237b608c4f8f8309e71ee699"), + } + t.Run("good", func(t *testing.T) { + testParseMultisigContract(t, s, 3, ks...) + }) + t.Run("good, with pushbytes1", func(t *testing.T) { + s := append([]byte{opPushBytes1, 3}, s[1:]...) + testParseMultisigContract(t, s, 3, ks...) + }) + t.Run("good, with pushbytes2", func(t *testing.T) { + s := append([]byte{opPushBytes2, 3, 0}, s[1:]...) + testParseMultisigContract(t, s, 3, ks...) + }) + t.Run("bad, no panic on prefix", func(t *testing.T) { + for i := minMultisigLen; i < len(s)-1; i++ { + testParseMultisigContract(t, s[:i], 0) + } + }) + }) + +} + +func fromHex(t *testing.T, s string) []byte { + bs, err := hex.DecodeString(s) + require.NoError(t, err) + return bs +} + +func pubFromHex(t *testing.T, s string) *keys.PublicKey { + bs := fromHex(t, s) + pub, err := keys.NewPublicKeyFromBytes(bs, elliptic.P256()) + require.NoError(t, err) + return pub +} diff --git a/cli/wallet/wallet.go b/cli/wallet/wallet.go index 6ab5eaa38..31ce122d4 100644 --- a/cli/wallet/wallet.go +++ b/cli/wallet/wallet.go @@ -247,40 +247,25 @@ func claimGas(ctx *cli.Context) error { } func convertWallet(ctx *cli.Context) error { - wall, err := openWallet(ctx.String("wallet")) + wall, err := newWalletV2FromFile(ctx.String("wallet")) if err != nil { return cli.NewExitError(err, 1) } - defer wall.Close() newWallet, err := wallet.NewWallet(ctx.String("out")) if err != nil { - return cli.NewExitError(err, -1) + return cli.NewExitError(err, 1) } defer newWallet.Close() for _, acc := range wall.Accounts { - address.Prefix = address.NEO2Prefix - pass, err := input.ReadPassword(ctx.App.Writer, fmt.Sprintf("Enter passphrase for account %s (label '%s') > ", acc.Address, acc.Label)) if err != nil { - return cli.NewExitError(err, -1) - } else if err := acc.Decrypt(pass); err != nil { - return cli.NewExitError("invalid passphrase", -1) + return cli.NewExitError(err, 1) } - - address.Prefix = address.NEO3Prefix - newAcc, err := wallet.NewAccountFromWIF(acc.PrivateKey().WIF()) + newAcc, err := acc.convert(pass) if err != nil { - return cli.NewExitError(fmt.Errorf("can't convert account: %w", err), -1) - } - newAcc.Address = address.Uint160ToString(acc.Contract.ScriptHash()) - newAcc.Contract = acc.Contract - newAcc.Default = acc.Default - newAcc.Label = acc.Label - newAcc.Locked = acc.Locked - if err := newAcc.Encrypt(pass); err != nil { - return cli.NewExitError(fmt.Errorf("can't encrypt converted account: %w", err), -1) + return cli.NewExitError(err, 1) } newWallet.AddAccount(newAcc) } diff --git a/cli/wallet_test.go b/cli/wallet_test.go index e425e247f..986adf4ac 100644 --- a/cli/wallet_test.go +++ b/cli/wallet_test.go @@ -310,3 +310,49 @@ func TestWalletDump(t *testing.T) { require.Equal(t, "NNuJqXDnRqvwgzhSzhH4jnVFWB1DyZ34EM", w.Accounts[0].Address) }) } + +// Testcase is the wallet of privnet validator. +func TestWalletConvert(t *testing.T) { + tmpDir := path.Join(os.TempDir(), "neogo.test.convert") + require.NoError(t, os.Mkdir(tmpDir, os.ModePerm)) + defer os.RemoveAll(tmpDir) + + e := newExecutor(t, false) + defer e.Close(t) + + outPath := path.Join(tmpDir, "wallet.json") + cmd := []string{"neo-go", "wallet", "convert"} + t.Run("missing wallet", func(t *testing.T) { + e.RunWithError(t, cmd...) + }) + + cmd = append(cmd, "--wallet", "testdata/wallets/testwallet_NEO2.json", "--out", outPath) + t.Run("invalid password", func(t *testing.T) { + // missing password + e.RunWithError(t, cmd...) + // invalid password + e.In.WriteString("two\r") + e.RunWithError(t, cmd...) + }) + + // 2 accounts. + e.In.WriteString("one\r") + e.In.WriteString("one\r") + e.Run(t, "neo-go", "wallet", "convert", + "--wallet", "testdata/wallets/testwallet_NEO2.json", + "--out", outPath) + + actual, err := wallet.NewWalletFromFile(outPath) + require.NoError(t, err) + expected, err := wallet.NewWalletFromFile("testdata/wallets/testwallet_NEO3.json") + require.NoError(t, err) + require.Equal(t, len(actual.Accounts), len(expected.Accounts)) + for _, exp := range expected.Accounts { + addr, err := address.StringToUint160(exp.Address) + require.NoError(t, err) + + act := actual.GetAccount(addr) + require.NotNil(t, act) + require.Equal(t, exp, act) + } +}