From e2da4caae435bee47f007eef2c292b04e42bcc27 Mon Sep 17 00:00:00 2001 From: Alessio Treglia Date: Fri, 21 Sep 2018 17:33:58 +0100 Subject: [PATCH] Merge PR #2364: Implement HTTPS for the LCD REST server In order to guarantee a secure connection between apps and the LCD the communication must be encrypted - even if clients and server run on the same local machine, credentials must never be transmitted in clear text. Upon start up, the server generates a self-signed certificate and a key. Both are stored as temporary files; removal is guaranteed on exit. This new behaviour is now enabled by default, though users are provided with a --insecure flag to switch it off. --- PENDING.md | 1 + client/lcd/certificates.go | 174 ++++++++++++++++++++++++++++++++ client/lcd/certificates_test.go | 93 +++++++++++++++++ client/lcd/root.go | 90 +++++++++++++++-- docs/light/getting_started.md | 20 +++- 5 files changed, 365 insertions(+), 13 deletions(-) create mode 100644 client/lcd/certificates.go create mode 100644 client/lcd/certificates_test.go diff --git a/PENDING.md b/PENDING.md index 10b0f93bb7..ab0a42d45d 100644 --- a/PENDING.md +++ b/PENDING.md @@ -4,6 +4,7 @@ BREAKING CHANGES * Gaia REST API (`gaiacli advanced rest-server`) * [x/stake] Validator.Owner renamed to Validator.Operator + * [\#595](https://github.com/cosmos/cosmos-sdk/issues/595) Connections to the REST server are now secured using Transport Layer Security by default. The --insecure flag is provided to switch back to insecure HTTP. * Gaia CLI (`gaiacli`) * [x/stake] Validator.Owner renamed to Validator.Operator diff --git a/client/lcd/certificates.go b/client/lcd/certificates.go new file mode 100644 index 0000000000..f47f2397c7 --- /dev/null +++ b/client/lcd/certificates.go @@ -0,0 +1,174 @@ +package lcd + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io/ioutil" + "math/big" + "net" + "os" + "strings" + "time" +) + +// default: 30 days +const defaultValidFor = 30 * 24 * time.Hour + +func generateSelfSignedCert(host string) (certBytes []byte, priv *ecdsa.PrivateKey, err error) { + priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + notBefore := time.Now() + notAfter := notBefore.Add(defaultValidFor) + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + err = fmt.Errorf("failed to generate serial number: %s", err) + return + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Gaia Lite"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + hosts := strings.Split(host, ",") + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + } + } + + certBytes, err = x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + err = fmt.Errorf("couldn't create certificate: %s", err) + return + } + return +} + +func writeCertAndPrivKey(certBytes []byte, priv *ecdsa.PrivateKey) (certFile string, keyFile string, err error) { + if priv == nil { + err = errors.New("private key is nil") + return + } + certFile, err = writeCertificateFile(certBytes) + if err != nil { + return + } + keyFile, err = writeKeyFile(priv) + return +} + +func writeCertificateFile(certBytes []byte) (filename string, err error) { + f, err := ioutil.TempFile("", "cert_") + if err != nil { + return + } + defer f.Close() + filename = f.Name() + if err := pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}); err != nil { + return filename, fmt.Errorf("failed to write data to %s: %s", filename, err) + } + return +} + +func writeKeyFile(priv *ecdsa.PrivateKey) (filename string, err error) { + f, err := ioutil.TempFile("", "key_") + if err != nil { + return + } + defer f.Close() + filename = f.Name() + block, err := pemBlockForKey(priv) + if err != nil { + return + } + if err := pem.Encode(f, block); err != nil { + return filename, fmt.Errorf("failed to write data to %s: %s", filename, err) + } + return +} + +func pemBlockForKey(priv *ecdsa.PrivateKey) (*pem.Block, error) { + b, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return nil, fmt.Errorf("unable to marshal ECDSA private key: %v", err) + } + return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}, nil + +} + +func genCertKeyFilesAndReturnFingerprint(sslHosts string) (certFile, keyFile string, fingerprint string, err error) { + certBytes, priv, err := generateSelfSignedCert(sslHosts) + if err != nil { + return + } + certFile, keyFile, err = writeCertAndPrivKey(certBytes, priv) + cleanupFunc := func() { + os.Remove(certFile) + os.Remove(keyFile) + } + // Either of the files could have been written already, + // thus clean up regardless of the error. + if err != nil { + defer cleanupFunc() + return + } + fingerprint, err = fingerprintForCertificate(certBytes) + if err != nil { + defer cleanupFunc() + return + } + return +} + +func fingerprintForCertificate(certBytes []byte) (string, error) { + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + return "", err + } + h := sha256.New() + h.Write(cert.Raw) + fingerprintBytes := h.Sum(nil) + var buf bytes.Buffer + for i, b := range fingerprintBytes { + if i > 0 { + fmt.Fprintf(&buf, ":") + } + fmt.Fprintf(&buf, "%02X", b) + } + return fmt.Sprintf("SHA256 Fingerprint=%s", buf.String()), nil +} + +func fingerprintFromFile(certFile string) (string, error) { + f, err := os.Open(certFile) + if err != nil { + return "", err + } + defer f.Close() + data, err := ioutil.ReadAll(f) + if err != nil { + return "", err + } + block, _ := pem.Decode(data) + if block == nil { + return "", fmt.Errorf("couldn't find PEM data in %s", certFile) + } + return fingerprintForCertificate(block.Bytes) +} diff --git a/client/lcd/certificates_test.go b/client/lcd/certificates_test.go new file mode 100644 index 0000000000..14bddfa0f8 --- /dev/null +++ b/client/lcd/certificates_test.go @@ -0,0 +1,93 @@ +package lcd + +import ( + "crypto/ecdsa" + "crypto/x509" + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateSelfSignedCert(t *testing.T) { + host := "127.0.0.1,localhost,::1" + certBytes, _, err := generateSelfSignedCert(host) + require.Nil(t, err) + cert, err := x509.ParseCertificate(certBytes) + require.Nil(t, err) + require.Equal(t, 2, len(cert.IPAddresses)) + require.Equal(t, 1, len(cert.DNSNames)) + require.True(t, cert.IsCA) +} + +func TestWriteCertAndPrivKey(t *testing.T) { + expectedPerm := "-rw-------" + derBytes, priv, err := generateSelfSignedCert("localhost") + require.Nil(t, err) + type args struct { + certBytes []byte + priv *ecdsa.PrivateKey + } + tests := []struct { + name string + args args + wantErr bool + }{ + {"valid certificate", args{derBytes, priv}, false}, + {"garbage", args{[]byte("some garbage"), nil}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotCertFile, gotKeyFile, err := writeCertAndPrivKey(tt.args.certBytes, tt.args.priv) + defer os.Remove(gotCertFile) + defer os.Remove(gotKeyFile) + if tt.wantErr { + require.NotNil(t, err) + return + } + require.Nil(t, err) + info, err := os.Stat(gotCertFile) + require.Nil(t, err) + require.True(t, info.Mode().IsRegular()) + require.Equal(t, expectedPerm, info.Mode().String()) + info, err = os.Stat(gotKeyFile) + require.Nil(t, err) + require.True(t, info.Mode().IsRegular()) + require.Equal(t, expectedPerm, info.Mode().String()) + }) + } +} + +func TestFingerprintFromFile(t *testing.T) { + cert := `-----BEGIN CERTIFICATE----- +MIIBbDCCARGgAwIBAgIQSuFKYv/22v+cxtVgMUrQADAKBggqhkjOPQQDAjASMRAw +DgYDVQQKEwdBY21lIENvMB4XDTE4MDkyMDIzNDQyNloXDTE5MDkyMDIzNDQyNlow +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDIo +ujAesRczcPVAWiLhpeV1B7hS/RI2LJaGj3QjyJ8hiUthJTPIamr8m7LuS/U5fS0o +hY297YeTIGo9YkxClICjSTBHMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MA8GA1UdEQQIMAaHBH8AAAEwCgYIKoZI +zj0EAwIDSQAwRgIhAKnwbhX9FrGG1otCVLwhClQ3RaLxnNpCgIGTqSimb34cAiEA +stMN+IqMCKWlZyGqxGIiyksMLMEU3lRqKNQn2EoAZJY= +-----END CERTIFICATE-----` + wantFingerprint := `SHA256 Fingerprint=0B:ED:9A:AA:A2:D1:7E:B2:53:56:F6:FC:C0:E6:1A:69:70:21:A2:B0:90:FC:AF:BB:EF:AE:2C:78:52:AB:68:40` + certFile, err := ioutil.TempFile("", "test_cert_") + require.Nil(t, err) + _, err = certFile.Write([]byte(cert)) + require.Nil(t, err) + err = certFile.Close() + require.Nil(t, err) + defer os.Remove(certFile.Name()) + fingerprint, err := fingerprintFromFile(certFile.Name()) + require.Nil(t, err) + require.Equal(t, wantFingerprint, fingerprint) + + // test failure + emptyFile, err := ioutil.TempFile("", "test_cert_") + require.Nil(t, err) + err = emptyFile.Close() + require.Nil(t, err) + defer os.Remove(emptyFile.Name()) + _, err = fingerprintFromFile(emptyFile.Name()) + require.NotNil(t, err) +} diff --git a/client/lcd/root.go b/client/lcd/root.go index 5fa8dc0471..36fbd42d0b 100644 --- a/client/lcd/root.go +++ b/client/lcd/root.go @@ -1,6 +1,8 @@ package lcd import ( + "errors" + "net" "net/http" "os" @@ -23,35 +25,84 @@ import ( tmserver "github.com/tendermint/tendermint/rpc/lib/server" ) +const ( + flagListenAddr = "laddr" + flagCORS = "cors" + flagMaxOpenConnections = "max-open" + flagInsecure = "insecure" + flagSSLHosts = "ssl-hosts" + flagSSLCertFile = "ssl-certfile" + flagSSLKeyFile = "ssl-keyfile" +) + // ServeCommand will generate a long-running rest server // (aka Light Client Daemon) that exposes functionality similar // to the cli, but over rest func ServeCommand(cdc *codec.Codec) *cobra.Command { - flagListenAddr := "laddr" - flagCORS := "cors" - flagMaxOpenConnections := "max-open" cmd := &cobra.Command{ Use: "rest-server", Short: "Start LCD (light-client daemon), a local REST server", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) (err error) { listenAddr := viper.GetString(flagListenAddr) handler := createHandler(cdc) logger := log.NewTMLogger(log.NewSyncWriter(os.Stdout)).With("module", "rest-server") maxOpen := viper.GetInt(flagMaxOpenConnections) + sslHosts := viper.GetString(flagSSLHosts) + certFile := viper.GetString(flagSSLCertFile) + keyFile := viper.GetString(flagSSLKeyFile) + cleanupFunc := func() {} - listener, err := tmserver.StartHTTPServer( - listenAddr, handler, logger, - tmserver.Config{MaxOpenConnections: maxOpen}, - ) - if err != nil { - return err + var listener net.Listener + var fingerprint string + if viper.GetBool(flagInsecure) { + listener, err = tmserver.StartHTTPServer( + listenAddr, handler, logger, + tmserver.Config{MaxOpenConnections: maxOpen}, + ) + if err != nil { + return + } + } else { + if certFile != "" { + // validateCertKeyFiles() is needed to work around tendermint/tendermint#2460 + err = validateCertKeyFiles(certFile, keyFile) + if err != nil { + return err + } + // cert/key pair is provided, read the fingerprint + fingerprint, err = fingerprintFromFile(certFile) + if err != nil { + return err + } + } else { + // if certificate is not supplied, generate a self-signed one + certFile, keyFile, fingerprint, err = genCertKeyFilesAndReturnFingerprint(sslHosts) + if err != nil { + return err + } + cleanupFunc = func() { + os.Remove(certFile) + os.Remove(keyFile) + } + defer cleanupFunc() + } + listener, err = tmserver.StartHTTPAndTLSServer( + listenAddr, handler, + certFile, keyFile, + logger, + tmserver.Config{MaxOpenConnections: maxOpen}, + ) + if err != nil { + return + } + logger.Info(fingerprint) } - logger.Info("REST server started") // wait forever and cleanup cmn.TrapSignal(func() { + defer cleanupFunc() err := listener.Close() logger.Error("error closing listener", "err", err) }) @@ -61,6 +112,10 @@ func ServeCommand(cdc *codec.Codec) *cobra.Command { } cmd.Flags().String(flagListenAddr, "tcp://localhost:1317", "The address for the server to listen on") + cmd.Flags().Bool(flagInsecure, false, "Do not set up SSL/TLS layer") + cmd.Flags().String(flagSSLHosts, "", "Comma-separated hostnames and IPs to generate a certificate for") + cmd.Flags().String(flagSSLCertFile, "", "Path to a SSL certificate file. If not supplied, a self-signed certificate will be generated.") + cmd.Flags().String(flagSSLKeyFile, "", "Path to a key file; ignored if a certificate file is not supplied.") cmd.Flags().String(flagCORS, "", "Set the domains that can make CORS requests (* for all)") cmd.Flags().String(client.FlagChainID, "", "Chain ID of Tendermint node") cmd.Flags().String(client.FlagNode, "tcp://localhost:26657", "Address of the node to connect to") @@ -95,3 +150,16 @@ func createHandler(cdc *codec.Codec) http.Handler { return r } + +func validateCertKeyFiles(certFile, keyFile string) error { + if keyFile == "" { + return errors.New("a key file is required") + } + if _, err := os.Stat(certFile); err != nil { + return err + } + if _, err := os.Stat(keyFile); err != nil { + return err + } + return nil +} diff --git a/docs/light/getting_started.md b/docs/light/getting_started.md index 5f11956c05..21497477a7 100644 --- a/docs/light/getting_started.md +++ b/docs/light/getting_started.md @@ -1,6 +1,6 @@ # Getting Started -To start a rest server, we need to specify the following parameters: +To start a REST server, we need to specify the following parameters: | Parameter | Type | Default | Required | Description | | ----------- | --------- | ----------------------- | -------- | ---------------------------------------------------- | | chain-id | string | null | true | chain id of the full node to connect | @@ -12,9 +12,25 @@ To start a rest server, we need to specify the following parameters: Sample command: ```bash -gaiacli light-client --chain-id=test --laddr=tcp://localhost:1317 --node tcp://localhost:46657 --trust-node=false +gaiacli rest-server --chain-id=test \ + --laddr=tcp://localhost:1317 \ + --node tcp://localhost:46657 \ + --trust-node=false ``` +The server listens on HTTPS by default. You can set the SSL certificate to be used by the server with these additional flags: + +```bash +gaiacli rest-server --chain-id=test \ + --laddr=tcp://localhost:1317 \ + --node tcp://localhost:46657 \ + --trust-node=false \ + --certfile=mycert.pem --keyfile=mykey.key +``` + +If no certificate/keyfile pair is supplied, a self-signed certificate will be generated and its fingerprint printed out. +Append `--insecure` to the command line if you want to disable the secure layer and listen on an insecure HTTP port. + ## Gaia Light Use Cases LCD could be very helpful for related service providers. For a wallet service provider, LCD could