From f5ea1e6a96f3dd386210187bc74f4f3d599b590a Mon Sep 17 00:00:00 2001 From: Alessio Treglia Date: Thu, 20 Sep 2018 04:09:25 +0100 Subject: [PATCH 1/5] 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. See #595 --- PENDING.md | 1 + client/lcd/certificates.go | 171 ++++++++++++++++++++++++++++++++ client/lcd/certificates_test.go | 84 ++++++++++++++++ client/lcd/root.go | 74 ++++++++++++-- docs/light/getting_started.md | 20 +++- 5 files changed, 338 insertions(+), 12 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 e07319d49ee3..c82988a6fc1d 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 000000000000..9d40bb6012f0 --- /dev/null +++ b/client/lcd/certificates.go @@ -0,0 +1,171 @@ +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: 10 years +const defaultValidFor = 365 * 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{"Acme Co"}, + }, + 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) + return fingerprintForCertificate(block.Bytes) +} diff --git a/client/lcd/certificates_test.go b/client/lcd/certificates_test.go new file mode 100644 index 000000000000..05d7ea804b5a --- /dev/null +++ b/client/lcd/certificates_test.go @@ -0,0 +1,84 @@ +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) +} diff --git a/client/lcd/root.go b/client/lcd/root.go index 5fa8dc047142..908a4199465d 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,83 @@ 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 + if (certFile != "") && (keyFile == "") { + return errors.New("key file wasn't supplied") } + var listener net.Listener + var fingerprint string + switch viper.GetBool(flagInsecure) { + case true: + listener, err = tmserver.StartHTTPServer( + listenAddr, handler, logger, + tmserver.Config{MaxOpenConnections: maxOpen}, + ) + if err != nil { + return + } + default: + // if certificate is not supplied, generate a self-signed one + if certFile != "" { + fingerprint, err = fingerprintFromFile(certFile) + if err != nil { + return err + } + } else { + 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 +111,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") diff --git a/docs/light/getting_started.md b/docs/light/getting_started.md index 5f11956c0540..21497477a701 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 From 44faf8826366fc1c722239c8e831ae21ea210f3e Mon Sep 17 00:00:00 2001 From: Alessio Treglia Date: Fri, 21 Sep 2018 14:34:01 +0100 Subject: [PATCH 2/5] Flags validation --- client/lcd/root.go | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/client/lcd/root.go b/client/lcd/root.go index 908a4199465d..049f19bb0cf4 100644 --- a/client/lcd/root.go +++ b/client/lcd/root.go @@ -53,10 +53,6 @@ func ServeCommand(cdc *codec.Codec) *cobra.Command { keyFile := viper.GetString(flagSSLKeyFile) cleanupFunc := func() {} - if (certFile != "") && (keyFile == "") { - return errors.New("key file wasn't supplied") - } - var listener net.Listener var fingerprint string switch viper.GetBool(flagInsecure) { @@ -69,13 +65,18 @@ func ServeCommand(cdc *codec.Codec) *cobra.Command { return } default: - // if certificate is not supplied, generate a self-signed one if certFile != "" { + 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 @@ -149,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 +} From c9e7b0b1db7cedeec406436bc3a1986046d80787 Mon Sep 17 00:00:00 2001 From: Alessio Treglia Date: Fri, 21 Sep 2018 14:35:23 +0100 Subject: [PATCH 3/5] One year is perhaps too long as well --- client/lcd/certificates.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/lcd/certificates.go b/client/lcd/certificates.go index 9d40bb6012f0..a5b2c60498ba 100644 --- a/client/lcd/certificates.go +++ b/client/lcd/certificates.go @@ -20,7 +20,7 @@ import ( ) // default: 10 years -const defaultValidFor = 365 * 24 * time.Hour +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) From 3b0b660e63b3f0a77110f89057896436ba369490 Mon Sep 17 00:00:00 2001 From: Alessio Treglia Date: Fri, 21 Sep 2018 15:31:32 +0100 Subject: [PATCH 4/5] Left comment --- client/lcd/root.go | 1 + 1 file changed, 1 insertion(+) diff --git a/client/lcd/root.go b/client/lcd/root.go index 049f19bb0cf4..8f1a9da1785d 100644 --- a/client/lcd/root.go +++ b/client/lcd/root.go @@ -66,6 +66,7 @@ func ServeCommand(cdc *codec.Codec) *cobra.Command { } default: if certFile != "" { + // validateCertKeyFiles() is needed to work around tendermint/tendermint#2460 err = validateCertKeyFiles(certFile, keyFile) if err != nil { return err From 7a8ceb85e0c6c489f983ce66621ffff38f496c97 Mon Sep 17 00:00:00 2001 From: Alessio Treglia Date: Fri, 21 Sep 2018 17:06:17 +0100 Subject: [PATCH 5/5] address @cwgoes comments --- client/lcd/certificates.go | 7 +++++-- client/lcd/certificates_test.go | 9 +++++++++ client/lcd/root.go | 5 ++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/client/lcd/certificates.go b/client/lcd/certificates.go index a5b2c60498ba..f47f2397c72e 100644 --- a/client/lcd/certificates.go +++ b/client/lcd/certificates.go @@ -19,7 +19,7 @@ import ( "time" ) -// default: 10 years +// default: 30 days const defaultValidFor = 30 * 24 * time.Hour func generateSelfSignedCert(host string) (certBytes []byte, priv *ecdsa.PrivateKey, err error) { @@ -36,7 +36,7 @@ func generateSelfSignedCert(host string) (certBytes []byte, priv *ecdsa.PrivateK template := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{ - Organization: []string{"Acme Co"}, + Organization: []string{"Gaia Lite"}, }, NotBefore: notBefore, NotAfter: notAfter, @@ -167,5 +167,8 @@ func fingerprintFromFile(certFile string) (string, error) { 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 index 05d7ea804b5a..14bddfa0f8bb 100644 --- a/client/lcd/certificates_test.go +++ b/client/lcd/certificates_test.go @@ -81,4 +81,13 @@ stMN+IqMCKWlZyGqxGIiyksMLMEU3lRqKNQn2EoAZJY= 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 8f1a9da1785d..36fbd42d0b47 100644 --- a/client/lcd/root.go +++ b/client/lcd/root.go @@ -55,8 +55,7 @@ func ServeCommand(cdc *codec.Codec) *cobra.Command { var listener net.Listener var fingerprint string - switch viper.GetBool(flagInsecure) { - case true: + if viper.GetBool(flagInsecure) { listener, err = tmserver.StartHTTPServer( listenAddr, handler, logger, tmserver.Config{MaxOpenConnections: maxOpen}, @@ -64,7 +63,7 @@ func ServeCommand(cdc *codec.Codec) *cobra.Command { if err != nil { return } - default: + } else { if certFile != "" { // validateCertKeyFiles() is needed to work around tendermint/tendermint#2460 err = validateCertKeyFiles(certFile, keyFile)