From ec83d9d0abb07857829ec2263b7cbb3e36943c9d Mon Sep 17 00:00:00 2001 From: Martin Holst Swende Date: Tue, 20 Feb 2018 14:35:25 +0100 Subject: [PATCH] signer/storage: implement aes-gcm-backed credential storage --- cmd/signer/README.md | 74 +-- cmd/signer/core/abihelper.go | 1 - cmd/signer/core/abihelper_test.go | 7 +- cmd/signer/core/api.go | 8 +- cmd/signer/core/auditlog.go | 8 +- cmd/signer/core/cliui.go | 6 +- cmd/signer/core/types.go | 3 +- cmd/signer/core/validation.go | 3 +- cmd/signer/core/validation_test.go | 5 +- cmd/signer/main.go | 579 ++++++++++++++++----- cmd/signer/rules/rules.go | 59 ++- cmd/signer/rules/rules_test.go | 62 ++- cmd/signer/storage/aes_gcm_storage.go | 148 ++++++ cmd/signer/storage/aes_gcm_storage_test.go | 99 ++++ cmd/signer/storage/storage.go | 16 +- cmd/signer/tutorial.md | 198 +++++++ 16 files changed, 1059 insertions(+), 217 deletions(-) create mode 100644 cmd/signer/storage/aes_gcm_storage.go create mode 100644 cmd/signer/storage/aes_gcm_storage_test.go create mode 100644 cmd/signer/tutorial.md diff --git a/cmd/signer/README.md b/cmd/signer/README.md index 2f25b8b8bb..c9a2de36aa 100644 --- a/cmd/signer/README.md +++ b/cmd/signer/README.md @@ -9,24 +9,38 @@ This setup allows a DApp to connect to a remote Ethereum node and send transacti help in situations when a DApp is connected to a remote node because a local Ethereum node is not available, not synchronised with the chain or a particular Ethereum node that has no build in, or limited account management. -In its current form the signer is very limited and designed to work with Mist. It hasn't got a connection to an -Ethereum node. This restriction imposed many limitations such as the lack of ability to keep track of nonces, balances -or fetching additional information that can help the user to make a decision to sign a transaction or data. +The signer can run as a daemon on the same machine, or off a usb-stick like [usb armory](https://inversepath.com/usbarmory), +or a separate VM in a [QubesOS](https://www.qubes-os.org/) type os setup. + ## Command line flags The signer accepts the following command line options: ``` - --chainid value chain identifier (default: 1) - --loglevel value log level to emit to the screen (default: 4) - --keystore value Directory for the keystore (default: "/home/martin/.ethereum/keystore") - --networkid value Network identifier (integer, 1=Frontier, 2=Morden (disused), 3=Ropsten, 4=Rinkeby) (default: 1) - --lightkdf Reduce key-derivation RAM & CPU usage at some expense of KDF strength - --nousb Disables monitoring for and managing USB hardware wallets - --rpcaddr value HTTP-RPC server listening interface (default: "localhost") - --rpcport value HTTP-RPC server listening port (default: 8550) - --4bytedb value File containing 4byte-identifiers (default: "./4byte.json") - --stdio-ui Use STDIN/STDOUT as a channel for an external UI. This means that an STDIN/STDOUT is used for RPC-communication with a e.g. a graphical user interface, and can be used when the signer is started by an external process. - --help, -h show help +COMMANDS: + init Initialize the signer, generate secret storage + attest Attest that a js-file is to be used + addpw Store a credential for a keystore file + help Shows a list of commands or help for one command + +GLOBAL OPTIONS: + --loglevel value log level to emit to the screen (default: 4) + --keystore value Directory for the keystore (default: "/home/martin/.ethereum/keystore") + --configdir value Directory for signer configuration (default: "/home/martin/.signer") + --networkid value Network identifier (integer, 1=Frontier, 2=Morden (disused), 3=Ropsten, 4=Rinkeby) (default: 1) + --lightkdf Reduce key-derivation RAM & CPU usage at some expense of KDF strength + --nousb Disables monitoring for and managing USB hardware wallets + --rpcaddr value HTTP-RPC server listening interface (default: "localhost") + --rpcport value HTTP-RPC server listening port (default: 8550) + --signersecret value A file containing the password used to encrypt signer credentials, e.g. keystore credentials and ruleset hash + --4bytedb value File containing 4byte-identifiers (default: "./4byte.json") + --4bytedb-custom value File used for writing new 4byte-identifiers submitted via API (default: "./4byte-custom.json") + --auditlog value File used to emit audit logs. Set to "" to disable (default: "audit.log") + --rules value Enable rule-engine (default: "rules.json") + --stdio-ui Use STDIN/STDOUT as a channel for an external UI. This means that an STDIN/STDOUT is used for RPC-communication with a e.g. a graphical user interface, and can be used when the signer is started by an external process. + --stdio-ui-test Mechanism to test interface between signer and UI. Requires 'stdio-ui'. + --help, -h show help + --version, -v print the version + ``` @@ -35,6 +49,8 @@ Example: signer -keystore /my/keystore -chainid 4 ``` +Check out the [tutorial](tutorial.md) for some concrete examples on how the signer works. + ## Security model The security model of the signer is as follows: @@ -67,29 +83,25 @@ passing on to the UI. The reason it currently does not, is that it would make it accounts if it immediately returned "unknown account". * [x] DONE: Similarly, it should be possible to configure the signer to auto-allow listing (certain) accounts, instead of asking every time. -* Upon startup, the signer should spit out some info to the caller (particularly important when executed in `stdio-ui`-mode), +* [x] Done Upon startup, the signer should spit out some info to the caller (particularly important when executed in `stdio-ui`-mode), invoking methods with the following info: - * Version info about the signer - * Address of API (http/ipc) - * This makes it posible for the UI to use the api for creating transactions - * List of known accounts + * [x] Version info about the signer + * [x] Address of API (http/ipc) + * [ ] List of known accounts -* The signer should pass the `Origin` header as call-info to the UI. As of right now, the way that info about the request is + +* Geth todos + - The signer should pass the `Origin` header as call-info to the UI. As of right now, the way that info about the request is put together is a bit of a hack into the http server. This could probably be greatly improved - -* Geth relay - - Geth should be started in `geth --external_signer localhost:8550`. - -* Geth checksum - - Currently, the Geth API:s use `common.Address` in the arguments to transaction submission (e.g `to` field). This + - Relay: Geth should be started in `geth --external_signer localhost:8550`. + - Currently, the Geth API:s use `common.Address` in the arguments to transaction submission (e.g `to` field). This type is 20 `bytes`, and is incapable of carrying checksum information. The signer uses `common.MixedcaseAddress`, which retains the original input. - - The Geth api should switch to use the same type, and relay `to`-account verbatim to the external api. + - The Geth api should switch to use the same type, and relay `to`-account verbatim to the external api. -* Wallets / accounts. Add API methods for wallets. -* Storage - * An encrypted key-value storage should be implemented +* [x] Storage + * [x] An encrypted key-value storage should be implemented * See [rules.md](rules.md) for more info about this. * Another potential thing to introduce is pairing. @@ -97,6 +109,8 @@ put together is a bit of a hack into the http server. This could probably be gre * Thus geth/mist/cpp would cryptographically handshake and afterwards the caller would be allowed to make signing requests. * This feature would make the addition of rules less dangerous. +* Wallets / accounts. Add API methods for wallets. + ## Communication ### External API diff --git a/cmd/signer/core/abihelper.go b/cmd/signer/core/abihelper.go index 9f7a0f2320..f60d9fc47b 100644 --- a/cmd/signer/core/abihelper.go +++ b/cmd/signer/core/abihelper.go @@ -17,7 +17,6 @@ package core import ( - // "bytes" "encoding/json" "fmt" "io/ioutil" diff --git a/cmd/signer/core/abihelper_test.go b/cmd/signer/core/abihelper_test.go index 00aa8e942b..a59f53b902 100644 --- a/cmd/signer/core/abihelper_test.go +++ b/cmd/signer/core/abihelper_test.go @@ -21,13 +21,12 @@ import ( "strings" "testing" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common" - // "reflect" - // "math/big" "io/ioutil" "math/big" "reflect" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" ) func verify(t *testing.T, jsondata, calldata string, exp []interface{}) { diff --git a/cmd/signer/core/api.go b/cmd/signer/core/api.go index 15c043c496..46f5fcb236 100644 --- a/cmd/signer/core/api.go +++ b/cmd/signer/core/api.go @@ -385,7 +385,7 @@ func (api *SignerAPI) SignTransaction(ctx context.Context, args SendTxArgs, meth // // https://github.com/ethereum/go-ethereum/wiki/Management-APIs#personal_sign func (api *SignerAPI) Sign(ctx context.Context, addr common.MixedcaseAddress, data hexutil.Bytes) (hexutil.Bytes, error) { - sighash, msg := signHash(data) + sighash, msg := SignHash(data) // We make the request prior to looking up if we actually have the account, to prevent // account-enumeration via the API req := &SignDataRequest{Address: addr, Rawdata: data, Message: msg, Hash: sighash, Meta: MetadataFromContext(ctx)} @@ -431,7 +431,7 @@ func (api *SignerAPI) EcRecover(ctx context.Context, data, sig hexutil.Bytes) (c return common.Address{}, fmt.Errorf("invalid Ethereum signature (V is not 27 or 28)") } sig[64] -= 27 // Transform yellow paper V from 27/28 to 0/1 - hash, _ := signHash(data) + hash, _ := SignHash(data) rpk, err := crypto.Ecrecover(hash, sig) if err != nil { return common.Address{}, err @@ -441,14 +441,14 @@ func (api *SignerAPI) EcRecover(ctx context.Context, data, sig hexutil.Bytes) (c return recoveredAddr, nil } -// signHash is a helper function that calculates a hash for the given message that can be +// SignHash is a helper function that calculates a hash for the given message that can be // safely used to calculate a signature from. // // The hash is calulcated as // keccak256("\x19Ethereum Signed Message:\n"${message length}${message}). // // This gives context to the signed message and prevents signing of transactions. -func signHash(data []byte) ([]byte, string) { +func SignHash(data []byte) ([]byte, string) { msg := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(data), data) return crypto.Keccak256([]byte(msg)), msg } diff --git a/cmd/signer/core/auditlog.go b/cmd/signer/core/auditlog.go index 5dd0cc21ed..beb9bfa215 100644 --- a/cmd/signer/core/auditlog.go +++ b/cmd/signer/core/auditlog.go @@ -20,6 +20,7 @@ import ( "context" "encoding/json" + "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" @@ -33,9 +34,12 @@ type AuditLogger struct { } func (l *AuditLogger) List(ctx context.Context) (Accounts, error) { + l.log.Info("List", "type", "request", "metadata", MetadataFromContext(ctx).String()) + res, e := l.api.List(ctx) - l.log.Info("Called list", "interface", "http") - return l.api.List(ctx) + l.log.Info("List", "type", "response", "data", res.String()) + + return res, e } func (l *AuditLogger) New(ctx context.Context) (accounts.Account, error) { diff --git a/cmd/signer/core/cliui.go b/cmd/signer/core/cliui.go index e84a74d555..468202cece 100644 --- a/cmd/signer/core/cliui.go +++ b/cmd/signer/core/cliui.go @@ -140,7 +140,7 @@ func (ui *CommandlineUI) ApproveSignData(request *SignDataRequest) (SignDataResp defer ui.mu.Unlock() fmt.Printf("-------- Sign data request--------------\n") - fmt.Printf("Account: %x\n", request.Address) + fmt.Printf("Account: %s\n", request.Address.String()) fmt.Printf("message: \n%q\n", request.Message) fmt.Printf("raw data: \n%v\n", request.Rawdata) fmt.Printf("message hash: %v\n", request.Hash) @@ -238,8 +238,8 @@ func (ui *CommandlineUI) OnApprovedTx(tx ethapi.SignTransactionResult) { func (ui *CommandlineUI) OnSignerStartup(info StartupInfo) { - fmt.Printf("------- Signer info ------- ") + fmt.Printf("------- Signer info -------\n") for k, v := range info.Info { - fmt.Printf("* %v : %v", k, v) + fmt.Printf("* %v : %v\n", k, v) } } diff --git a/cmd/signer/core/types.go b/cmd/signer/core/types.go index 9ee15e3486..f23bdae401 100644 --- a/cmd/signer/core/types.go +++ b/cmd/signer/core/types.go @@ -20,11 +20,12 @@ import ( "encoding/json" "strings" + "math/big" + "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" - "math/big" ) type Accounts []Account diff --git a/cmd/signer/core/validation.go b/cmd/signer/core/validation.go index 5b57b3afba..445a2b7b98 100644 --- a/cmd/signer/core/validation.go +++ b/cmd/signer/core/validation.go @@ -20,8 +20,9 @@ import ( "bytes" "errors" "fmt" - "github.com/ethereum/go-ethereum/common" "math/big" + + "github.com/ethereum/go-ethereum/common" ) // The validation package contains validation checks for transactions diff --git a/cmd/signer/core/validation_test.go b/cmd/signer/core/validation_test.go index 8f58c2bb63..785f45a020 100644 --- a/cmd/signer/core/validation_test.go +++ b/cmd/signer/core/validation_test.go @@ -18,10 +18,11 @@ package core import ( "fmt" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" "math/big" "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" ) func hexAddr(a string) common.Address { return common.BytesToAddress(common.FromHex(a)) } diff --git a/cmd/signer/main.go b/cmd/signer/main.go index 65a50046b6..a5e7eabd61 100644 --- a/cmd/signer/main.go +++ b/cmd/signer/main.go @@ -19,17 +19,28 @@ package main import ( + "bufio" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/json" "fmt" + "io" + "io/ioutil" "net" "os" + "os/user" "path/filepath" + "runtime" + "strings" - "context" - "encoding/json" - + "encoding/hex" "github.com/ethereum/go-ethereum/cmd/signer/core" + "github.com/ethereum/go-ethereum/cmd/signer/rules" + "github.com/ethereum/go-ethereum/cmd/signer/storage" "github.com/ethereum/go-ethereum/cmd/utils" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/node" "github.com/ethereum/go-ethereum/rpc" @@ -42,152 +53,463 @@ const EXT_API_VERSION = "2.0.0" // INT_API_VERSION -- see intapi_changelog.md const INT_API_VERSION = "1.2.0" -func main() { +const legal_warning = ` +WARNING! - app := cli.NewApp() +The signer is alpha software, and not yet publically released. This software has _not_ been audited, and there +are no guarantees about the workings of this software. It may contain severe flaws. You should not use this software +unless you agree to take full responsibility for doing so, and know what you are doing. + +TLDR; THIS IS NOT PRODUCTION-READY SOFTWARE! + +` + +var ( + logLevelFlag = cli.IntFlag{ + Name: "loglevel", + Value: 4, + Usage: "log level to emit to the screen", + } + keystoreFlag = cli.StringFlag{ + Name: "keystore", + Value: filepath.Join(node.DefaultDataDir(), "keystore"), + Usage: "Directory for the keystore", + } + configdirFlag = cli.StringFlag{ + Name: "configdir", + Value: DefaultConfigDir(), + Usage: "Directory for signer configuration", + } + rpcPortFlag = cli.IntFlag{ + Name: "rpcport", + Usage: "HTTP-RPC server listening port", + Value: node.DefaultHTTPPort + 5, + } + signerSecretFlag = cli.StringFlag{ + Name: "signersecret", + Usage: "A file containing the password used to encrypt signer credentials, e.g. keystore credentials and ruleset hash", + } + dBFlag = cli.StringFlag{ + Name: "4bytedb", + Usage: "File containing 4byte-identifiers", + Value: "./4byte.json", + } + customDBFlag = cli.StringFlag{ + Name: "4bytedb-custom", + Usage: "File used for writing new 4byte-identifiers submitted via API", + Value: "./4byte-custom.json", + } + auditLogFlag = cli.StringFlag{ + Name: "auditlog", + Usage: "File used to emit audit logs. Set to \"\" to disable", + Value: "audit.log", + } + ruleFlag = cli.StringFlag{ + Name: "rules", + Usage: "Enable rule-engine", + Value: "rules.json", + } + stdiouiFlag = cli.BoolFlag{ + Name: "stdio-ui", + Usage: "Use STDIN/STDOUT as a channel for an external UI. " + + "This means that an STDIN/STDOUT is used for RPC-communication with a e.g. a graphical user " + + "interface, and can be used when the signer is started by an external process.", + } + testFlag = cli.BoolFlag{ + Name: "stdio-ui-test", + Usage: "Mechanism to test interface between signer and UI. Requires 'stdio-ui'.", + } + app = cli.NewApp() + initCommand = cli.Command{ + Action: utils.MigrateFlags(initializeSecrets), + Name: "init", + Usage: "Initialize the signer, generate secret storage", + ArgsUsage: "", + Flags: []cli.Flag{ + logLevelFlag, + configdirFlag, + }, + Description: ` +The init command generates a master seed which the signer can use to store credentials and data needed for +the rule-engine to work.`, + } + attestCommand = cli.Command{ + Action: utils.MigrateFlags(attestFile), + Name: "attest", + Usage: "Attest that a js-file is to be used", + ArgsUsage: "", + Flags: []cli.Flag{ + logLevelFlag, + configdirFlag, + signerSecretFlag, + }, + Description: ` +The attest command stores the sha256 of the rule.js-file that you want to use for automatic processing of +incoming requests. + +Whenever you make an edit to the rule file, you need to use attestation to tell +the signer that the file is 'safe' to execute.`, + } + + addCredentialCommand = cli.Command{ + Action: utils.MigrateFlags(addCredential), + Name: "addpw", + Usage: "Store a credential for a keystore file", + ArgsUsage: "
", + Flags: []cli.Flag{ + logLevelFlag, + configdirFlag, + signerSecretFlag, + }, + Description: ` +The addpw command stores a password for a given address (keyfile). If you invoke it with only one parameter, it will +remove any stored credential for that address (keyfile) +`, + } +) + +func init() { app.Name = "signer" app.Usage = "Manage ethereum Account operations" app.Flags = []cli.Flag{ - cli.IntFlag{ - Name: "loglevel", - Value: 4, - Usage: "log level to emit to the screen", - }, - cli.StringFlag{ - Name: "keystore", - Value: filepath.Join(node.DefaultDataDir(), "keystore"), - Usage: "Directory for the keystore", - }, + logLevelFlag, + keystoreFlag, + configdirFlag, utils.NetworkIdFlag, utils.LightKDFFlag, utils.NoUSBFlag, utils.RPCListenAddrFlag, - cli.IntFlag{ - Name: "rpcport", - Usage: "HTTP-RPC server listening port", - Value: node.DefaultHTTPPort + 5, - }, - cli.StringFlag{ - Name: "4bytedb", - Usage: "File containing 4byte-identifiers", - Value: "./4byte.json", - }, - cli.StringFlag{ - Name: "4bytedb-custom", - Usage: "File used for writing new 4byte-identifiers submitted via API", - Value: "./4byte-custom.json", - }, - cli.StringFlag{ - Name: "auditlog", - Usage: "File used to emit audit logs. Set to \"\" to disable", - Value: "audit.log", - }, - cli.StringFlag{ - Name: "requestfile", - Usage: "File containing requests to handle", - Value: "", - }, - cli.BoolFlag{ - Name: "stdio-ui", - Usage: "Use STDIN/STDOUT as a channel for an external UI. " + - "This means that an STDIN/STDOUT is used for RPC-communication with a e.g. a graphical user " + - "interface, and can be used when the signer is started by an external process.", - }, - cli.BoolFlag{ - Name: "stdio-ui-test", - Usage: "Mechanism to test interface between signer and UI. Requires 'stdio-ui'.", - }, + rpcPortFlag, + signerSecretFlag, + dBFlag, + customDBFlag, + auditLogFlag, + ruleFlag, + stdiouiFlag, + testFlag, + } + app.Action = signer + app.Commands = []cli.Command{initCommand, attestCommand, addCredentialCommand} + +} +func main() { + if err := app.Run(os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func initializeSecrets(c *cli.Context) error { + if err := initialize(c); err != nil { + return err + } + configDir := c.String(configdirFlag.Name) + + masterSeed := make([]byte, 256) + n, err := io.ReadFull(rand.Reader, masterSeed) + if err != nil { + return err + } + if n != len(masterSeed) { + return fmt.Errorf("Failed to read enough random") + } + err = os.Mkdir(configDir, 0700) + if err != nil && !os.IsExist(err) { + return err + } + location := filepath.Join(configDir, "secrets.dat") + if _, err := os.Stat(location); err == nil { + return fmt.Errorf("file %v already exists, will not overwrite", location) + } + err = ioutil.WriteFile(location, masterSeed, 0700) + if err != nil { + return err + } + fmt.Printf("A master seed has been generated into %s\n", location) + fmt.Printf(` +This is required to be able to store credentials, such as : +* Passwords for keystores (used by rule engine) +* Storage for javascript rules +* Hash of rule-file + +You should treat that file with utmost secrecy, and make a backup of it. +NOTE: This file does not contain your accounts. Those need to be backed up separately! + +`) + return nil +} +func attestFile(ctx *cli.Context) error { + if len(ctx.Args()) < 1 { + utils.Fatalf("This command requires an argument.") + } + if err := initialize(ctx); err != nil { + return err } - app.Action = func(c *cli.Context) error { + stretchedKey, err := readMasterKey(ctx) + if err != nil { + utils.Fatalf(err.Error()) + } + configDir := ctx.String(configdirFlag.Name) + vaultLocation := filepath.Join(configDir, common.Bytes2Hex(crypto.Keccak256([]byte("vault"), stretchedKey)[:10])) + confKey := crypto.Keccak256([]byte("config"), stretchedKey) - var ( - ui core.SignerUI - ) - // Set up the logger to print everything - logOutput := os.Stdout - if c.Bool("stdio-ui") { - logOutput = os.Stderr - } - log.Root().SetHandler(log.LvlFilterHandler(log.Lvl(c.Int("loglevel")), log.StreamHandler(logOutput, log.TerminalFormat(true)))) + // Initialize the encrypted storages + configStorage := storage.NewAESEncryptedStorage(filepath.Join(vaultLocation, "config.json"), confKey) + val := ctx.Args().First() + configStorage.Put("ruleset_sha256", val) + log.Info("Ruleset attestation updated", "sha256", val) + return nil +} - if c.Bool("stdio-ui") { - ui = core.NewStdIOUI() - } else { - ui = core.NewCommandlineUI() - } - if c.Bool("stdio-ui") { - log.Info("Using stdin/stdout as UI-channel") - } - db, err := core.NewAbiDBFromFiles(c.String("4bytedb"), c.String("4bytedb-custom")) +func addCredential(ctx *cli.Context) error { + if len(ctx.Args()) < 1 { + utils.Fatalf("This command requires at leaste one argument.") + } + if err := initialize(ctx); err != nil { + return err + } + + stretchedKey, err := readMasterKey(ctx) + if err != nil { + utils.Fatalf(err.Error()) + } + configDir := ctx.String(configdirFlag.Name) + vaultLocation := filepath.Join(configDir, common.Bytes2Hex(crypto.Keccak256([]byte("vault"), stretchedKey)[:10])) + pwkey := crypto.Keccak256([]byte("credentials"), stretchedKey) + + // Initialize the encrypted storages + pw_storage := storage.NewAESEncryptedStorage(filepath.Join(vaultLocation, "credentials.json"), pwkey) + key := ctx.Args().First() + value := "" + if len(ctx.Args()) > 1 { + value = ctx.Args().Get(1) + } + pw_storage.Put(key, value) + log.Info("Credential store updated", "key", key) + return nil +} + +func initialize(c *cli.Context) error { + if !confirm(legal_warning) { + return fmt.Errorf("aborted by user") + } + // Set up the logger to print everything + logOutput := os.Stdout + if c.Bool(stdiouiFlag.Name) { + logOutput = os.Stderr + } + log.Root().SetHandler(log.LvlFilterHandler(log.Lvl(c.Int(logLevelFlag.Name)), log.StreamHandler(logOutput, log.TerminalFormat(true)))) + return nil +} + +func signer(c *cli.Context) error { + if err := initialize(c); err != nil { + return err + } + var ( + ui core.SignerUI + ) + if c.Bool(stdiouiFlag.Name) { + log.Info("Using stdin/stdout as UI-channel") + ui = core.NewStdIOUI() + } else { + log.Info("Using CLI as UI-channel") + ui = core.NewCommandlineUI() + } + db, err := core.NewAbiDBFromFiles(c.String(dBFlag.Name), c.String(customDBFlag.Name)) + if err != nil { + utils.Fatalf(err.Error()) + } + log.Info("Loaded 4byte db", "signatures", db.Size(), "file", c.String("4bytedb")) + + var ( + api core.ExternalAPI + listener net.Listener + server = rpc.NewServer() + ) + + configDir := c.String(configdirFlag.Name) + if stretchedKey, err := readMasterKey(c); err != nil { + log.Info("No master seed provided, rules disabled") + } else { if err != nil { utils.Fatalf(err.Error()) } - log.Info("Loaded 4byte db", "signatures", db.Size(), "file", c.String("4bytedb")) + vaultLocation := filepath.Join(configDir, common.Bytes2Hex(crypto.Keccak256([]byte("vault"), stretchedKey)[:10])) - var ( - api core.ExternalAPI - listener net.Listener - server = rpc.NewServer() - ) + // Generate domain specific keys + pwkey := crypto.Keccak256([]byte("credentials"), stretchedKey) + jskey := crypto.Keccak256([]byte("jsstorage"), stretchedKey) + confkey := crypto.Keccak256([]byte("config"), stretchedKey) - api_impl := core.NewSignerAPI( - c.Int64(utils.NetworkIdFlag.Name), - c.String("keystore"), - c.Bool(utils.NoUSBFlag.Name), - ui, db, - c.Bool(utils.LightKDFFlag.Name)) + // Initialize the encrypted storages + pw_storage := storage.NewAESEncryptedStorage(filepath.Join(vaultLocation, "credentials.json"), pwkey) + js_storage := storage.NewAESEncryptedStorage(filepath.Join(vaultLocation, "jsstorage.json"), jskey) + config_storage := storage.NewAESEncryptedStorage(filepath.Join(vaultLocation, "config.json"), confkey) - api = api_impl - - // Audit logging - if logfile := c.String("auditlog"); logfile != "" { - api, err = core.NewAuditLogger(logfile, api_impl) - if err != nil { - utils.Fatalf(err.Error()) + //Do we have a rule-file? + ruleJS, err := ioutil.ReadFile(c.String(ruleFlag.Name)) + if err != nil { + log.Info("Could not load rulefile, rules not enabled", "file", "rulefile") + } else { + hasher := sha256.New() + hasher.Write(ruleJS) + shasum := hasher.Sum(nil) + stored_shasum := config_storage.Get("ruleset_sha256") + if stored_shasum != hex.EncodeToString(shasum) { + log.Info("Could not validate ruleset hash, rules not enabled", "got", hex.EncodeToString(shasum), "expected", stored_shasum) + } else { + // Initialize rules + ruleEngine, err := rules.NewRuleEvaluator(ui, js_storage, pw_storage) + if err != nil { + utils.Fatalf(err.Error()) + } + ruleEngine.Init(string(ruleJS)) + ui = ruleEngine + log.Info("Rule engine configured", "file", c.String(ruleFlag.Name)) } - log.Info("Audit logs configured", "file", logfile) } - // register signer API with server - if err = server.RegisterName("account", api); err != nil { - utils.Fatalf("Could not register signer API: %v", err) - } - - // Import from file - if rfile := c.String("requestfile"); rfile != "" { - //Each line of file represents one request - log.Warn("Import from file not yet implemented") - } - - // start http server - endpoint := fmt.Sprintf("%s:%d", c.String(utils.RPCListenAddrFlag.Name), c.Int("rpcport")) - if listener, err = net.Listen("tcp", endpoint); err != nil { - utils.Fatalf("Could not start http listener: %v", err) - } - extapi_url := fmt.Sprintf("http://%s", endpoint) - log.Info("HTTP endpoint opened", "url", extapi_url) - cors := []string{"*"} - - if c.Bool("stdio-ui-test") { - log.Info("Performing UI test") - go testExternalUI(api_impl) - } - ui.OnSignerStartup(core.StartupInfo{ - Info: map[string]interface{}{ - "extapi_version": EXT_API_VERSION, - "intapi_version": INT_API_VERSION, - "extapi_http": extapi_url, - "extapi_ipc": nil, - }, - }) - - rpc.NewHTTPServer(cors, server).Serve(listener) - - return nil } - app.Run(os.Args) + apiImpl := core.NewSignerAPI( + c.Int64(utils.NetworkIdFlag.Name), + c.String(keystoreFlag.Name), + c.Bool(utils.NoUSBFlag.Name), + ui, db, + c.Bool(utils.LightKDFFlag.Name)) + + api = apiImpl + + // Audit logging + if logfile := c.String(auditLogFlag.Name); logfile != "" { + api, err = core.NewAuditLogger(logfile, api) + if err != nil { + utils.Fatalf(err.Error()) + } + log.Info("Audit logs configured", "file", logfile) + } + // register signer API with server + if err = server.RegisterName("account", api); err != nil { + utils.Fatalf("Could not register signer API: %v", err) + } + + // start http server + endpoint := fmt.Sprintf("%s:%d", c.String(utils.RPCListenAddrFlag.Name), c.Int(rpcPortFlag.Name)) + if listener, err = net.Listen("tcp", endpoint); err != nil { + utils.Fatalf("Could not start http listener: %v", err) + } + extapi_url := fmt.Sprintf("http://%s", endpoint) + log.Info("HTTP endpoint opened", "url", extapi_url) + cors := []string{"*"} + + if c.Bool(testFlag.Name) { + log.Info("Performing UI test") + go testExternalUI(apiImpl) + } + ui.OnSignerStartup(core.StartupInfo{ + Info: map[string]interface{}{ + "extapi_version": EXT_API_VERSION, + "intapi_version": INT_API_VERSION, + "extapi_http": extapi_url, + "extapi_ipc": nil, + }, + }) + + rpc.NewHTTPServer(cors, server).Serve(listener) + + return nil +} + +// DefaultConfigDir is the default config directory to use for the vaults and other +// persistence requirements. +func DefaultConfigDir() string { + // Try to place the data folder in the user's home dir + home := homeDir() + if home != "" { + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Signer") + } else if runtime.GOOS == "windows" { + return filepath.Join(home, "AppData", "Roaming", "Signer") + } else { + return filepath.Join(home, ".signer") + } + } + // As we cannot guess a stable location, return empty and handle later + return "" +} + +func homeDir() string { + if home := os.Getenv("HOME"); home != "" { + return home + } + if usr, err := user.Current(); err == nil { + return usr.HomeDir + } + return "" +} +func readMasterKey(ctx *cli.Context) ([]byte, error) { + var ( + file string + configDir = ctx.String(configdirFlag.Name) + ) + if ctx.IsSet(signerSecretFlag.Name) { + file = ctx.String(signerSecretFlag.Name) + } else { + file = filepath.Join(configDir, "secrets.dat") + } + if err := checkFile(file); err != nil { + return nil, err + } + masterKey, err := ioutil.ReadFile(file) + if err != nil { + return nil, err + } + if len(masterKey) < 256 { + return nil, fmt.Errorf("Master key of insufficient length, expected >255 bytes, got %d", len(masterKey)) + } + // Create vault location + vaultLocation := filepath.Join(configDir, common.Bytes2Hex(crypto.Keccak256([]byte("vault"), masterKey)[:10])) + err = os.Mkdir(vaultLocation, 0700) + if err != nil && !os.IsExist(err) { + return nil, err + } + //!TODO, use KDF to stretch the master key + // stretched_key := stretch_key(master_key) + + return masterKey, nil +} + +// checkFile is a convenience function to check if a file +// * exists +// * is mode 0600 +func checkFile(filename string) error { + info, err := os.Stat(filename) + if err != nil { + return fmt.Errorf("failed stat on %s: %v", filename, err) + } + // Check the unix permission bits + if info.Mode().Perm()&077 != 0 { + return fmt.Errorf("file (%v) has insecure file permissions (%v)", filename, info.Mode().String()) + } + return nil +} + +// confirm displays a text and asks for user confirmation +func confirm(text string) bool { + fmt.Printf(text) + fmt.Printf("\nEnter 'ok' to proceed:\n>") + + text, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + log.Crit("Failed to read user input", "err", err) + } + + if text := strings.TrimSpace(text); text == "ok" { + return true + } + return false } func testExternalUI(api *core.SignerAPI) { @@ -253,4 +575,9 @@ curl -i -H "Content-Type: application/json" -X POST --data '{"jsonrpc":"2.0","me // Not supplied curl -i -H "Content-Type: application/json" -X POST --data '{"jsonrpc":"2.0","method":"account_signTransaction","params":["0x82A2A876D39022B3019932D30Cd9c97ad5616813",{"gas":"0x333","gasPrice":"0x123","nonce":"0x0","to":"0x07a565b7ed7d7a678680a4c162885bedbb695fe0", "value":"0x10", "data":"0x4401a6e40000000000000000000000000000000000000000000000000000000000000012"}],"id":67}' http://localhost:8550/ +// Sign data + +curl -i -H "Content-Type: application/json" -X POST --data '{"jsonrpc":"2.0","method":"account_sign","params":["0x694267f14675d7e1b9494fd8d72fefe1755710fa","bazonk gaz baz"],"id":67}' http://localhost:8550/ + + **/ diff --git a/cmd/signer/rules/rules.go b/cmd/signer/rules/rules.go index cdab8c776f..db78720d69 100644 --- a/cmd/signer/rules/rules.go +++ b/cmd/signer/rules/rules.go @@ -19,14 +19,16 @@ package rules import ( "encoding/json" "fmt" + "os" + "strings" + "github.com/ethereum/go-ethereum/cmd/signer/core" "github.com/ethereum/go-ethereum/cmd/signer/rules/deps" "github.com/ethereum/go-ethereum/cmd/signer/storage" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/internal/ethapi" "github.com/ethereum/go-ethereum/log" "github.com/robertkrimen/otto" - "os" - "strings" ) var ( @@ -47,18 +49,18 @@ func consoleOutput(call otto.FunctionCall) otto.Value { // rulesetUi provides an implementation of SignerUI that evaluates a javascript // file for each defined UI-method type rulesetUi struct { - // vm *otto.Otto // The JS vm - next core.SignerUI // The next handler, for manual processing - storage storage.Storage - jsRules string // The rules to use + next core.SignerUI // The next handler, for manual processing + storage storage.Storage + credentials storage.Storage + jsRules string // The rules to use } -func NewRuleEvaluator(next core.SignerUI) (*rulesetUi, error) { +func NewRuleEvaluator(next core.SignerUI, jsbackend, credentialsBackend storage.Storage) (*rulesetUi, error) { c := &rulesetUi{ - // vm: otto.New(), - next: next, - storage: storage.NewEphemeralStorage(), - jsRules: "", + next: next, + storage: jsbackend, + credentials: credentialsBackend, + jsRules: "", } return c, nil @@ -97,7 +99,6 @@ func (r *rulesetUi) execute(jsfunc string, jsarg interface{}) (otto.Value, error // All calls are objects with the parameters being keys in that object. // To provide additional insulation between js and go, we serialize it into JSON on the Go-side, // and deserialize it on the JS side. - //argdata := "" jsonbytes, err := json.Marshal(jsarg) if err != nil { @@ -142,24 +143,34 @@ func (r *rulesetUi) ApproveTx(request *core.SignTxRequest) (core.SignTxResponse, jsonreq, err := json.Marshal(request) approved, err := r.checkApproval("ApproveTx", jsonreq, err) if err != nil { - log.Info("Rule-based approval error, going to manual", "error", "err") + log.Info("Rule-based approval error, going to manual", "error", err) return r.next.ApproveTx(request) } + if approved { - return core.SignTxResponse{Transaction: request.Transaction, Approved: true, Password: ""}, nil + return core.SignTxResponse{ + Transaction: request.Transaction, + Approved: true, + Password: r.lookupPassword(request.Transaction.From.Address()), + }, + nil } return core.SignTxResponse{Approved: false}, err } +func (r *rulesetUi) lookupPassword(address common.Address) string { + return r.credentials.Get(strings.ToLower(address.String())) +} + func (r *rulesetUi) ApproveSignData(request *core.SignDataRequest) (core.SignDataResponse, error) { jsonreq, err := json.Marshal(request) approved, err := r.checkApproval("ApproveSignData", jsonreq, err) if err != nil { - log.Info("Rule-based approval error, going to manual", "error", "err") + log.Info("Rule-based approval error, going to manual", "error", err) return r.next.ApproveSignData(request) } if approved { - return core.SignDataResponse{Approved: true, Password: ""}, nil + return core.SignDataResponse{Approved: true, Password: r.lookupPassword(request.Address.Address())}, nil } return core.SignDataResponse{Approved: false, Password: ""}, err } @@ -168,7 +179,7 @@ func (r *rulesetUi) ApproveExport(request *core.ExportRequest) (core.ExportRespo jsonreq, err := json.Marshal(request) approved, err := r.checkApproval("ApproveExport", jsonreq, err) if err != nil { - log.Info("Rule-based approval error, going to manual", "error", "err") + log.Info("Rule-based approval error, going to manual", "error", err) return r.next.ApproveExport(request) } if approved { @@ -187,7 +198,7 @@ func (r *rulesetUi) ApproveListing(request *core.ListRequest) (core.ListResponse jsonreq, err := json.Marshal(request) approved, err := r.checkApproval("ApproveListing", jsonreq, err) if err != nil { - log.Info("Rule-based approval error, going to manual", "error", "err") + log.Info("Rule-based approval error, going to manual", "error", err) return r.next.ApproveListing(request) } if approved { @@ -211,6 +222,18 @@ func (r *rulesetUi) ShowInfo(message string) { log.Info(message) r.next.ShowInfo(message) } +func (r *rulesetUi) OnSignerStartup(info core.StartupInfo) { + jsonInfo, err := json.Marshal(info) + if err != nil { + log.Warn("failed marshalling data", "data", info) + return + } + r.next.OnSignerStartup(info) + _, err = r.execute("OnSignerStartup", string(jsonInfo)) + if err != nil { + log.Info("error occurred during execution", "error", err) + } +} func (r *rulesetUi) OnApprovedTx(tx ethapi.SignTransactionResult) { jsonTx, err := json.Marshal(tx) diff --git a/cmd/signer/rules/rules_test.go b/cmd/signer/rules/rules_test.go index 44e47f6f30..d288933c12 100644 --- a/cmd/signer/rules/rules_test.go +++ b/cmd/signer/rules/rules_test.go @@ -2,15 +2,17 @@ package rules import ( "fmt" + "math/big" + "strings" + "testing" + "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/cmd/signer/core" + "github.com/ethereum/go-ethereum/cmd/signer/storage" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/internal/ethapi" - "math/big" - "strings" - "testing" ) const JS = ` @@ -97,7 +99,7 @@ func (alwaysDenyUi) OnApprovedTx(tx ethapi.SignTransactionResult) { } func initRuleEngine(js string) (*rulesetUi, error) { - r, err := NewRuleEvaluator(&alwaysDenyUi{}) + r, err := NewRuleEvaluator(&alwaysDenyUi{}, storage.NewEphemeralStorage(), storage.NewEphemeralStorage()) if err != nil { return nil, fmt.Errorf("Failed to create js engine: %v", err) } @@ -234,7 +236,9 @@ func TestForwarding(t *testing.T) { js := "" ui := &dummyUi{make([]string, 0)} - r, err := NewRuleEvaluator(ui) + jsbackend := storage.NewEphemeralStorage() + credbackend := storage.NewEphemeralStorage() + r, err := NewRuleEvaluator(ui, jsbackend, credbackend) if err != nil { t.Fatalf("Failed to create js engine: %v", err) } @@ -459,10 +463,7 @@ func TestLimitWindow(t *testing.T) { t.Errorf("Couldn't create evaluator %v", err) return } - if err != nil { - t.Error(err) - return - } + // 0.3 ether: 429D069189E0000 wei v := big.NewInt(0).SetBytes(common.Hex2Bytes("0429D069189E0000")) h := hexutil.Big(*v) @@ -562,7 +563,7 @@ func TestContextIsCleared(t *testing.T) { } ` ui := &dontCallMe{t} - r, err := NewRuleEvaluator(ui) + r, err := NewRuleEvaluator(ui, storage.NewEphemeralStorage(), storage.NewEphemeralStorage()) if err != nil { t.Fatalf("Failed to create js engine: %v", err) } @@ -576,3 +577,44 @@ func TestContextIsCleared(t *testing.T) { t.Errorf("Expected execution context to be cleared between executions") } } + +func TestSignData(t *testing.T) { + + js := `function ApproveListing(){ + return "Approve" +} +function ApproveSignData(r){ + if( r.address.toLowerCase() == "0x694267f14675d7e1b9494fd8d72fefe1755710fa") + { + if(r.message.indexOf("bazonk") >= 0){ + return "Approve" + } + return "Reject" + } + // Otherwise goes to manual processing +}` + r, err := initRuleEngine(js) + if err != nil { + t.Errorf("Couldn't create evaluator %v", err) + return + } + message := []byte("baz bazonk foo") + hash, msg := core.SignHash(message) + raw := hexutil.Bytes(message) + addr, _ := mixAddr("0x694267f14675d7e1b9494fd8d72fefe1755710fa") + + fmt.Printf("address %v %v\n", addr.String(), addr.Original()) + resp, err := r.ApproveSignData(&core.SignDataRequest{ + Address: *addr, + Message: msg, + Hash: hash, + Meta: core.Metadata{"remoteip", "localip", "inproc"}, + Rawdata: raw, + }) + if err != nil { + t.Fatalf("Unexpected error", err) + } + if !resp.Approved { + t.Fatalf("Expected approved") + } +} diff --git a/cmd/signer/storage/aes_gcm_storage.go b/cmd/signer/storage/aes_gcm_storage.go new file mode 100644 index 0000000000..66817b4504 --- /dev/null +++ b/cmd/signer/storage/aes_gcm_storage.go @@ -0,0 +1,148 @@ +package storage + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/json" + "io" + "io/ioutil" + "os" + + "github.com/ethereum/go-ethereum/log" +) + +type storedCredential struct { + // The iv + Iv []byte `json:"iv"` + // The ciphertext + CipherText []byte `json:"c"` +} + +// AESEncryptedStorage is a storage type which is backed by a json-faile. The json-file contains +// key-value mappings, where the keys are _not_ encrypted, only the values are. +type AESEncryptedStorage struct { + // File to read/write credentials + filename string + // Key stored in base64 + key []byte +} + +// NewAESEncryptedStorage creates a new encrypted storage backed by the given file/key +func NewAESEncryptedStorage(filename string, key []byte) *AESEncryptedStorage { + return &AESEncryptedStorage{ + filename: filename, + key: key, + } +} + +// Put stores a value by key. 0-length keys results in no-op +func (s *AESEncryptedStorage) Put(key, value string) { + if len(key) == 0 { + return + } + data, err := s.readEncryptedStorage() + if err != nil { + log.Warn("Failed to read encrypted storage", "err", err, "file", s.filename) + return + } + ciphertext, iv, err := encrypt(s.key, []byte(value)) + if err != nil { + log.Warn("Failed to encrypt entry", "err", err) + return + } + encrypted := storedCredential{Iv: iv, CipherText: ciphertext} + data[key] = encrypted + if err = s.writeEncryptedStorage(data); err != nil { + log.Warn("Failed to write entry", "err", err) + } +} + +// Get returns the previously stored value, or the empty string if it does not exist or key is of 0-length +func (s *AESEncryptedStorage) Get(key string) string { + if len(key) == 0 { + return "" + } + data, err := s.readEncryptedStorage() + if err != nil { + log.Warn("Failed to read encrypted storage", "err", err, "file", s.filename) + return "" + } + encrypted, exist := data[key] + if !exist { + log.Warn("Key does not exist", "key", key) + return "" + } + entry, err := decrypt(s.key, encrypted.Iv, encrypted.CipherText) + if err != nil { + log.Warn("Failed to decrypt key", "key", key) + return "" + } + return string(entry) +} + +// readEncryptedStorage reads the file with encrypted creds +func (s *AESEncryptedStorage) readEncryptedStorage() (map[string]storedCredential, error) { + creds := make(map[string]storedCredential) + raw, err := ioutil.ReadFile(s.filename) + + if err != nil { + if os.IsNotExist(err) { + // Doesn't exist yet + return creds, nil + + } else { + log.Warn("Failed to read encrypted storage", "err", err, "file", s.filename) + } + } + if err = json.Unmarshal(raw, &creds); err != nil { + log.Warn("Failed to unmarshal encrypted storage", "err", err, "file", s.filename) + return nil, err + } + return creds, nil +} + +// writeEncryptedStorage write the file with encrypted creds +func (s *AESEncryptedStorage) writeEncryptedStorage(creds map[string]storedCredential) error { + raw, err := json.Marshal(creds) + if err != nil { + return err + } + if err = ioutil.WriteFile(s.filename, raw, 0600); err != nil { + return err + } + return nil +} + +func encrypt(key []byte, plaintext []byte) ([]byte, []byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, err + } + aesgcm, err := cipher.NewGCM(block) + nonce := make([]byte, aesgcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, err + } + if err != nil { + return nil, nil, err + } + ciphertext := aesgcm.Seal(nil, nonce, plaintext, nil) + return ciphertext, nonce, nil +} + +func decrypt(key []byte, nonce []byte, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + return plaintext, nil +} diff --git a/cmd/signer/storage/aes_gcm_storage_test.go b/cmd/signer/storage/aes_gcm_storage_test.go new file mode 100644 index 0000000000..e9bfa9e91d --- /dev/null +++ b/cmd/signer/storage/aes_gcm_storage_test.go @@ -0,0 +1,99 @@ +package storage + +import ( + "bytes" + "fmt" + "io/ioutil" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + "github.com/mattn/go-colorable" +) + +func TestEncryption(t *testing.T) { + // key := []byte("AES256Key-32Characters1234567890") + // plaintext := []byte(value) + key := []byte("AES256Key-32Characters1234567890") + plaintext := []byte("exampleplaintext") + + c, iv, err := encrypt(key, plaintext) + if err != nil { + t.Fatal(err) + } + fmt.Printf("Ciphertext %x, nonce %x\n", c, iv) + + p, err := decrypt(key, iv, c) + if err != nil { + t.Fatal(err) + } + fmt.Printf("Plaintext %v\n", string(p)) + if !bytes.Equal(plaintext, p) { + t.Errorf("Failed: expected plaintext recovery, got %v expected %v", string(plaintext), string(p)) + } +} + +func TestFileStorage(t *testing.T) { + + a := map[string]storedCredential{ + "secret": storedCredential{ + Iv: common.Hex2Bytes("cdb30036279601aeee60f16b"), + CipherText: common.Hex2Bytes("f311ac49859d7260c2c464c28ffac122daf6be801d3cfd3edcbde7e00c9ff74f"), + }, + "secret2": storedCredential{ + Iv: common.Hex2Bytes("afb8a7579bf971db9f8ceeed"), + CipherText: common.Hex2Bytes("2df87baf86b5073ef1f03e3cc738de75b511400f5465bb0ddeacf47ae4dc267d"), + }, + } + d, err := ioutil.TempDir("", "eth-encrypted-storage-test") + if err != nil { + t.Fatal(err) + } + stored := &AESEncryptedStorage{ + filename: fmt.Sprintf("%v/vault.json", d), + key: []byte("AES256Key-32Characters1234567890"), + } + stored.writeEncryptedStorage(a) + read := &AESEncryptedStorage{ + filename: fmt.Sprintf("%v/vault.json", d), + key: []byte("AES256Key-32Characters1234567890"), + } + creds, err := read.readEncryptedStorage() + if err != nil { + t.Fatal(err) + } + for k, v := range a { + if v2, exist := creds[k]; !exist { + t.Errorf("Missing entry %v", k) + } else { + if !bytes.Equal(v.CipherText, v2.CipherText) { + t.Errorf("Wrong ciphertext, expected %x got %x", v.CipherText, v2.CipherText) + } + if !bytes.Equal(v.Iv, v2.Iv) { + t.Errorf("Wrong iv") + } + } + } +} +func TestEnd2End(t *testing.T) { + log.Root().SetHandler(log.LvlFilterHandler(log.Lvl(3), log.StreamHandler(colorable.NewColorableStderr(), log.TerminalFormat(true)))) + + d, err := ioutil.TempDir("", "eth-encrypted-storage-test") + if err != nil { + t.Fatal(err) + } + + s1 := &AESEncryptedStorage{ + filename: fmt.Sprintf("%v/vault.json", d), + key: []byte("AES256Key-32Characters1234567890"), + } + s2 := &AESEncryptedStorage{ + filename: fmt.Sprintf("%v/vault.json", d), + key: []byte("AES256Key-32Characters1234567890"), + } + + s1.Put("bazonk", "foobar") + if v := s2.Get("bazonk"); v != "foobar" { + t.Errorf("Expected bazonk->foobar, got '%v'", v) + } +} diff --git a/cmd/signer/storage/storage.go b/cmd/signer/storage/storage.go index 6b2f8c6660..60f4e3892a 100644 --- a/cmd/signer/storage/storage.go +++ b/cmd/signer/storage/storage.go @@ -26,8 +26,6 @@ type Storage interface { Put(key, value string) // Get returns the previously stored value, or the empty string if it does not exist or key is of 0-length Get(key string) string - // New creates a new (sub) namespace for the storage - New(namespace string) Storage } // EphemeralStorage is an in-memory storage that does @@ -41,17 +39,14 @@ func (s *EphemeralStorage) Put(key, value string) { if len(key) == 0 { return } - key = fmt.Sprintf("%s.%s", s.namespace, key) fmt.Printf("storage: put %v -> %v\n", key, value) s.data[key] = value } func (s *EphemeralStorage) Get(key string) string { - if len(key) == 0 { return "" } - key = fmt.Sprintf("%s.%s", s.namespace, key) fmt.Printf("storage: get %v\n", key) if v, exist := s.data[key]; exist { return v @@ -59,18 +54,9 @@ func (s *EphemeralStorage) Get(key string) string { return "" } -func (s *EphemeralStorage) New(namespace string) Storage { - child := &EphemeralStorage{ - data: make(map[string]string), - namespace: fmt.Sprintf("%s.%s", s.namespace, namespace), - } - return child -} - func NewEphemeralStorage() Storage { s := &EphemeralStorage{ - data: make(map[string]string), - namespace: "root", + data: make(map[string]string), } return s } diff --git a/cmd/signer/tutorial.md b/cmd/signer/tutorial.md new file mode 100644 index 0000000000..d59e08ac7a --- /dev/null +++ b/cmd/signer/tutorial.md @@ -0,0 +1,198 @@ +## Initializing the signer + +First, initialize the master seed. + +```text +#./signer init + +WARNING! + +The signer is alpha software, and not yet publically released. This software has _not_ been audited, and there +are no guarantees about the workings of this software. It may contain severe flaws. You should not use this software +unless you agree to take full responsibility for doing so, and know what you are doing. + +TLDR; THIS IS NOT PRODUCTION-READY SOFTWARE! + + +Enter 'ok' to proceed: +>ok +A master seed has been generated into /home/martin/.signer/secrets.dat + +This is required to be able to store credentials, such as : +* Passwords for keystores (used by rule engine) +* Storage for javascript rules +* Hash of rule-file + +You should treat that file with utmost secrecy, and make a backup of it. +NOTE: This file does not contain your accounts. Those need to be backed up separately! +``` + +(for readability purposes, we'll remove the WARNING printout in the rest of this document) + +## Creating rules + +Now, you can create a rule-file. + +```javascript +function ApproveListing(){ + return "Approve" +} +``` +Get the `sha256` hash.... +```text +#sha256sum rules.js +6c21d1737429d6d4f2e55146da0797782f3c0a0355227f19d702df377c165d72 rules.js +``` +...And then `attest` the file: +```text +#./signer attest 6c21d1737429d6d4f2e55146da0797782f3c0a0355227f19d702df377c165d72 + +INFO [02-21|12:14:38] Ruleset attestation updated sha256=6c21d1737429d6d4f2e55146da0797782f3c0a0355227f19d702df377c165d72 +``` +At this point, we then start the signer with the rule-file: + +```text +#./signer --rules rules.json + +INFO [02-21|12:15:18] Using CLI as UI-channel +INFO [02-21|12:15:18] Loaded 4byte db signatures=5509 file=./4byte.json +INFO [02-21|12:15:18] Could not load rulefile, rules not enabled file=rulefile +DEBUG[02-21|12:15:18] FS scan times list=35.335µs set=5.536µs diff=5.073µs +DEBUG[02-21|12:15:18] Ledger support enabled +DEBUG[02-21|12:15:18] Trezor support enabled +INFO [02-21|12:15:18] Audit logs configured file=audit.log +INFO [02-21|12:15:18] HTTP endpoint opened url=http://localhost:8550 +------- Signer info ------- +* extapi_http : http://localhost:8550 +* extapi_ipc : +* extapi_version : 2.0.0 +* intapi_version : 1.2.0 + +``` + +Any list-requests will now be auto-approved by our rule-file. + +## Under the hood + +While doing the operations above, these files have been created: + +```text +#ls -laR ~/.signer/ +/home/martin/.signer/: +total 16 +drwx------ 3 martin martin 4096 feb 21 12:14 . +drwxr-xr-x 71 martin martin 4096 feb 21 12:12 .. +drwx------ 2 martin martin 4096 feb 21 12:14 43f73718397aa54d1b22 +-rwx------ 1 martin martin 256 feb 21 12:12 secrets.dat + +/home/martin/.signer/43f73718397aa54d1b22: +total 12 +drwx------ 2 martin martin 4096 feb 21 12:14 . +drwx------ 3 martin martin 4096 feb 21 12:14 .. +-rw------- 1 martin martin 159 feb 21 12:14 config.json + +#cat /home/martin/.signer/43f73718397aa54d1b22/config.json +{"ruleset_sha256":{"iv":"6v4W4tfJxj3zZFbl","c":"6dt5RTDiTq93yh1qDEjpsat/tsKG7cb+vr3sza26IPL2fvsQ6ZoqFx++CPUa8yy6fD9Bbq41L01ehkKHTG3pOAeqTW6zc/+t0wv3AB6xPmU="}} + +``` + +In `~/.signer`, the `secrets.dat` file was created, containing the `master_seed`. +The `master_seed` was then used to derive a few other things: + +- `vault_location` : in this case `43f73718397aa54d1b22` . + - Thus, if you use a different `master_seed`, another `vault_location` will be used that does not conflict with each other. + - Example: `signer --signersecret /path/to/afile ...` +- `config.json` which is the encrypted key/value storage for configuration data, containing the key `ruleset_sha256`. + + +## Adding credentials + +In order to make more useful rules; sign transactions, the signer needs access to the passwords needed to unlock keystores. + +```text +#./signer addpw 0x694267f14675d7e1b9494fd8d72fefe1755710fa test + +INFO [02-21|13:43:21] Credential store updated key=0x694267f14675d7e1b9494fd8d72fefe1755710fa +``` +## More advanced rules + +Now let's update the rules to make use of credentials + +```javascript +function ApproveListing(){ + return "Approve" +} +function ApproveSignData(r){ + if( r.address.toLowerCase() == "0x694267f14675d7e1b9494fd8d72fefe1755710fa") + { + if(r.message.indexOf("bazonk") >= 0){ + return "Approve" + } + return "Reject" + } + // Otherwise goes to manual processing +} + +``` +In this example, +* any requests to sign data with the account `0x694...` will be + * auto-approved if the message contains with `bazonk`, + * and auto-rejected if it does not. + * Any other signing-requests will be passed along for manual approve/reject. + +..attest the new file +```text +#sha256sum rules.js +2a0cb661dacfc804b6e95d935d813fd17c0997a7170e4092ffbc34ca976acd9f rules.js + +#./signer attest 2a0cb661dacfc804b6e95d935d813fd17c0997a7170e4092ffbc34ca976acd9f + +INFO [02-21|14:36:30] Ruleset attestation updated sha256=2a0cb661dacfc804b6e95d935d813fd17c0997a7170e4092ffbc34ca976acd9f +``` + +And start the signer: + +``` +#./signer --rules rules.js + +INFO [02-21|14:41:56] Using CLI as UI-channel +INFO [02-21|14:41:56] Loaded 4byte db signatures=5509 file=./4byte.json +INFO [02-21|14:41:56] Rule engine configured file=rules.js +DEBUG[02-21|14:41:56] FS scan times list=34.607µs set=4.509µs diff=4.87µs +DEBUG[02-21|14:41:56] Ledger support enabled +DEBUG[02-21|14:41:56] Trezor support enabled +INFO [02-21|14:41:56] Audit logs configured file=audit.log +INFO [02-21|14:41:56] HTTP endpoint opened url=http://localhost:8550 +------- Signer info ------- +* extapi_version : 2.0.0 +* intapi_version : 1.2.0 +* extapi_http : http://localhost:8550 +* extapi_ipc : +INFO [02-21|14:41:56] error occurred during execution error="ReferenceError: 'OnSignerStartup' is not defined" +``` +And then test signing, once with `bazonk` and once without: + +``` +#curl -H "Content-Type: application/json" -X POST --data "{\"jsonrpc\":\"2.0\",\"method\":\"account_sign\",\"params\":[\"0x694267f14675d7e1b9494fd8d72fefe1755710fa\",\"0x$(xxd -pu <<< ' bazonk baz gaz')\"],\"id\":67}" http://localhost:8550/ +{"jsonrpc":"2.0","id":67,"result":"0x93e6161840c3ae1efc26dc68dedab6e8fc233bb3fefa1b4645dbf6609b93dace160572ea4ab33240256bb6d3dadb60dcd9c515d6374d3cf614ee897408d41d541c"} + +#curl -H "Content-Type: application/json" -X POST --data "{\"jsonrpc\":\"2.0\",\"method\":\"account_sign\",\"params\":[\"0x694267f14675d7e1b9494fd8d72fefe1755710fa\",\"0x$(xxd -pu <<< ' bonk baz gaz')\"],\"id\":67}" http://localhost:8550/ +{"jsonrpc":"2.0","id":67,"error":{"code":-32000,"message":"Request denied"}} + +``` + +Meanwhile, in the signer output: +```text +INFO [02-21|14:42:41] Op approved +INFO [02-21|14:42:56] Op rejected +``` + +The signer also stores all traffic over the external API in a log file. The last 4 lines shows the two requests and their responses: + +```text +#tail audit.log -n 4 +t=2018-02-21T14:42:41+0100 lvl=info msg=Sign api=signer type=request metadata="{\"remote\":\"127.0.0.1:49706\",\"local\":\"localhost:8550\",\"scheme\":\"HTTP/1.1\"}" addr="0x694267f14675d7e1b9494fd8d72fefe1755710fa [chksum INVALID]" data=202062617a6f6e6b2062617a2067617a0a +t=2018-02-21T14:42:42+0100 lvl=info msg=Sign api=signer type=response data=93e6161840c3ae1efc26dc68dedab6e8fc233bb3fefa1b4645dbf6609b93dace160572ea4ab33240256bb6d3dadb60dcd9c515d6374d3cf614ee897408d41d541c error=nil +t=2018-02-21T14:42:56+0100 lvl=info msg=Sign api=signer type=request metadata="{\"remote\":\"127.0.0.1:49708\",\"local\":\"localhost:8550\",\"scheme\":\"HTTP/1.1\"}" addr="0x694267f14675d7e1b9494fd8d72fefe1755710fa [chksum INVALID]" data=2020626f6e6b2062617a2067617a0a +t=2018-02-21T14:42:56+0100 lvl=info msg=Sign api=signer type=response data= error="Request denied" +```