From b88ac30027be392d71245f6f683ac44324a08fcd Mon Sep 17 00:00:00 2001 From: buddh0 Date: Thu, 27 Feb 2025 18:30:24 +0800 Subject: [PATCH] Revert "all: remove `personal` RPC namespace (#30704)" This reverts commit f3b4bbbaf3db60d0f7f1163cca4aad5cf41ff499. --- cmd/geth/main.go | 2 +- cmd/utils/flags.go | 8 +- cmd/utils/flags_legacy.go | 6 - console/bridge.go | 265 ++++++++++++++++++++++++++++ console/bridge_test.go | 48 +++++ console/console.go | 25 +++ go.mod | 2 +- internal/ethapi/api.go | 341 ++++++++++++++++++++++++++++++++++++ internal/ethapi/backend.go | 3 + internal/web3ext/web3ext.go | 75 +++++++- node/node.go | 16 +- signer/core/signed_data.go | 2 +- 12 files changed, 772 insertions(+), 21 deletions(-) create mode 100644 console/bridge_test.go diff --git a/cmd/geth/main.go b/cmd/geth/main.go index e8a84b6f1b..d7beb60816 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -83,7 +83,7 @@ var ( utils.OverrideDefaultExtraReserveForBlobRequests, utils.OverrideBreatheBlockInterval, utils.OverrideFixedTurnLength, - utils.EnablePersonal, // deprecated + utils.EnablePersonal, utils.TxPoolLocalsFlag, utils.TxPoolNoLocalsFlag, utils.TxPoolJournalFlag, diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index 9feee1c293..b961a59e11 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -893,6 +893,11 @@ var ( Value: node.DefaultConfig.BatchResponseMaxSize, Category: flags.APICategory, } + EnablePersonal = &cli.BoolFlag{ + Name: "rpc.enabledeprecatedpersonal", + Usage: "Enables the (deprecated) personal namespace", + Category: flags.APICategory, + } // Network Settings MaxPeersFlag = &cli.IntFlag{ @@ -1678,8 +1683,9 @@ func SetNodeConfig(ctx *cli.Context, cfg *node.Config) { if ctx.IsSet(JWTSecretFlag.Name) { cfg.JWTSecret = ctx.String(JWTSecretFlag.Name) } + if ctx.IsSet(EnablePersonal.Name) { - log.Warn(fmt.Sprintf("Option --%s is deprecated. The 'personal' RPC namespace has been removed.", EnablePersonal.Name)) + cfg.EnablePersonal = true } if ctx.IsSet(ExternalSignerFlag.Name) { diff --git a/cmd/utils/flags_legacy.go b/cmd/utils/flags_legacy.go index f1a91515b7..eb86fa92fc 100644 --- a/cmd/utils/flags_legacy.go +++ b/cmd/utils/flags_legacy.go @@ -141,12 +141,6 @@ var ( Usage: "Enable expensive metrics collection and reporting (deprecated)", Category: flags.DeprecatedCategory, } - // Deprecated Oct 2024 - EnablePersonal = &cli.BoolFlag{ - Name: "rpc.enabledeprecatedpersonal", - Usage: "This used to enable the 'personal' namespace.", - Category: flags.DeprecatedCategory, - } ) // showDeprecated displays deprecated flags that will be soon removed from the codebase. diff --git a/console/bridge.go b/console/bridge.go index c1d7746c02..37578041ca 100644 --- a/console/bridge.go +++ b/console/bridge.go @@ -19,12 +19,15 @@ package console import ( "encoding/json" "errors" + "fmt" "io" "reflect" "strings" "time" "github.com/dop251/goja" + "github.com/ethereum/go-ethereum/accounts/scwallet" + "github.com/ethereum/go-ethereum/accounts/usbwallet" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/console/prompt" "github.com/ethereum/go-ethereum/internal/jsre" @@ -48,6 +51,268 @@ func newBridge(client *rpc.Client, prompter prompt.UserPrompter, printer io.Writ } } +func getJeth(vm *goja.Runtime) *goja.Object { + jeth := vm.Get("jeth") + if jeth == nil { + panic(vm.ToValue("jeth object does not exist")) + } + return jeth.ToObject(vm) +} + +// NewAccount is a wrapper around the personal.newAccount RPC method that uses a +// non-echoing password prompt to acquire the passphrase and executes the original +// RPC method (saved in jeth.newAccount) with it to actually execute the RPC call. +func (b *bridge) NewAccount(call jsre.Call) (goja.Value, error) { + var ( + password string + confirm string + err error + ) + switch { + // No password was specified, prompt the user for it + case len(call.Arguments) == 0: + if password, err = b.prompter.PromptPassword("Passphrase: "); err != nil { + return nil, err + } + if confirm, err = b.prompter.PromptPassword("Repeat passphrase: "); err != nil { + return nil, err + } + if password != confirm { + return nil, errors.New("passwords don't match") + } + // A single string password was specified, use that + case len(call.Arguments) == 1 && call.Argument(0).ToString() != nil: + password = call.Argument(0).ToString().String() + default: + return nil, errors.New("expected 0 or 1 string argument") + } + // Password acquired, execute the call and return + newAccount, callable := goja.AssertFunction(getJeth(call.VM).Get("newAccount")) + if !callable { + return nil, errors.New("jeth.newAccount is not callable") + } + ret, err := newAccount(goja.Null(), call.VM.ToValue(password)) + if err != nil { + return nil, err + } + return ret, nil +} + +// OpenWallet is a wrapper around personal.openWallet which can interpret and +// react to certain error messages, such as the Trezor PIN matrix request. +func (b *bridge) OpenWallet(call jsre.Call) (goja.Value, error) { + // Make sure we have a wallet specified to open + if call.Argument(0).ToObject(call.VM).ClassName() != "String" { + return nil, errors.New("first argument must be the wallet URL to open") + } + wallet := call.Argument(0) + + var passwd goja.Value + if goja.IsUndefined(call.Argument(1)) || goja.IsNull(call.Argument(1)) { + passwd = call.VM.ToValue("") + } else { + passwd = call.Argument(1) + } + // Open the wallet and return if successful in itself + openWallet, callable := goja.AssertFunction(getJeth(call.VM).Get("openWallet")) + if !callable { + return nil, errors.New("jeth.openWallet is not callable") + } + val, err := openWallet(goja.Null(), wallet, passwd) + if err == nil { + return val, nil + } + + // Wallet open failed, report error unless it's a PIN or PUK entry + switch { + case strings.HasSuffix(err.Error(), usbwallet.ErrTrezorPINNeeded.Error()): + val, err = b.readPinAndReopenWallet(call) + if err == nil { + return val, nil + } + val, err = b.readPassphraseAndReopenWallet(call) + if err != nil { + return nil, err + } + + case strings.HasSuffix(err.Error(), scwallet.ErrPairingPasswordNeeded.Error()): + // PUK input requested, fetch from the user and call open again + input, err := b.prompter.PromptPassword("Please enter the pairing password: ") + if err != nil { + return nil, err + } + passwd = call.VM.ToValue(input) + if val, err = openWallet(goja.Null(), wallet, passwd); err != nil { + if !strings.HasSuffix(err.Error(), scwallet.ErrPINNeeded.Error()) { + return nil, err + } + // PIN input requested, fetch from the user and call open again + input, err := b.prompter.PromptPassword("Please enter current PIN: ") + if err != nil { + return nil, err + } + if val, err = openWallet(goja.Null(), wallet, call.VM.ToValue(input)); err != nil { + return nil, err + } + } + + case strings.HasSuffix(err.Error(), scwallet.ErrPINUnblockNeeded.Error()): + // PIN unblock requested, fetch PUK and new PIN from the user + var pukpin string + input, err := b.prompter.PromptPassword("Please enter current PUK: ") + if err != nil { + return nil, err + } + pukpin = input + input, err = b.prompter.PromptPassword("Please enter new PIN: ") + if err != nil { + return nil, err + } + pukpin += input + + if val, err = openWallet(goja.Null(), wallet, call.VM.ToValue(pukpin)); err != nil { + return nil, err + } + + case strings.HasSuffix(err.Error(), scwallet.ErrPINNeeded.Error()): + // PIN input requested, fetch from the user and call open again + input, err := b.prompter.PromptPassword("Please enter current PIN: ") + if err != nil { + return nil, err + } + if val, err = openWallet(goja.Null(), wallet, call.VM.ToValue(input)); err != nil { + return nil, err + } + + default: + // Unknown error occurred, drop to the user + return nil, err + } + return val, nil +} + +func (b *bridge) readPassphraseAndReopenWallet(call jsre.Call) (goja.Value, error) { + wallet := call.Argument(0) + input, err := b.prompter.PromptPassword("Please enter your passphrase: ") + if err != nil { + return nil, err + } + openWallet, callable := goja.AssertFunction(getJeth(call.VM).Get("openWallet")) + if !callable { + return nil, errors.New("jeth.openWallet is not callable") + } + return openWallet(goja.Null(), wallet, call.VM.ToValue(input)) +} + +func (b *bridge) readPinAndReopenWallet(call jsre.Call) (goja.Value, error) { + wallet := call.Argument(0) + // Trezor PIN matrix input requested, display the matrix to the user and fetch the data + fmt.Fprintf(b.printer, "Look at the device for number positions\n\n") + fmt.Fprintf(b.printer, "7 | 8 | 9\n") + fmt.Fprintf(b.printer, "--+---+--\n") + fmt.Fprintf(b.printer, "4 | 5 | 6\n") + fmt.Fprintf(b.printer, "--+---+--\n") + fmt.Fprintf(b.printer, "1 | 2 | 3\n\n") + + input, err := b.prompter.PromptPassword("Please enter current PIN: ") + if err != nil { + return nil, err + } + openWallet, callable := goja.AssertFunction(getJeth(call.VM).Get("openWallet")) + if !callable { + return nil, errors.New("jeth.openWallet is not callable") + } + return openWallet(goja.Null(), wallet, call.VM.ToValue(input)) +} + +// UnlockAccount is a wrapper around the personal.unlockAccount RPC method that +// uses a non-echoing password prompt to acquire the passphrase and executes the +// original RPC method (saved in jeth.unlockAccount) with it to actually execute +// the RPC call. +func (b *bridge) UnlockAccount(call jsre.Call) (goja.Value, error) { + if len(call.Arguments) < 1 { + return nil, errors.New("usage: unlockAccount(account, [ password, duration ])") + } + + account := call.Argument(0) + // Make sure we have an account specified to unlock. + if goja.IsUndefined(account) || goja.IsNull(account) || account.ExportType().Kind() != reflect.String { + return nil, errors.New("first argument must be the account to unlock") + } + + // If password is not given or is the null value, prompt the user for it. + var passwd goja.Value + if goja.IsUndefined(call.Argument(1)) || goja.IsNull(call.Argument(1)) { + fmt.Fprintf(b.printer, "Unlock account %s\n", account) + input, err := b.prompter.PromptPassword("Passphrase: ") + if err != nil { + return nil, err + } + passwd = call.VM.ToValue(input) + } else { + if call.Argument(1).ExportType().Kind() != reflect.String { + return nil, errors.New("password must be a string") + } + passwd = call.Argument(1) + } + + // Third argument is the duration how long the account should be unlocked. + duration := goja.Null() + if !goja.IsUndefined(call.Argument(2)) && !goja.IsNull(call.Argument(2)) { + if !isNumber(call.Argument(2)) { + return nil, errors.New("unlock duration must be a number") + } + duration = call.Argument(2) + } + + // Send the request to the backend and return. + unlockAccount, callable := goja.AssertFunction(getJeth(call.VM).Get("unlockAccount")) + if !callable { + return nil, errors.New("jeth.unlockAccount is not callable") + } + return unlockAccount(goja.Null(), account, passwd, duration) +} + +// Sign is a wrapper around the personal.sign RPC method that uses a non-echoing password +// prompt to acquire the passphrase and executes the original RPC method (saved in +// jeth.sign) with it to actually execute the RPC call. +func (b *bridge) Sign(call jsre.Call) (goja.Value, error) { + if nArgs := len(call.Arguments); nArgs < 2 { + return nil, errors.New("usage: sign(message, account, [ password ])") + } + var ( + message = call.Argument(0) + account = call.Argument(1) + passwd = call.Argument(2) + ) + + if goja.IsUndefined(message) || message.ExportType().Kind() != reflect.String { + return nil, errors.New("first argument must be the message to sign") + } + if goja.IsUndefined(account) || account.ExportType().Kind() != reflect.String { + return nil, errors.New("second argument must be the account to sign with") + } + + // if the password is not given or null ask the user and ensure password is a string + if goja.IsUndefined(passwd) || goja.IsNull(passwd) { + fmt.Fprintf(b.printer, "Give password for account %s\n", account) + input, err := b.prompter.PromptPassword("Password: ") + if err != nil { + return nil, err + } + passwd = call.VM.ToValue(input) + } else if passwd.ExportType().Kind() != reflect.String { + return nil, errors.New("third argument must be the password to unlock the account") + } + + // Send the request to the backend and return + sign, callable := goja.AssertFunction(getJeth(call.VM).Get("sign")) + if !callable { + return nil, errors.New("jeth.sign is not callable") + } + return sign(goja.Null(), message, account, passwd) +} + // Sleep will block the console for the specified number of seconds. func (b *bridge) Sleep(call jsre.Call) (goja.Value, error) { if nArgs := len(call.Arguments); nArgs < 1 { diff --git a/console/bridge_test.go b/console/bridge_test.go new file mode 100644 index 0000000000..e57e294fc5 --- /dev/null +++ b/console/bridge_test.go @@ -0,0 +1,48 @@ +// Copyright 2020 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package console + +import ( + "testing" + + "github.com/dop251/goja" + "github.com/ethereum/go-ethereum/internal/jsre" +) + +// TestUndefinedAsParam ensures that personal functions can receive +// `undefined` as a parameter. +func TestUndefinedAsParam(t *testing.T) { + b := bridge{} + call := jsre.Call{} + call.Arguments = []goja.Value{goja.Undefined()} + + b.UnlockAccount(call) + b.Sign(call) + b.Sleep(call) +} + +// TestNullAsParam ensures that personal functions can receive +// `null` as a parameter. +func TestNullAsParam(t *testing.T) { + b := bridge{} + call := jsre.Call{} + call.Arguments = []goja.Value{goja.Null()} + + b.UnlockAccount(call) + b.Sign(call) + b.Sleep(call) +} diff --git a/console/console.go b/console/console.go index 5e27d0814b..cdee53684e 100644 --- a/console/console.go +++ b/console/console.go @@ -142,6 +142,7 @@ func (c *Console) init(preload []string) error { // Add bridge overrides for web3.js functionality. c.jsre.Do(func(vm *goja.Runtime) { c.initAdmin(vm, bridge) + c.initPersonal(vm, bridge) }) // Preload JavaScript files. @@ -248,6 +249,30 @@ func (c *Console) initAdmin(vm *goja.Runtime, bridge *bridge) { } } +// initPersonal redirects account-related API methods through the bridge. +// +// If the console is in interactive mode and the 'personal' API is available, override +// the openWallet, unlockAccount, newAccount and sign methods since these require user +// interaction. The original web3 callbacks are stored in 'jeth'. These will be called +// by the bridge after the prompt and send the original web3 request to the backend. +func (c *Console) initPersonal(vm *goja.Runtime, bridge *bridge) { + personal := getObject(vm, "personal") + if personal == nil || c.prompter == nil { + return + } + log.Warn("Enabling deprecated personal namespace") + jeth := vm.NewObject() + vm.Set("jeth", jeth) + jeth.Set("openWallet", personal.Get("openWallet")) + jeth.Set("unlockAccount", personal.Get("unlockAccount")) + jeth.Set("newAccount", personal.Get("newAccount")) + jeth.Set("sign", personal.Get("sign")) + personal.Set("openWallet", jsre.MakeCallback(vm, bridge.OpenWallet)) + personal.Set("unlockAccount", jsre.MakeCallback(vm, bridge.UnlockAccount)) + personal.Set("newAccount", jsre.MakeCallback(vm, bridge.NewAccount)) + personal.Set("sign", jsre.MakeCallback(vm, bridge.Sign)) +} + func (c *Console) clearHistory() { c.history = nil c.prompter.ClearHistory() diff --git a/go.mod b/go.mod index e6e8c8bf1a..faa7415cbf 100644 --- a/go.mod +++ b/go.mod @@ -75,6 +75,7 @@ require ( github.com/tendermint/iavl v0.12.0 github.com/tendermint/tendermint v0.31.15 github.com/tidwall/wal v1.1.7 + github.com/tyler-smith/go-bip39 v1.1.0 github.com/urfave/cli/v2 v2.26.0 github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4 v1.1.3 github.com/willf/bitset v1.1.3 @@ -279,7 +280,6 @@ require ( github.com/tklauser/go-sysconf v0.3.13 // indirect github.com/tklauser/numcpus v0.7.0 // indirect github.com/trailofbits/go-mutexasserts v0.0.0-20230328101604-8cdbc5f3d279 // indirect - github.com/tyler-smith/go-bip39 v1.1.0 // indirect github.com/uber/jaeger-client-go v2.25.0+incompatible // indirect github.com/wealdtech/go-bytesutil v1.1.1 // indirect github.com/wealdtech/go-eth2-types/v2 v2.5.2 // indirect diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index a86835a41f..0733966a30 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -28,6 +28,8 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/accounts/scwallet" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/gopool" "github.com/ethereum/go-ethereum/common/hexutil" @@ -52,6 +54,7 @@ import ( "github.com/ethereum/go-ethereum/rpc" "github.com/ethereum/go-ethereum/trie" "github.com/holiman/uint256" + "github.com/tyler-smith/go-bip39" ) // max is a helper function which returns the larger of the two given integers. @@ -308,6 +311,344 @@ func (api *EthereumAccountAPI) Accounts() []common.Address { return api.am.Accounts() } +// PersonalAccountAPI provides an API to access accounts managed by this node. +// It offers methods to create, (un)lock en list accounts. Some methods accept +// passwords and are therefore considered private by default. +type PersonalAccountAPI struct { + am *accounts.Manager + nonceLock *AddrLocker + b Backend +} + +// NewPersonalAccountAPI creates a new PersonalAccountAPI. +func NewPersonalAccountAPI(b Backend, nonceLock *AddrLocker) *PersonalAccountAPI { + return &PersonalAccountAPI{ + am: b.AccountManager(), + nonceLock: nonceLock, + b: b, + } +} + +// ListAccounts will return a list of addresses for accounts this node manages. +func (api *PersonalAccountAPI) ListAccounts() []common.Address { + return api.am.Accounts() +} + +// rawWallet is a JSON representation of an accounts.Wallet interface, with its +// data contents extracted into plain fields. +type rawWallet struct { + URL string `json:"url"` + Status string `json:"status"` + Failure string `json:"failure,omitempty"` + Accounts []accounts.Account `json:"accounts,omitempty"` +} + +// ListWallets will return a list of wallets this node manages. +func (api *PersonalAccountAPI) ListWallets() []rawWallet { + wallets := make([]rawWallet, 0) // return [] instead of nil if empty + for _, wallet := range api.am.Wallets() { + status, failure := wallet.Status() + + raw := rawWallet{ + URL: wallet.URL().String(), + Status: status, + Accounts: wallet.Accounts(), + } + if failure != nil { + raw.Failure = failure.Error() + } + wallets = append(wallets, raw) + } + return wallets +} + +// OpenWallet initiates a hardware wallet opening procedure, establishing a USB +// connection and attempting to authenticate via the provided passphrase. Note, +// the method may return an extra challenge requiring a second open (e.g. the +// Trezor PIN matrix challenge). +func (api *PersonalAccountAPI) OpenWallet(url string, passphrase *string) error { + wallet, err := api.am.Wallet(url) + if err != nil { + return err + } + pass := "" + if passphrase != nil { + pass = *passphrase + } + return wallet.Open(pass) +} + +// DeriveAccount requests an HD wallet to derive a new account, optionally pinning +// it for later reuse. +func (api *PersonalAccountAPI) DeriveAccount(url string, path string, pin *bool) (accounts.Account, error) { + wallet, err := api.am.Wallet(url) + if err != nil { + return accounts.Account{}, err + } + derivPath, err := accounts.ParseDerivationPath(path) + if err != nil { + return accounts.Account{}, err + } + if pin == nil { + pin = new(bool) + } + return wallet.Derive(derivPath, *pin) +} + +// NewAccount will create a new account and returns the address for the new account. +func (api *PersonalAccountAPI) NewAccount(password string) (common.AddressEIP55, error) { + ks, err := fetchKeystore(api.am) + if err != nil { + return common.AddressEIP55{}, err + } + acc, err := ks.NewAccount(password) + if err == nil { + addrEIP55 := common.AddressEIP55(acc.Address) + log.Info("Your new key was generated", "address", addrEIP55.String()) + log.Warn("Please backup your key file!", "path", acc.URL.Path) + log.Warn("Please remember your password!") + return addrEIP55, nil + } + return common.AddressEIP55{}, err +} + +// fetchKeystore retrieves the encrypted keystore from the account manager. +func fetchKeystore(am *accounts.Manager) (*keystore.KeyStore, error) { + if ks := am.Backends(keystore.KeyStoreType); len(ks) > 0 { + return ks[0].(*keystore.KeyStore), nil + } + return nil, errors.New("local keystore not used") +} + +// ImportRawKey stores the given hex encoded ECDSA key into the key directory, +// encrypting it with the passphrase. +func (api *PersonalAccountAPI) ImportRawKey(privkey string, password string) (common.Address, error) { + key, err := crypto.HexToECDSA(privkey) + if err != nil { + return common.Address{}, err + } + ks, err := fetchKeystore(api.am) + if err != nil { + return common.Address{}, err + } + acc, err := ks.ImportECDSA(key, password) + return acc.Address, err +} + +// UnlockAccount will unlock the account associated with the given address with +// the given password for duration seconds. If duration is nil it will use a +// default of 300 seconds. It returns an indication if the account was unlocked. +func (api *PersonalAccountAPI) UnlockAccount(ctx context.Context, addr common.Address, password string, duration *uint64) (bool, error) { + // When the API is exposed by external RPC(http, ws etc), unless the user + // explicitly specifies to allow the insecure account unlocking, otherwise + // it is disabled. + if api.b.ExtRPCEnabled() && !api.b.AccountManager().Config().InsecureUnlockAllowed { + return false, errors.New("account unlock with HTTP access is forbidden") + } + + const max = uint64(time.Duration(gomath.MaxInt64) / time.Second) + var d time.Duration + if duration == nil { + d = 300 * time.Second + } else if *duration > max { + return false, errors.New("unlock duration too large") + } else { + d = time.Duration(*duration) * time.Second + } + ks, err := fetchKeystore(api.am) + if err != nil { + return false, err + } + err = ks.TimedUnlock(accounts.Account{Address: addr}, password, d) + if err != nil { + log.Warn("Failed account unlock attempt", "address", addr, "err", err) + } + return err == nil, err +} + +// LockAccount will lock the account associated with the given address when it's unlocked. +func (api *PersonalAccountAPI) LockAccount(addr common.Address) bool { + if ks, err := fetchKeystore(api.am); err == nil { + return ks.Lock(addr) == nil + } + return false +} + +// signTransaction sets defaults and signs the given transaction +// NOTE: the caller needs to ensure that the nonceLock is held, if applicable, +// and release it after the transaction has been submitted to the tx pool +func (api *PersonalAccountAPI) signTransaction(ctx context.Context, args *TransactionArgs, passwd string) (*types.Transaction, error) { + // Look up the wallet containing the requested signer + account := accounts.Account{Address: args.from()} + wallet, err := api.am.Find(account) + if err != nil { + return nil, err + } + // Set some sanity defaults and terminate on failure + if err := args.setDefaults(ctx, api.b, false); err != nil { + return nil, err + } + // Assemble the transaction and sign with the wallet + tx := args.ToTransaction(types.LegacyTxType) + + return wallet.SignTxWithPassphrase(account, passwd, tx, api.b.ChainConfig().ChainID) +} + +// SendTransaction will create a transaction from the given arguments and +// tries to sign it with the key associated with args.From. If the given +// passwd isn't able to decrypt the key it fails. +func (api *PersonalAccountAPI) SendTransaction(ctx context.Context, args TransactionArgs, passwd string) (common.Hash, error) { + if args.Nonce == nil { + // Hold the mutex around signing to prevent concurrent assignment of + // the same nonce to multiple accounts. + api.nonceLock.LockAddr(args.from()) + defer api.nonceLock.UnlockAddr(args.from()) + } + if args.IsEIP4844() { + return common.Hash{}, errBlobTxNotSupported + } + signed, err := api.signTransaction(ctx, &args, passwd) + if err != nil { + log.Warn("Failed transaction send attempt", "from", args.from(), "to", args.To, "value", args.Value.ToInt(), "err", err) + return common.Hash{}, err + } + return SubmitTransaction(ctx, api.b, signed) +} + +// SignTransaction will create a transaction from the given arguments and +// tries to sign it with the key associated with args.From. If the given passwd isn't +// able to decrypt the key it fails. The transaction is returned in RLP-form, not broadcast +// to other nodes +func (api *PersonalAccountAPI) SignTransaction(ctx context.Context, args TransactionArgs, passwd string) (*SignTransactionResult, error) { + // No need to obtain the noncelock mutex, since we won't be sending this + // tx into the transaction pool, but right back to the user + if args.From == nil { + return nil, errors.New("sender not specified") + } + if args.Gas == nil { + return nil, errors.New("gas not specified") + } + if args.GasPrice == nil && (args.MaxFeePerGas == nil || args.MaxPriorityFeePerGas == nil) { + return nil, errors.New("missing gasPrice or maxFeePerGas/maxPriorityFeePerGas") + } + if args.IsEIP4844() { + return nil, errBlobTxNotSupported + } + if args.Nonce == nil { + return nil, errors.New("nonce not specified") + } + // Before actually signing the transaction, ensure the transaction fee is reasonable. + tx := args.ToTransaction(types.LegacyTxType) + if err := checkTxFee(tx.GasPrice(), tx.Gas(), api.b.RPCTxFeeCap()); err != nil { + return nil, err + } + signed, err := api.signTransaction(ctx, &args, passwd) + if err != nil { + log.Warn("Failed transaction sign attempt", "from", args.from(), "to", args.To, "value", args.Value.ToInt(), "err", err) + return nil, err + } + data, err := signed.MarshalBinary() + if err != nil { + return nil, err + } + return &SignTransactionResult{data, signed}, nil +} + +// Sign calculates an Ethereum ECDSA signature for: +// keccak256("\x19Ethereum Signed Message:\n" + len(message) + message)) +// +// Note, the produced signature conforms to the secp256k1 curve R, S and V values, +// where the V value will be 27 or 28 for legacy reasons. +// +// The key used to calculate the signature is decrypted with the given password. +// +// https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-personal#personal-sign +func (api *PersonalAccountAPI) Sign(ctx context.Context, data hexutil.Bytes, addr common.Address, passwd string) (hexutil.Bytes, error) { + // Look up the wallet containing the requested signer + account := accounts.Account{Address: addr} + + wallet, err := api.b.AccountManager().Find(account) + if err != nil { + return nil, err + } + // Assemble sign the data with the wallet + signature, err := wallet.SignTextWithPassphrase(account, passwd, data) + if err != nil { + log.Warn("Failed data sign attempt", "address", addr, "err", err) + return nil, err + } + signature[crypto.RecoveryIDOffset] += 27 // Transform V from 0/1 to 27/28 according to the yellow paper + return signature, nil +} + +// EcRecover returns the address for the account that was used to create the signature. +// Note, this function is compatible with eth_sign and personal_sign. As such it recovers +// the address of: +// hash = keccak256("\x19Ethereum Signed Message:\n"${message length}${message}) +// addr = ecrecover(hash, signature) +// +// Note, the signature must conform to the secp256k1 curve R, S and V values, where +// the V value must be 27 or 28 for legacy reasons. +// +// https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-personal#personal-ecrecover +func (api *PersonalAccountAPI) EcRecover(ctx context.Context, data, sig hexutil.Bytes) (common.Address, error) { + if len(sig) != crypto.SignatureLength { + return common.Address{}, fmt.Errorf("signature must be %d bytes long", crypto.SignatureLength) + } + if sig[crypto.RecoveryIDOffset] != 27 && sig[crypto.RecoveryIDOffset] != 28 { + return common.Address{}, errors.New("invalid Ethereum signature (V is not 27 or 28)") + } + sig[crypto.RecoveryIDOffset] -= 27 // Transform yellow paper V from 27/28 to 0/1 + + rpk, err := crypto.SigToPub(accounts.TextHash(data), sig) + if err != nil { + return common.Address{}, err + } + return crypto.PubkeyToAddress(*rpk), nil +} + +// InitializeWallet initializes a new wallet at the provided URL, by generating and returning a new private key. +func (api *PersonalAccountAPI) InitializeWallet(ctx context.Context, url string) (string, error) { + wallet, err := api.am.Wallet(url) + if err != nil { + return "", err + } + + entropy, err := bip39.NewEntropy(256) + if err != nil { + return "", err + } + + mnemonic, err := bip39.NewMnemonic(entropy) + if err != nil { + return "", err + } + + seed := bip39.NewSeed(mnemonic, "") + + switch wallet := wallet.(type) { + case *scwallet.Wallet: + return mnemonic, wallet.Initialize(seed) + default: + return "", errors.New("specified wallet does not support initialization") + } +} + +// Unpair deletes a pairing between wallet and geth. +func (api *PersonalAccountAPI) Unpair(ctx context.Context, url string, pin string) error { + wallet, err := api.am.Wallet(url) + if err != nil { + return err + } + + switch wallet := wallet.(type) { + case *scwallet.Wallet: + return wallet.Unpair([]byte(pin)) + default: + return errors.New("specified wallet does not support pairing") + } +} + // BlockChainAPI provides an API to access Ethereum blockchain data. type BlockChainAPI struct { b Backend diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go index 8e6dfbea87..3e91de315f 100644 --- a/internal/ethapi/backend.go +++ b/internal/ethapi/backend.go @@ -150,6 +150,9 @@ func GetAPIs(apiBackend Backend) []rpc.API { }, { Namespace: "mev", Service: NewMevAPI(apiBackend), + }, { + Namespace: "personal", + Service: NewPersonalAccountAPI(apiBackend, nonceLock), }, } } diff --git a/internal/web3ext/web3ext.go b/internal/web3ext/web3ext.go index 3ad368ec50..b6001d0010 100644 --- a/internal/web3ext/web3ext.go +++ b/internal/web3ext/web3ext.go @@ -18,15 +18,16 @@ package web3ext var Modules = map[string]string{ - "admin": AdminJs, - "clique": CliqueJs, - "debug": DebugJs, - "eth": EthJs, - "miner": MinerJs, - "net": NetJs, - "rpc": RpcJs, - "txpool": TxpoolJs, - "dev": DevJs, + "admin": AdminJs, + "clique": CliqueJs, + "debug": DebugJs, + "eth": EthJs, + "miner": MinerJs, + "net": NetJs, + "personal": PersonalJs, + "rpc": RpcJs, + "txpool": TxpoolJs, + "dev": DevJs, } const CliqueJs = ` @@ -704,6 +705,62 @@ web3._extend({ }); ` +const PersonalJs = ` +web3._extend({ + property: 'personal', + methods: [ + new web3._extend.Method({ + name: 'importRawKey', + call: 'personal_importRawKey', + params: 2 + }), + new web3._extend.Method({ + name: 'sign', + call: 'personal_sign', + params: 3, + inputFormatter: [null, web3._extend.formatters.inputAddressFormatter, null] + }), + new web3._extend.Method({ + name: 'ecRecover', + call: 'personal_ecRecover', + params: 2 + }), + new web3._extend.Method({ + name: 'openWallet', + call: 'personal_openWallet', + params: 2 + }), + new web3._extend.Method({ + name: 'deriveAccount', + call: 'personal_deriveAccount', + params: 3 + }), + new web3._extend.Method({ + name: 'signTransaction', + call: 'personal_signTransaction', + params: 2, + inputFormatter: [web3._extend.formatters.inputTransactionFormatter, null] + }), + new web3._extend.Method({ + name: 'unpair', + call: 'personal_unpair', + params: 2 + }), + new web3._extend.Method({ + name: 'initializeWallet', + call: 'personal_initializeWallet', + params: 1 + }) + ], + properties: [ + new web3._extend.Property({ + name: 'listWallets', + getter: 'personal_listWallets' + }), + ] +}) +` + const RpcJs = ` web3._extend({ property: 'rpc', diff --git a/node/node.go b/node/node.go index 8a7b7d3cce..4b54cdc1d9 100644 --- a/node/node.go +++ b/node/node.go @@ -422,13 +422,25 @@ func (n *Node) obtainJWTSecret(cliParam string) ([]byte, error) { // startup. It's not meant to be called at any time afterwards as it makes certain // assumptions about the state of the node. func (n *Node) startRPC() error { - if err := n.startInProc(n.rpcAPIs); err != nil { + // Filter out personal api + var apis []rpc.API + for _, api := range n.rpcAPIs { + if api.Namespace == "personal" { + if n.config.EnablePersonal { + log.Warn("Deprecated personal namespace activated") + } else { + continue + } + } + apis = append(apis, api) + } + if err := n.startInProc(apis); err != nil { return err } // Configure IPC. if n.ipc.endpoint != "" { - if err := n.ipc.start(n.rpcAPIs); err != nil { + if err := n.ipc.start(apis); err != nil { return err } } diff --git a/signer/core/signed_data.go b/signer/core/signed_data.go index a265bc9ee6..5bc6cbc613 100644 --- a/signer/core/signed_data.go +++ b/signer/core/signed_data.go @@ -341,7 +341,7 @@ func typedDataRequest(data any) (*SignDataRequest, error) { func (api *SignerAPI) EcRecover(ctx context.Context, data hexutil.Bytes, sig hexutil.Bytes) (common.Address, error) { // Returns the address for the Account that was used to create the signature. // - // Note, this function is compatible with eth_sign. As such it recovers + // Note, this function is compatible with eth_sign and personal_sign. As such it recovers // the address of: // hash = keccak256("\x19Ethereum Signed Message:\n${message length}${message}") // addr = ecrecover(hash, signature)