Authenticate the Server with TLS

You’ve now seen how TLS works and why to use it, so we’re ready to build TLS support into our service to encrypt data in-flight and authenticate the server. I’ll also cover how to make obtaining and working with certificates easier to manage.

Operate as Your Own CA with CFSSL

Before changing our server’s code, let’s get some certs. We could use a third-party certificate authority (CA) to get the certs, but that could cost money (depending on the CA) and is a hassle. For internal services (like ours), there’s no need to go through a third-party authority. Trusted certificates don’t have to come from Comodo or Let’s Encrypt or any other CA—they can come from a CA you operate yourself. It’s free and easy with the right tools.

CloudFlare[24] wrote a toolkit called CFSSL for signing, verifying, and bundling TLS certificates. CloudFlare uses CFSSL for their internal services’ TLS certificates, acting as their own certificate authority. CloudFlare open sourced CFSSL so others, including us, can use it. Even major CA vendors like Let’s Encrypt use CFSSL. Big thanks to CloudFlare because CFSSL is a seriously useful toolkit.

CFSSL has two tools we’ll need:

Install the CloudFlare CLIs by running the following commands:

 $ go get github.com/cloudflare/cfssl/cmd/cfssl@v1.4.1
 $ go get github.com/cloudflare/cfssl/cmd/cfssljson@v1.4.1

To initialize our CA and generate certs, we need to pass various config files to the cfssl commands we’ll run. We need separate config files to generate our CA and server certs and we need a config file containing general config info about our CA. So let’s create a directory in our project to contain these config files by running $ mkdir test.

Put the following JSON into a file called ca-csr.json in your test directory:

SecureYourServices/test/ca-csr.json
 {
 "CN"​: ​"My Awesome CA"​,
 "key"​: {
 "algo"​: ​"rsa"​,
 "size"​: 2048
  },
 "names"​: [
  {
 "C"​: ​"CA"​,
 "L"​: ​"ON"​,
 "ST"​: ​"Toronto"​,
 "O"​: ​"My Awesome Company"​,
 "OU"​: ​"CA Services"
  }
  ]
 }

cfssl will use this file to configure our CA’s certificate. CN stands for Common Name, so we’re saying our CA is called “My Awesome CA.” key specifies the algorithm and size of key to sign the certificate with; names is a list of various name information that’ll be added to the certificate. Each name object should contain at least one “C,” “L,” “O,” “OU,” or “ST” value (or any combination of these). They stand for:

Create a test/ca-config.json that looks like this to define the CA’s policy:

SecureYourServices/test/ca-config.json
 {
 "signing"​: {
 "profiles"​: {
 "server"​: {
 "expiry"​: ​"8760h"​,
 "usages"​: [
 "signing"​,
 "key encipherment"​,
 "server auth"
  ]
  },
 "client"​: {
 "expiry"​: ​"8760h"​,
 "usages"​: [
 "signing"​,
 "key encipherment"​,
 "client auth"
  ]
  }
  }
  }
 }

Our CA needs to know what kind of certificates it will issue. The signing section of this configuration file defines your CA’s signing policy. Our configuration file says that the CA can generate client and server certificates that will expire after a year and the certificates may be used for digital signatures, encrypting keys, and auth.

Put the following JSON into a file called server-csr.json in your test directory:

SecureYourServices/test/server-csr.json
 {
 "CN"​: ​"127.0.0.1"​,
 "hosts"​: [
 "localhost"​,
 "127.0.0.1"
  ],
 "key"​: {
 "algo"​: ​"rsa"​,
 "size"​: 2048
  },
 "names"​: [
  {
 "C"​: ​"CA"​,
 "L"​: ​"ON"​,
 "ST"​: ​"Toronto"​,
 "O"​: ​"My Awesome Company"​,
 "OU"​: ​"Distributed Services"
  }
  ]
 }

cfssl will use these configs to configure our server’s certificate. The “hosts” field is a list of the domain names that the certificate should be valid for. Since we’re running our service locally, we just need 127.0.0.1 and localhost.

Now let’s update our Makefile to call cfssl and cfssljson to actually generate the certs. Make your project’s Makefile look like this:

SecureYourServices/Makefile
 CONFIG_PATH=${HOME}/.proglog/
 
 .PHONY: init
 init:
  mkdir -p ${CONFIG_PATH}
 
 .PHONY: gencert
 gencert:
  cfssl gencert \
  -initca test/ca-csr.json | cfssljson -bare ca
 
  cfssl gencert \
  -ca=ca.pem \
  -ca-key=ca-key.pem \
  -config=test/ca-config.json \
  -profile=server \
  test/server-csr.json | cfssljson -bare server
  mv *.pem *.csr ${CONFIG_PATH}
 
 .PHONY: test
 test:
  go test -race ./...
 
 .PHONY: compile
 compile:
  protoc api/v1/*.proto \
  --go_out=. \
  --go-grpc_out=. \
  --go_opt=paths=source_relative \
  --go-grpc_opt=paths=source_relative \
  --proto_path=.

In this updated Makefile, we’ve added a CONFIG_PATH variable to specify where we’d like to put our generated certs and an init target to create that directory. With these configs in a static and known location on the filesystem, it’s easier to look up and use the certs in our code. The gencert target calls cfssl to generate the certificate and private keys for our CA and server using the config files we added earlier.

We’ll reference these config files frequently in our tests, so let’s make a package containing their file paths as variables to make referencing them easy. Create an internal/config directory with a files.go file containing this code:

SecureYourServices/internal/config/files.go
 package​ config
 
 import​ (
 "os"
 "path/filepath"
 )
 
 var​ (
  CAFile = configFile(​"ca.pem"​)
  ServerCertFile = configFile(​"server.pem"​)
  ServerKeyFile = configFile(​"server-key.pem"​)
 )
 
 
 func​ configFile(filename ​string​) ​string​ {
 if​ dir := os.Getenv(​"CONFIG_DIR"​); dir != ​""​ {
 return​ filepath.Join(dir, filename)
  }
  homeDir, err := os.UserHomeDir()
 if​ err != nil {
  panic(err)
  }
 return​ filepath.Join(homeDir, ​".proglog"​, filename)
 }

These variables define the paths to the certs we generated and need to look up and parse for our tests. I would use constants and the const keyword if Go allowed using const with function calls.

We’ll use the certificate and key files to build *tls.Configs, so let’s add a helper function and struct for that. In the config directory, create a tls.go file beginning with this code:

SecureYourServices/internal/config/tls.go
 package​ config
 
 import​ (
 "crypto/tls"
 "crypto/x509"
 "fmt"
 "io/ioutil"
 )
 
 func​ SetupTLSConfig(cfg TLSConfig) (*tls.Config, ​error​) {
 var​ err ​error
  tlsConfig := &tls.Config{}
 if​ cfg.CertFile != ​""​ && cfg.KeyFile != ​""​ {
  tlsConfig.Certificates = make([]tls.Certificate, 1)
  tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(
  cfg.CertFile,
  cfg.KeyFile,
  )
 if​ err != nil {
 return​ nil, err
  }
  }
 if​ cfg.CAFile != ​""​ {
  b, err := ioutil.ReadFile(cfg.CAFile)
 if​ err != nil {
 return​ nil, err
  }
  ca := x509.NewCertPool()
  ok := ca.AppendCertsFromPEM([]​byte​(b))
 if​ !ok {
 return​ nil, fmt.Errorf(
 "failed to parse root certificate: %q"​,
  cfg.CAFile,
  )
  }
 if​ cfg.Server {
  tlsConfig.ClientCAs = ca
  tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
  } ​else​ {
  tlsConfig.RootCAs = ca
  }
  tlsConfig.ServerName = cfg.ServerAddress
  }
 return​ tlsConfig, nil
 }

Our tests use a few different *tls.Config configurations, and SetupTLSConfig allows us to get each type of *tls.Config with one function call. These are the different configurations:

Below SetupTLSConfig, put this struct:

SecureYourServices/internal/config/tls.go
 type​ TLSConfig ​struct​ {
  CertFile ​string
  KeyFile ​string
  CAFile ​string
  ServerAddress ​string
  Server ​bool
 }

TLSConfig defines the parameters that SetupTLSConfig uses to determine what type of *tls.Config to return.

Back to our tests. Let’s test that the client uses our CA to verify the server’s certificate. If the server’s certificate came from a different authority, the client wouldn’t trust the server and wouldn’t make a connection. In setup_test.go, add these imports:

SecureYourServices/internal/server/server_test.go
 "github.com/travisjeffery/proglog/internal/config"
 "google.golang.org/grpc/credentials"

Now replace the code in your existing setupTest function with the following code:

SecureYourServices/internal/server/server_test.go
 t.Helper()
 
 l, err := net.Listen(​"tcp"​, ​"127.0.0.1:0"​)
 require.NoError(t, err)
 
 clientTLSConfig, err := config.SetupTLSConfig(config.TLSConfig{
  CAFile: config.CAFile,
 })
 require.NoError(t, err)
 
 clientCreds := credentials.NewTLS(clientTLSConfig)
 cc, err := grpc.Dial(
  l.Addr().String(),
  grpc.WithTransportCredentials(clientCreds),
 )
 require.NoError(t, err)
 
 client = api.NewLogClient(cc)

In this code, we configure our client’s TLS credentials to use our CA as the client’s Root CA (the CA it will use to verify the server). Then we tell the client to use those credentials for its connection.

Next we need to hook up our server with its certificate and enable it to handle TLS connections. Add the following code below the previous snippet:

SecureYourServices/internal/server/server_test.go
 serverTLSConfig, err := config.SetupTLSConfig(config.TLSConfig{
  CertFile: config.ServerCertFile,
  KeyFile: config.ServerKeyFile,
  CAFile: config.CAFile,
  ServerAddress: l.Addr().String(),
 })
 require.NoError(t, err)
 serverCreds := credentials.NewTLS(serverTLSConfig)
 
 dir, err := ioutil.TempDir(​""​, ​"server-test"​)
 require.NoError(t, err)
 
 clog, err := log.NewLog(dir, log.Config{})
 require.NoError(t, err)
 
 cfg = &Config{
  CommitLog: clog,
 }
 if​ fn != nil {
  fn(cfg)
 }
 server, err := NewGRPCServer(cfg, grpc.Creds(serverCreds))
 require.NoError(t, err)
 
 go​ ​func​() {
  server.Serve(l)
 }()
 
 return​ client, cfg, ​func​() {
  server.Stop()
  cc.Close()
  l.Close()
 }

In this code, we’re parsing the server’s cert and key, which we then use to configure the server’s TLS credentials. We then pass those credentials as a gRPC server option to our NewGRPCServer function so it can create our gRPC server with that option. gRPC server options are how you enable features in gRPC servers. We’re setting the credentials for the server connections in this case, but there are plenty of other server options[25] to configure connection timeouts, keep alive policies, and so on.

Finally, we need to update the NewGRPCServer function in server.go to take in the given gRPC server options and create the server with them. Change the NewGRPCServer function to this:

SecureYourServices/internal/server/server.go
 func​ NewGRPCServer(config *Config, opts ...grpc.ServerOption) (
  *grpc.Server,
 error​,
 ) {
  gsrv := grpc.NewServer(opts...)
  srv, err := newgrpcServer(config)
 if​ err != nil {
 return​ nil, err
  }
  api.RegisterLogServer(gsrv, srv)
 return​ gsrv, nil
 }

At this point you can run the tests with $ make test, and our tests should pass as they did before the changes we’ve made in this chapter. The difference is that your server is now authenticated and your connection is encrypted. You can verify this by temporarily changing your test code back to using an insecure client connection with the grpc.WithInsecure() dial option, and then running the tests again. This time the tests will fail because the client and server won’t be able to connect with each other because the server is expecting the client to run over TLS.

Your server is authenticated so you know your client is communicating with your actual server and not some middleman’s. Now we’ll use mutual TLS authentication to verify that the client hitting your server really is your client.