diff --git a/cmd/ostracon/commands/reset_test.go b/cmd/ostracon/commands/reset_test.go index 781d5301e..0918745a7 100644 --- a/cmd/ostracon/commands/reset_test.go +++ b/cmd/ostracon/commands/reset_test.go @@ -13,11 +13,12 @@ import ( "github.com/line/ostracon/privval" ) -func setupEnv(t *testing.T) { +func setupEnv(t *testing.T) string { rootDir := t.TempDir() viper.SetEnvPrefix("OC") require.NoError(t, viper.BindEnv("HOME")) require.NoError(t, os.Setenv("OC_HOME", rootDir)) + return rootDir } func TestResetAllCmd(t *testing.T) { diff --git a/cmd/ostracon/commands/show_validator.go b/cmd/ostracon/commands/show_validator.go index 6217c0118..27678a4cc 100644 --- a/cmd/ostracon/commands/show_validator.go +++ b/cmd/ostracon/commands/show_validator.go @@ -3,8 +3,11 @@ package commands import ( "fmt" + "github.com/line/ostracon/node" + "github.com/line/ostracon/types" "github.com/spf13/cobra" + cfg "github.com/line/ostracon/config" tmjson "github.com/line/ostracon/libs/json" tmos "github.com/line/ostracon/libs/os" "github.com/line/ostracon/privval" @@ -15,18 +18,31 @@ var ShowValidatorCmd = &cobra.Command{ Use: "show-validator", Aliases: []string{"show_validator"}, Short: "Show this node's validator info", - RunE: showValidator, - PreRun: deprecateSnakeCase, + RunE: func(cmd *cobra.Command, args []string) error { + return showValidator(cmd, args, config) + }, + PreRun: deprecateSnakeCase, } -func showValidator(cmd *cobra.Command, args []string) error { - keyFilePath := config.PrivValidatorKeyFile() - if !tmos.FileExists(keyFilePath) { - return fmt.Errorf("private validator file %s does not exist", keyFilePath) +func showValidator(cmd *cobra.Command, args []string, config *cfg.Config) error { + var pv types.PrivValidator + if config.PrivValidatorListenAddr != "" { + chainID, err := loadChainID(config) + if err != nil { + return err + } + pv, err = node.CreateAndStartPrivValidatorSocketClient(config.PrivValidatorListenAddr, chainID, logger) + if err != nil { + return err + } + } else { + keyFilePath := config.PrivValidatorKeyFile() + if !tmos.FileExists(keyFilePath) { + return fmt.Errorf("private validator file %s does not exist", keyFilePath) + } + pv = privval.LoadFilePV(keyFilePath, config.PrivValidatorStateFile()) } - pv := privval.LoadFilePV(keyFilePath, config.PrivValidatorStateFile()) - pubKey, err := pv.GetPubKey() if err != nil { return fmt.Errorf("can't get pubkey: %w", err) @@ -40,3 +56,19 @@ func showValidator(cmd *cobra.Command, args []string) error { fmt.Println(string(bz)) return nil } + +func loadChainID(config *cfg.Config) (string, error) { + stateDB, err := node.DefaultDBProvider(&node.DBContext{ID: "state", Config: config}) + if err != nil { + return "", err + } + defer func() { + var _ = stateDB.Close() + }() + genesisDocProvider := node.DefaultGenesisDocProviderFunc(config) + _, genDoc, err := node.LoadStateFromDBOrGenesisDocProvider(stateDB, genesisDocProvider) + if err != nil { + return "", err + } + return genDoc.ChainID, nil +} diff --git a/cmd/ostracon/commands/show_validator_test.go b/cmd/ostracon/commands/show_validator_test.go new file mode 100644 index 000000000..041899016 --- /dev/null +++ b/cmd/ostracon/commands/show_validator_test.go @@ -0,0 +1,191 @@ +package commands + +import ( + "bytes" + "io/ioutil" + "os" + "sync" + "testing" + + "github.com/line/ostracon/types" + + cfg "github.com/line/ostracon/config" + "github.com/line/ostracon/crypto" + tmjson "github.com/line/ostracon/libs/json" + tmos "github.com/line/ostracon/libs/os" + "github.com/line/ostracon/privval" + "github.com/stretchr/testify/require" +) + +func TestShowValidator(t *testing.T) { + original := config + defer func() { + config = original + }() + + setupEnv(t) + config = cfg.DefaultConfig() + err := RootCmd.PersistentPreRunE(RootCmd, nil) + require.NoError(t, err) + init := NewInitCmd() + err = init.RunE(init, nil) + require.NoError(t, err) + output, err := captureStdout(func() { + err = ShowValidatorCmd.RunE(ShowValidatorCmd, nil) + require.NoError(t, err) + }) + require.NoError(t, err) + + // output must match the locally stored priv_validator key + privKey := loadFilePVKey(t, config.PrivValidatorKeyFile()) + bz, err := tmjson.Marshal(privKey.PubKey) + require.NoError(t, err) + require.Equal(t, string(bz), output) +} + +func TestShowValidatorWithoutLocalKeyFile(t *testing.T) { + setupEnv(t) + config := cfg.DefaultConfig() + if tmos.FileExists(config.PrivValidatorKeyFile()) { + err := os.Remove(config.PrivValidatorKeyFile()) + require.NoError(t, err) + } + err := showValidator(ShowValidatorCmd, nil, config) + require.Error(t, err) +} + +func TestShowValidatorWithKMS(t *testing.T) { + dir := setupEnv(t) + cfg.EnsureRoot(dir) + + original := config + defer func() { + config = original + }() + + config = cfg.DefaultConfig() + config.SetRoot(dir) + err := RootCmd.PersistentPreRunE(RootCmd, nil) + require.NoError(t, err) + init := NewInitCmd() + err = init.RunE(init, nil) + require.NoError(t, err) + + chainID, err := loadChainID(config) + require.NoError(t, err) + + if tmos.FileExists(config.PrivValidatorKeyFile()) { + err := os.Remove(config.PrivValidatorKeyFile()) + require.NoError(t, err) + } + privval.WithMockKMS(t, dir, chainID, func(addr string, privKey crypto.PrivKey) { + config.PrivValidatorListenAddr = addr + require.NoFileExists(t, config.PrivValidatorKeyFile()) + output, err := captureStdout(func() { + err := showValidator(ShowValidatorCmd, nil, config) + require.NoError(t, err) + }) + require.NoError(t, err) + + // output must contains the KMS public key + bz, err := tmjson.Marshal(privKey.PubKey()) + require.NoError(t, err) + expected := string(bz) + require.Contains(t, output, expected) + }) +} + +func TestShowValidatorWithInefficientKMSAddress(t *testing.T) { + dir := setupEnv(t) + cfg.EnsureRoot(dir) + + original := config + defer func() { + config = original + }() + + config = cfg.DefaultConfig() + config.SetRoot(dir) + err := RootCmd.PersistentPreRunE(RootCmd, nil) + require.NoError(t, err) + init := NewInitCmd() + err = init.RunE(init, nil) + require.NoError(t, err) + + if tmos.FileExists(config.PrivValidatorKeyFile()) { + err := os.Remove(config.PrivValidatorKeyFile()) + require.NoError(t, err) + } + config.PrivValidatorListenAddr = "127.0.0.1:inefficient" + err = showValidator(ShowValidatorCmd, nil, config) + require.Error(t, err) +} + +func TestLoadChainID(t *testing.T) { + expected := "c57861" + config := cfg.ResetTestRootWithChainID("TestLoadChainID", expected) + defer func() { + var _ = os.RemoveAll(config.RootDir) + }() + + require.FileExists(t, config.GenesisFile()) + genDoc, err := types.GenesisDocFromFile(config.GenesisFile()) + require.NoError(t, err) + require.Equal(t, expected, genDoc.ChainID) + + chainID, err := loadChainID(config) + require.NoError(t, err) + require.Equal(t, expected, chainID) +} + +func TestLoadChainIDWithoutStateDB(t *testing.T) { + expected := "c34091" + config := cfg.ResetTestRootWithChainID("TestLoadChainID", expected) + defer func() { + var _ = os.RemoveAll(config.RootDir) + }() + + config.DBBackend = "goleveldb" + config.DBPath = "/../path with containing chars that cannot be used\\/:*?\"<>|\x00" + + _, err := loadChainID(config) + require.Error(t, err) +} + +func loadFilePVKey(t *testing.T, file string) privval.FilePVKey { + // output must match the locally stored priv_validator key + keyJSONBytes, err := ioutil.ReadFile(file) + require.NoError(t, err) + privKey := privval.FilePVKey{} + err = tmjson.Unmarshal(keyJSONBytes, &privKey) + require.NoError(t, err) + return privKey +} + +var stdoutMutex sync.Mutex + +func captureStdout(f func()) (string, error) { + r, w, err := os.Pipe() + if err != nil { + return "", err + } + + stdoutMutex.Lock() + original := os.Stdout + defer func() { + stdoutMutex.Lock() + os.Stdout = original + stdoutMutex.Unlock() + }() + os.Stdout = w + stdoutMutex.Unlock() + + f() + _ = w.Close() + var buffer bytes.Buffer + if _, err := buffer.ReadFrom(r); err != nil { + return "", err + } + output := buffer.String() + return output[:len(output)-1], nil +} diff --git a/cmd/ostracon/main.go b/cmd/ostracon/main.go index b5540b8c6..0169b79e5 100644 --- a/cmd/ostracon/main.go +++ b/cmd/ostracon/main.go @@ -40,7 +40,7 @@ func main() { // * Provide their own DB implementation // can copy this file and use something other than the // DefaultNewNode function - nodeFunc := nm.DefaultNewNode + nodeFunc := nm.NewOstraconNode // Create & start node rootCmd.AddCommand(cmd.NewInitCmd()) diff --git a/config/toml.go b/config/toml.go index 0bca78e60..0a6d52731 100644 --- a/config/toml.go +++ b/config/toml.go @@ -560,6 +560,7 @@ func ResetTestRootWithChainID(testName string, chainID string) *Config { genesisFilePath := filepath.Join(rootDir, baseConfig.Genesis) privKeyFilePath := filepath.Join(rootDir, baseConfig.PrivValidatorKey) privStateFilePath := filepath.Join(rootDir, baseConfig.PrivValidatorState) + nodeKeyFilePath := filepath.Join(rootDir, baseConfig.NodeKey) // Write default config file if missing. if !tmos.FileExists(configFilePath) { @@ -575,6 +576,7 @@ func ResetTestRootWithChainID(testName string, chainID string) *Config { // we always overwrite the priv val tmos.MustWriteFile(privKeyFilePath, []byte(testPrivValidatorKey), 0644) tmos.MustWriteFile(privStateFilePath, []byte(testPrivValidatorState), 0644) + tmos.MustWriteFile(nodeKeyFilePath, []byte(testNodeKey), 0644) config := TestConfig().SetRoot(rootDir) return config @@ -633,6 +635,13 @@ var testPrivValidatorKey = `{ } }` +var testNodeKey = `{ + "priv_key": { + "type": "ostracon/PrivKeyEd25519", + "value": "hICuZLlVwHdzz6pAQOKk07MFn3Hze1EwwTUUhEDIdti9a1cQLR5Co/lxAzeGcyPWS/LuEr7qbgHmDUJT/nxx+Q==" + } +}` + var testPrivValidatorState = `{ "height": "0", "round": 0, diff --git a/node/node.go b/node/node.go index 5f6288a0d..fe799bd47 100644 --- a/node/node.go +++ b/node/node.go @@ -120,6 +120,32 @@ func DefaultNewNode(config *cfg.Config, logger log.Logger) (*Node, error) { ) } +// NewOstraconNode returns an Ostracon node for more safe production environments that don't automatically generate +// critical files. This function doesn't reference local key pair in configurations using KMS. +func NewOstraconNode(config *cfg.Config, logger log.Logger) (*Node, error) { + nodeKey, err := p2p.LoadNodeKey(config.NodeKeyFile()) + if err != nil { + return nil, fmt.Errorf("failed to load node key %s: %w", config.NodeKeyFile(), err) + } + + var privKey types.PrivValidator + if config.PrivValidatorListenAddr == "" { + privKey = privval.LoadFilePV( + config.PrivValidatorKeyFile(), + config.PrivValidatorStateFile()) + } + return NewNode( + config, + privKey, + nodeKey, + proxy.DefaultClientCreator(config.ProxyApp, config.ABCI, config.DBDir()), + DefaultGenesisDocProviderFunc(config), + DefaultDBProvider, + DefaultMetricsProvider(config.Instrumentation), + logger, + ) +} + // MetricsProvider returns a consensus, p2p and mempool Metrics. type MetricsProvider func(chainID string) (*cs.Metrics, *p2p.Metrics, *mempl.Metrics, *sm.Metrics) @@ -730,7 +756,7 @@ func NewNode(config *cfg.Config, // external signing process. if config.PrivValidatorListenAddr != "" { // FIXME: we should start services inside OnStart - privValidator, err = createAndStartPrivValidatorSocketClient(config.PrivValidatorListenAddr, genDoc.ChainID, logger) + privValidator, err = CreateAndStartPrivValidatorSocketClient(config.PrivValidatorListenAddr, genDoc.ChainID, logger) if err != nil { return nil, fmt.Errorf("error with private validator socket client: %w", err) } @@ -1438,7 +1464,7 @@ func saveGenesisDoc(db dbm.DB, genDoc *types.GenesisDoc) error { return nil } -func createAndStartPrivValidatorSocketClient( +func CreateAndStartPrivValidatorSocketClient( listenAddr, chainID string, logger log.Logger, diff --git a/node/node_test.go b/node/node_test.go index ca3335522..52c8cc494 100644 --- a/node/node_test.go +++ b/node/node_test.go @@ -36,6 +36,25 @@ import ( tmtime "github.com/line/ostracon/types/time" ) +func TestNewOstraconNode(t *testing.T) { + config := cfg.ResetTestRootWithChainID("TestNewOstraconNode", "new_ostracon_node") + defer os.RemoveAll(config.RootDir) + require.Equal(t, config.PrivValidatorListenAddr, "") + node, err := NewOstraconNode(config, log.TestingLogger()) + require.NoError(t, err) + pubKey, err := node.PrivValidator().GetPubKey() + require.NoError(t, err) + require.NotNil(t, pubKey) +} + +func TestNewOstraconNode_WithoutNodeKey(t *testing.T) { + config := cfg.ResetTestRootWithChainID("TestNewOstraconNode", "new_ostracon_node_wo_node_key") + defer os.RemoveAll(config.RootDir) + _ = os.Remove(config.NodeKeyFile()) + _, err := NewOstraconNode(config, log.TestingLogger()) + require.Error(t, err) +} + func TestNodeStartStop(t *testing.T) { config := cfg.ResetTestRoot("node_node_test") defer os.RemoveAll(config.RootDir) diff --git a/privval/test_util.go b/privval/test_util.go new file mode 100644 index 000000000..8ffdbe1c3 --- /dev/null +++ b/privval/test_util.go @@ -0,0 +1,58 @@ +package privval + +// This file defines the functions only used in the test. *DON'T add functions for production use*. + +import ( + "fmt" + "net" + "os" + "path" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/line/ostracon/crypto" + "github.com/line/ostracon/crypto/ed25519" + "github.com/line/ostracon/libs/log" +) + +// WithMockKMS function starts/stops a mock KMS function for testing on an unused local port. The continuation function +// f is passed the address to connect to and the private key that KMS uses for signing. Thus, it is possible to test +// the connection to KMS and verify the signature in the continuation function. +func WithMockKMS(t *testing.T, dir, chainID string, f func(string, crypto.PrivKey)) { + // This process is based on cmd/priv_validator_server/main.go + + // obtain an address using a vacancy port number + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := listener.Addr().String() + err = listener.Close() + require.NoError(t, err) + + // start mock kms server + logger := log.NewOCLogger(log.NewSyncWriter(os.Stdout)) + privKey := ed25519.GenPrivKeyFromSecret([]byte("🏺")) + shutdown := make(chan string) + go func() { + logger.Info(fmt.Sprintf("MockKMS starting: [%s] %s", chainID, addr)) + pv := NewFilePV(privKey, path.Join(dir, "keyfile"), path.Join(dir, "statefile")) + connTimeout := 5 * time.Second + dialer := DialTCPFn(addr, connTimeout, ed25519.GenPrivKeyFromSecret([]byte("🔌"))) + sd := NewSignerDialerEndpoint(logger, dialer) + ss := NewSignerServer(sd, chainID, pv) + err := ss.Start() + require.NoError(t, err) + logger.Info("MockKMS started") + <-shutdown + logger.Info("MockKMS stopping") + err = ss.Stop() + require.NoError(t, err) + logger.Info("MockKMS stopped") + }() + defer func() { + shutdown <- "SHUTDOWN" + }() + + f(addr, privKey) +} diff --git a/privval/test_util_test.go b/privval/test_util_test.go new file mode 100644 index 000000000..03a4cae96 --- /dev/null +++ b/privval/test_util_test.go @@ -0,0 +1,12 @@ +package privval + +import ( + "testing" + + "github.com/line/ostracon/crypto" +) + +func TestWithMockKMS(t *testing.T) { + dir := t.TempDir() + WithMockKMS(t, dir, "test", func(addr string, privKey crypto.PrivKey) {}) +} diff --git a/test/kms/bench_test.go b/test/kms/bench_test.go new file mode 100644 index 000000000..02cf16262 --- /dev/null +++ b/test/kms/bench_test.go @@ -0,0 +1,185 @@ +// This program performs tests and benchmarks that SignerClient can connect to KMS and make API calls. +// To test, address the KMS connection to port 45666 on the machine running this program and run the following: +// +// $ cd test/kms +// $ go test -tags libsodium -bench . -benchmem +// +package main + +import ( + "net" + "os" + "testing" + "time" + + "github.com/line/ostracon/config" + "github.com/line/ostracon/crypto" + "github.com/line/ostracon/crypto/ed25519" + "github.com/line/ostracon/libs/log" + tmnet "github.com/line/ostracon/libs/net" + "github.com/line/ostracon/node" + "github.com/line/ostracon/privval" + types2 "github.com/line/ostracon/proto/ostracon/types" + "github.com/line/ostracon/types" + "github.com/stretchr/testify/require" +) + +var logger = log.NewOCLogger(log.NewSyncWriter(os.Stdout)) + +const chainID = "test-chain" +const listenAddr = "tcp://0.0.0.0:45666" + +const VrfProofSize = 80 +const VrfOutputSize = 64 + +func BenchmarkKMS(b *testing.B) { + chainID := "test-chain" + protocol, address := tmnet.ProtocolAndAddress(listenAddr) + ln, err := net.Listen(protocol, address) + require.NoError(b, err) + listener := privval.NewTCPListener(ln, ed25519.GenPrivKeyFromSecret([]byte("🏺"))) + endpoint := privval.NewSignerListenerEndpoint(logger, listener) + client, err := privval.NewSignerClient(endpoint, chainID) + require.NoError(b, err) + + // ensure connection and warm up + b.Run("Ping", func(b *testing.B) { + var err error + b.ResetTimer() + for i := 0; i < b.N; i++ { + err = client.Ping() + } + b.StopTimer() + require.NoError(b, err) + }) + + benchmarkPrivValidator(b, client) +} + +func BenchmarkFilePV(b *testing.B) { + cfg := config.ResetTestRoot("BenchmarkFilePV") + defer func() { + var _ = os.RemoveAll(cfg.RootDir) + }() + + n, err := node.NewOstraconNode(cfg, logger) + require.NoError(b, err) + + benchmarkPrivValidator(b, n.PrivValidator()) +} + +func benchmarkPrivValidator(b *testing.B, pv types.PrivValidator) { + pubKey := benchmarkGetPubKey(b, pv) + benchmarkSignVote(b, pv, pubKey) + benchmarkSignProposal(b, pv, pubKey) + benchmarkVRFProof(b, pv, pubKey) +} + +func benchmarkGetPubKey(b *testing.B, pv types.PrivValidator) crypto.PubKey { + var pubKey crypto.PubKey + var err error + + // performance measurement + b.Run("GetPubKey", func(b *testing.B) { + for i := 0; i < b.N; i++ { + pubKey, err = pv.GetPubKey() + } + }) + + // evaluate execution results + require.NoError(b, err) + require.Equalf(b, len(pubKey.Bytes()), ed25519.PubKeySize, "PubKey: public key size = %d != %d", + len(pubKey.Bytes()), ed25519.PubKeySize) + return pubKey +} + +func benchmarkSignVote(b *testing.B, pv types.PrivValidator, pubKey crypto.PubKey) { + blockID := types.BlockID{ + Hash: make([]byte, 32), + PartSetHeader: types.PartSetHeader{ + Total: 10, + Hash: make([]byte, 32), + }, + } + vote := types.Vote{ + Type: types2.PrevoteType, + Height: 1, + Round: 0, + BlockID: blockID, + Timestamp: time.Now(), + ValidatorAddress: pubKey.Address(), + ValidatorIndex: 0, + Signature: nil, + } + pb := vote.ToProto() + var err error + + // performance measurement + b.Run("SignVote", func(b *testing.B) { + for i := 0; i < b.N; i++ { + err = pv.SignVote(chainID, pb) + } + }) + + // evaluate execution results + require.NoError(b, err) + require.Equalf(b, len(pb.Signature), ed25519.SignatureSize, "SignVote: signature size = %d != %d", + len(pb.Signature), ed25519.SignatureSize) + bytes := types.VoteSignBytes(chainID, pb) + require.Truef(b, pubKey.VerifySignature(bytes, pb.Signature), "SignVote: signature verification") +} + +func benchmarkSignProposal(b *testing.B, pv types.PrivValidator, pubKey crypto.PubKey) { + blockID := types.BlockID{ + Hash: make([]byte, 32), + PartSetHeader: types.PartSetHeader{ + Total: 10, + Hash: make([]byte, 32), + }, + } + proposal := types.Proposal{ + Type: types2.ProposalType, + Height: 2, + Round: 0, + POLRound: -1, + BlockID: blockID, + Timestamp: time.Now(), + Signature: nil, + } + pb := proposal.ToProto() + var err error + + // performance measurement + b.Run("SignProposal", func(b *testing.B) { + for i := 0; i < b.N; i++ { + err = pv.SignProposal(chainID, pb) + } + }) + + // evaluate execution results + require.NoError(b, err) + require.Equalf(b, len(pb.Signature), ed25519.SignatureSize, "ignProposal: signature size = %d != %d", + len(pb.Signature), ed25519.SignatureSize) + bytes := types.ProposalSignBytes(chainID, pb) + require.Truef(b, pubKey.VerifySignature(bytes, pb.Signature), "SignProposal: signature verification") +} + +func benchmarkVRFProof(b *testing.B, pv types.PrivValidator, pubKey crypto.PubKey) { + message := []byte("hello, world") + var proof crypto.Proof + var err error + + // performance measurement + b.Run("VRFProof", func(b *testing.B) { + for i := 0; i < b.N; i++ { + proof, err = pv.GenerateVRFProof(message) + } + }) + + // evaluate execution results + require.NoError(b, err) + require.Equalf(b, len(proof), VrfProofSize, "VRFProof: proof size = %d != %d", len(proof), VrfProofSize) + output, err := pubKey.VRFVerify(proof, message) + require.NoError(b, err) + require.Equalf(b, len(output), VrfOutputSize, "VRFProof: output size = %d != %d", len(output), VrfOutputSize) +}