Secure gRPC over mTLS using Go

Secure gRPC over mTLS using Go

In this post, we will guide you through the steps to create a gRPC client/server setup that is secured using mutual TLS authentication (mTLS). We'll begin with a brief introduction to mTLS and gRPC to provide the necessary context. However, a more in-depth exploration of these topics is beyond the scope of this post.

Mutual TLS

TLS and mTLS are both protocols designed to secure communications over a network. TLS ensures privacy and data integrity by allowing clients to verify the server's identity using digital certificates, a process known as one-way TLS. This is commonly used for securing public-facing websites and applications.

In contrast, mTLS enhances security by requiring both the client and server to authenticate each other with digital certificates, providing mutual verification. This bidirectional authentication, also known as two-way TLS, is ideal for high-security environments like corporate networks and API communications where both parties need to establish trust.

gRPC Remote Procedure Calls

gRPC (opens in a new tab) is an open-source framework from Google that helps different parts of an application communicate efficiently. It's great for connecting microservices, which are common in cloud-based apps. gRPC uses HTTP/2 for fast communication and Protocol Buffers (opens in a new tab) protobuf for compact data serialisation.

When working with gRPC in Go, you define your service methods in protobuf files. These files are then compiled into Go code using the protoc compiler with a gRPC Go plugin. This process generates both server and client code, making it easier to implement your services. On the server side, you define the methods and link them to a gRPC server. On the client side, you use the generated code to call these methods remotely, making network communication look like local function calls.

Mock Banking Application

From this point on, all technical steps in this post are centered around Keystone Global Banking, a mock banking application. This application is perfect for showing how mutual TLS (mTLS) authentication works. This approach helps us understand how mTLS protects sensitive financial data from unauthorised access and breaches.

Features

  • mTLS Authentication: Ensures both client and server mutually verify each other's identities.
  • Banking Operations: Simulates basic banking operations such as account creation, balance checking, and funds transfer.
  • Secure API Endpoints: All interactions with the app are secured using mTLS.

Source Code

You can find the code for the Keystone Global Banking (KGB) application on GitHub (opens in a new tab).

mTLS Certificates

By following these steps, you will generate the necessary certificates for setting up mTLS without passwords and using a separate configuration file for OpenSSL (opens in a new tab).

Set Up a Certificate Authority (CA)

# Create a Private Key for the CA
openssl genrsa -out ca.key 4096
 
# Create a Self-Signed Certificate for the CA
openssl req -x509 -new -nodes -key ca.key -sha256 -subj "/C=US/ST=New York/L=New York City/O=Example CA Inc./CN=Example Root CA" -days 365 -out ca.crt

Generate the Server Certificate

# Create a Private Key for the Server
openssl genrsa -out server.key 4096
 
# Create a Certificate Signing Request (CSR) config for the Server
cat > server.conf <<EOF
[ req ]
default_bits       = 2048
default_keyfile    = server.key
default_md         = sha256
prompt             = no
distinguished_name = req_distinguished_name
x509_extensions    = v3_req
 
[ req_distinguished_name ]
C                  = ZA
ST                 = Western Cape
L                  = Cape Town
O                  = Keystone Global Banking
OU                 = Finance
CN                 = bank.kgb.rip
 
[ v3_req ]
keyUsage           = keyEncipherment, dataEncipherment
extendedKeyUsage   = serverAuth
subjectAltName     = @alt_names
 
[ alt_names ]
DNS.1              = localhost
DNS.2              = server
DNS.3              = bank.kgb.rip
IP.1               = 127.0.0.1
EOF
 
# Create a Certificate Signing Request (CSR) for the Server
openssl req -new -key server.key -out server.csr -config server.conf
 
# Sign the Server CSR with the CA Certificate to Generate the Server Certificate
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 90 -sha256 -extensions v3_req -extfile server.conf

Generate the Client Certificate

# Create a Private Key for the Client
openssl genrsa -out client.key 4096
 
# Create a Certificate Signing Request (CSR) config for the Client
cat > client.conf <<EOF
[ req ]
default_bits        = 2048
default_keyfile     = client.key
default_md          = sha256
prompt              = no
distinguished_name  = req_distinguished_name
x509_extensions     = v3_req
 
[ req_distinguished_name ]
C                   = ZA
ST                  = Western Cape
L                   = Cape Town
O                   = Keystone Global Banking
OU                  = Finance
CN                  = bank.kgb.rip
 
[ v3_req ]
keyUsage            = keyEncipherment, dataEncipherment
extendedKeyUsage    = clientAuth
subjectAltName      = @alt_names
 
[ alt_names ]
DNS.1               = localhost
DNS.2               = server
DNS.3               = bank.kgb.rip
IP.1                = 127.0.0.1
EOF
 
# Create a Certificate Signing Request (CSR) for the Client
openssl req -new -key client.key -out client.csr -config client.conf
 
# Sign the Client CSR with the CA Certificate to Generate the Client Certificate
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 90 -sha256 -extensions v3_req -extfile client.conf

Write Some Code

Let's create a simple Go application that reads configuration from a YAML file.

Reading Configuration Data

First, you need to install Viper using go get:

go get github.com/spf13/viper

Create a config.go file and initialise Viper to read from the configs/client-config.yaml and configs/server-config.yaml files:

package config
 
import (
	"log"
 
	"github.com/spf13/viper"
)
 
type Config struct {
	App    appConfig
	Server serverConfig
	TLS    tlsConfig
}
 
type appConfig struct {
	Name    string
	Version string
}
 
type serverConfig struct {
	Port int
	Host string
}
 
type tlsConfig struct {
	CA   string
	Cert string
	Key  string
}
 
func New(name string) *Config {
	// Initialise Viper
	viper.SetConfigName(name)
	viper.SetConfigType("yaml")
	viper.AddConfigPath("configs")
 
	// Read in environment variables that match
	viper.AutomaticEnv()
 
	// Read the config file
	err := viper.ReadInConfig()
	if err != nil {
		log.Fatalf("Error reading config file %v", err)
	}
 
	// Unmarshal the config into a Config struct
	var config Config
	err = viper.Unmarshal(&config)
	if err != nil {
		log.Fatalf("Unable to decode into struct %v", err)
	}
 
	return &config
}

TLS Credentials

Load TLS credentials for the client and server.

package credentials
 
import (
	"crypto/tls"
	"crypto/x509"
	"log"
	"os"
 
	"github.com/liambeeton/go-grpc-over-mtls/internal/config"
 
	"google.golang.org/grpc/credentials"
)
 
func NewClientTLS(c *config.Config) credentials.TransportCredentials {
	// Load the client certificate and its key
	clientCert, err := tls.LoadX509KeyPair(c.TLS.Cert, c.TLS.Key)
	if err != nil {
		log.Fatalf("Failed to load client certificate and key %v", err)
	}
 
	// Load the CA certificate
	trustedCert, err := os.ReadFile(c.TLS.CA)
	if err != nil {
		log.Fatalf("Failed to load trusted certificate %v", err)
	}
 
	// Put the CA certificate into the certificate pool
	certPool := x509.NewCertPool()
	if !certPool.AppendCertsFromPEM(trustedCert) {
		log.Fatalf("Failed to append trusted certificate to certificate pool %v", err)
	}
 
	// Create the TLS configuration
	tlsConfig := &tls.Config{
		Certificates: []tls.Certificate{clientCert},
		RootCAs:      certPool,
		MinVersion:   tls.VersionTLS13,
		MaxVersion:   tls.VersionTLS13,
	}
 
	// Return new TLS credentials based on the TLS configuration
	return credentials.NewTLS(tlsConfig)
}
 
func NewServerTLS(c *config.Config) credentials.TransportCredentials {
	// Load the server certificate and its key
	serverCert, err := tls.LoadX509KeyPair(c.TLS.Cert, c.TLS.Key)
	if err != nil {
		log.Fatalf("Failed to load server certificate and key %v", err)
	}
 
	// Load the CA certificate
	trustedCert, err := os.ReadFile(c.TLS.CA)
	if err != nil {
		log.Fatalf("Failed to load trusted certificate %v", err)
	}
 
	// Put the CA certificate into the certificate pool
	certPool := x509.NewCertPool()
	if !certPool.AppendCertsFromPEM(trustedCert) {
		log.Fatalf("Failed to append trusted certificate to certificate pool %v", err)
	}
 
	// Create the TLS configuration
	tlsConfig := &tls.Config{
		Certificates: []tls.Certificate{serverCert},
		RootCAs:      certPool,
		ClientCAs:    certPool,
		MinVersion:   tls.VersionTLS13,
		MaxVersion:   tls.VersionTLS13,
	}
 
	// Return new TLS credentials based on the TLS configuration
	return credentials.NewTLS(tlsConfig)
}

Protobuf Generation

Next, you need install gRPC and Protobuf using go get:

go get google.golang.org/grpc
go get google.golang.org/protobuf

Use make proto-compile to generate the protobuf files.

syntax = "proto3";

package bank.service.v1;

option go_package = "github.com/liambeeton/go-grpc-over-mtls/pb/message";

message CreateAccountRequest {
  string account_id = 1;
}

message CreateAccountResponse {
  string account_id = 1;
}

message GetBalanceRequest {
  string account_id = 1;
}

message GetBalanceResponse {
  string account_id = 1;
  double balance = 2;
}

message DepositRequest {
  string account_id = 1;
  double amount = 2;
}

message DepositResponse {
  double new_balance = 1;
}

message WithdrawRequest {
  string account_id = 1;
  double amount = 2;
}

message WithdrawResponse {
  double new_balance = 1;
}
syntax = "proto3";

package bank.service.v1;

option go_package = "github.com/liambeeton/go-grpc-over-mtls/pb/service";

import "message.proto";

service BankService {
  rpc CreateAccount(CreateAccountRequest) returns (CreateAccountResponse);
  rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse);
  rpc Deposit(DepositRequest) returns (DepositResponse);
  rpc Withdraw(WithdrawRequest) returns (WithdrawResponse);
}

gRPC Server

We will create a main Go file with the primary function of starting the gRPC server. The code snippet below demonstrates this. I've also included comments within the code to provide clearer explanations of the key sections.

package main
 
import (
	"context"
	"fmt"
	"log"
	"net"
 
	"github.com/liambeeton/go-grpc-over-mtls/internal/config"
	"github.com/liambeeton/go-grpc-over-mtls/internal/credentials"
	"github.com/liambeeton/go-grpc-over-mtls/pb/message"
	"github.com/liambeeton/go-grpc-over-mtls/pb/service"
 
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)
 
type server struct {
	service.UnimplementedBankServiceServer
	accounts map[string]float64
}
 
func (s *server) CreateAccount(_ context.Context, req *message.CreateAccountRequest) (*message.CreateAccountResponse, error) {
	s.accounts[req.AccountId] = 0
	return &message.CreateAccountResponse{AccountId: req.AccountId}, nil
}
 
func (s *server) GetBalance(_ context.Context, req *message.GetBalanceRequest) (*message.GetBalanceResponse, error) {
	balance, exists := s.accounts[req.AccountId]
	if !exists {
		return nil, status.Error(codes.NotFound, "Account not found")
	}
	return &message.GetBalanceResponse{AccountId: req.AccountId, Balance: balance}, nil
}
 
func (s *server) Deposit(_ context.Context, req *message.DepositRequest) (*message.DepositResponse, error) {
	_, exists := s.accounts[req.AccountId]
	if !exists {
		return nil, status.Error(codes.NotFound, "Account not found")
	}
	s.accounts[req.AccountId] += req.Amount
	return &message.DepositResponse{NewBalance: s.accounts[req.AccountId]}, nil
}
 
func (s *server) Withdraw(_ context.Context, req *message.WithdrawRequest) (*message.WithdrawResponse, error) {
	balance, exists := s.accounts[req.AccountId]
	if !exists {
		return nil, status.Error(codes.NotFound, "Account not found")
	}
	if balance < req.Amount {
		return nil, status.Error(codes.FailedPrecondition, "Insufficient funds")
	}
	s.accounts[req.AccountId] -= req.Amount
	return &message.WithdrawResponse{NewBalance: s.accounts[req.AccountId]}, nil
}
 
func main() {
	// Load app config
	config := config.New("server-config")
 
	// Print out the loaded configuration
	fmt.Printf("App Name: %s\n", config.App.Name)
	fmt.Printf("App Version: %s\n", config.App.Version)
	fmt.Printf("Server Host: %s\n", config.Server.Host)
	fmt.Printf("Server Port: %d\n", config.Server.Port)
	fmt.Printf("CA File: %s\n", config.TLS.CA)
	fmt.Printf("Cert File: %s\n", config.TLS.Cert)
	fmt.Printf("Key File: %s\n", config.TLS.Key)
 
	// Get TLS credentials
	cred := credentials.NewServerTLS(config)
 
	// Create a listener that listens to localhost port 8443
	lis, err := net.Listen("tcp", fmt.Sprintf(":%d", config.Server.Port))
	if err != nil {
		log.Fatalf("Failed to start listener %v", err)
	}
 
	// Close the listener when containing function terminates
	defer func() {
		err = lis.Close()
		if err != nil {
			log.Printf("Failed to close listener %v", err)
		}
	}()
 
	// Create a new gRPC server
	s := grpc.NewServer(grpc.Creds(cred))
	service.RegisterBankServiceServer(s, &server{accounts: make(map[string]float64)})
 
	// Start the gRPC server
	log.Printf("Server listening at %v", lis.Addr())
	if err := s.Serve(lis); err != nil {
		log.Fatalf("Failed to serve %v", err)
	}
}

gRPC Client

The final step is to develop the code for the gRPC client. Below is the code I used to create the client.

package main
 
import (
	"context"
	"fmt"
	"log"
	"time"
 
	"github.com/liambeeton/go-grpc-over-mtls/internal/config"
	"github.com/liambeeton/go-grpc-over-mtls/internal/credentials"
	"github.com/liambeeton/go-grpc-over-mtls/pb/message"
	"github.com/liambeeton/go-grpc-over-mtls/pb/service"
 
	"google.golang.org/grpc"
)
 
func main() {
	// Load app config
	config := config.New("client-config")
 
	// Print out the loaded configuration
	fmt.Printf("App Name: %s\n", config.App.Name)
	fmt.Printf("App Version: %s\n", config.App.Version)
	fmt.Printf("Server Host: %s\n", config.Server.Host)
	fmt.Printf("Server Port: %d\n", config.Server.Port)
	fmt.Printf("CA File: %s\n", config.TLS.CA)
	fmt.Printf("Cert File: %s\n", config.TLS.Cert)
	fmt.Printf("Key File: %s\n", config.TLS.Key)
 
	// Get TLS credentials
	cred := credentials.NewClientTLS(config)
 
	// Dial the gRPC server with the given credentials
	log.Printf("Client connecting to %s:%d", config.Server.Host, config.Server.Port)
	conn, err := grpc.NewClient(fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port), grpc.WithTransportCredentials(cred))
	if err != nil {
		log.Fatalf("Unable to connect gRPC channel %v", err)
	}
 
	// Close the listener when containing function terminates
	defer func() {
		err = conn.Close()
		if err != nil {
			log.Printf("Unable to close gRPC channel %v", err)
		}
	}()
 
	// Create the gRPC client
	c := service.NewBankServiceClient(conn)
 
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
 
	// Create account
	createResp, err := c.CreateAccount(ctx, &message.CreateAccountRequest{AccountId: "12345"})
	if err != nil {
		log.Fatalf("Could not create account %v", err)
	}
	log.Printf("Account created %v", createResp.AccountId)
 
	// Deposit
	depositResp, err := c.Deposit(ctx, &message.DepositRequest{AccountId: "12345", Amount: 100.0})
	if err != nil {
		log.Fatalf("Could not deposit %v", err)
	}
	log.Printf("New balance after deposit %v", depositResp.NewBalance)
 
	// Get balance
	balanceResp, err := c.GetBalance(ctx, &message.GetBalanceRequest{AccountId: "12345"})
	if err != nil {
		log.Fatalf("Could not get balance %v", err)
	}
	log.Printf("Balance %v", balanceResp.Balance)
 
	// Withdraw
	withdrawResp, err := c.Withdraw(ctx, &message.WithdrawRequest{AccountId: "12345", Amount: 50.0})
	if err != nil {
		log.Fatalf("Could not withdraw %v", err)
	}
	log.Printf("New balance after withdrawal %v", withdrawResp.NewBalance)
}