Service to Service Authorization in Go Using X.509 Certificates

Service-to-service authentication is the ability of one service to identify its clients. It’s a good idea to ensure that a service accepts requests only from specified services. But how to implement access controls (authorization)?

For instance, only the service cart is granted to bind to the service invoice, and only two services invoice and billing are granted to bind to the service payment.

Alt text

Firstly, we must decide on the unique key of service, the attribute of a caller to be verified by a recipient. Do you think the IP address or hostname is manageable in the infrastructure at scale? It’s challenging in a multi-tenant environment where different personas can park successively the same network identity in a short period.

Let’s consider an X.509 certificate which is a widely used standard for network security and identity verification.

Give a secure identity to components

We could go over the long way to set up your own Certificate Authority and sign a certificate using OpenSSL. For the sake of simplicity, I use the cfssl toolkit for certificates management.

Installation

go get -u github.com/cloudflare/cfssl/cmd/...

Configuration

The cfssl toolkit expects two files to generate a certificate:

  • config.json – settings for the issuer
  • csr.json – request for a certificate.

The file config.json contains settings for the Certificate Authority. We are interested in the profiles section for our certificates:

{
  "signing": {
    "profiles": {
      "service": {
        "usages": [
          "signing",
          "key encipherment",
          "server auth",
          "client auth"
        ],
        "expiry": "720h"
      }
    }
  }
}

The file csr/ca.json contains the request for the Certificate Authority certificate:

{
  "CN": "Demo Citadel",
  "names": [
    {
      "C": "US",
      "L": "San Francisco",
      "O": "Citadel, Inc.",
      "ST": "CA"
    }
  ],
  "ca": {
    "expiry": "86700h"
  }
}

The file csr/service.json contains the request for the service certificate:

{
  "CN": "citadel.xyz",
  "names": [
    {
      "C": "US",
      "L": "San Francisco",
      "O": "Citadel, Inc.",
      "ST": "CA"
    }
  ]
}

Generating

The Certificate Authority certificate:

cfssl gencert -initca csr/ca.json | cfssljson -bare pki/ca –

Meantime, we reached the important step and have to define the unique attribute of service. Usually, the service has a name and it belongs to a company. Hence we need to stamp the identity citadel:srv-invoice into the handcrafted X.509 certificate along with the server hostname host1.infra.citadel.net:

cfssl gencert \
-ca pki/ca.pem -ca-key pki/ca-key.pem \
-config config.json \
-profile service \
-hostname 'host1.infra.citadel.net,citadel:srv-invoice' \
csr/service.json | cfssljson -bare pki/srv-invoice-host1 -

Let’s check the resulted alternative name from the issued certificate:

openssl x509 -in pki/srv-invoice-host1.pem -text | grep -A 1 'Alternative Name'
X509v3 Subject Alternative Name:
    DNS:host1.infra.citadel.net, URI:citadel:srv-invoice

💚 The service identity citadel:srv-invoice successfully assigned to the server host1.infra.citadel.net.

Consume the service identity in Go

Fortunately, the callback VerifyConnection of tls.Config was introduced in Go 1.15:

package tls

type Config struct {
    VerifyConnection func(ConnectionState) error
}

The Go documentation says: if the function returns an error, the TLS handshake is aborted. Hence we are enabled to restrain the TLS connection permit by own.

For testing purposes, we make the simplest access control by a prefix:

var errAccessForbidden = errors.New("access forbidden")

func verifyPeerPrefix(prefix string) func(tls.ConnectionState) error {
	return func(s tls.ConnectionState) error {
		// The first element is the leaf certificate
		// that the connection is verified against.
		for _, uri := range s.PeerCertificates[0].URIs {
			if strings.HasPrefix(uri.String(), prefix) {
				return nil
			}
		}
		return errAccessForbidden
	}
}

Then prepare tls.Config for the server listener. Necessarily, the callback should be provided in conjunction with the mutual TLS authentication enabled:

cfg := tls.Config{
	ClientAuth:       tls.RequireAndVerifyClientCert,
	VerifyConnection: verifyPeerPrefix("citadel:srv"),
}

The constant tls.RequireAndVerifyClientCert of tls.ClientAuthType indicates the server will request a client certificate and if none is provided the session will terminate, if the client certificate cannot be verified (a corresponding CA (root) certificate cannot be found) the session will also be terminated.

The full source of the example server:

package main

import (
	"crypto/tls"
	"crypto/x509"
	"errors"
	"flag"
	"io"
	"io/ioutil"
	"log"
	"net"
	"strings"
)

var (
	listenAddr   = flag.String("listen-addr", "127.0.0.1:8080", "Listen address for incoming connections")
	certFile     = flag.String("cert", "", "Path to a PEM-encoded certificate")
	keyFile      = flag.String("key", "", "Path to a private key matching the PEM-encoded certificate")
	caFile       = flag.String("ca", "", "Path to a bundle of root certificates")
	acceptPrefix = flag.String("accept-prefix", "", "Accept client certificates only with this prefix")
)

func main() {
	flag.Parse()

	caCert, err := ioutil.ReadFile(*caFile)
	if err != nil {
		log.Fatal("ca cert file could not be read:", err)
	}
	rootCAs := x509.NewCertPool()
	if !rootCAs.AppendCertsFromPEM(caCert) {
		log.Fatal("ca cert file could not be installed")
	}

	clientCert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
	if err != nil {
		log.Fatal("client cert could not be loaded:", err)
	}

	cfg := tls.Config{
		Certificates:     []tls.Certificate{clientCert},
		ClientCAs:        rootCAs,
		ClientAuth:       tls.RequireAndVerifyClientCert,
		MinVersion:       tls.VersionTLS12,
		VerifyConnection: verifyPeerPrefix(*acceptPrefix),
	}

	l, err := tls.Listen("tcp", *listenAddr, &cfg)
	if err != nil {
		log.Fatal("listen failed:", err)
	}

	log.Println("Listen:", *listenAddr)

	for {
		conn, err := l.Accept()
		if err != nil {
			log.Println("ERR! accept failed:", err)
			continue
		}
		go talk(conn)
	}
}

var errAccessForbidden = errors.New("access forbidden")

func verifyPeerPrefix(prefix string) func(tls.ConnectionState) error {
	return func(s tls.ConnectionState) error {
		// The first element is the leaf certificate
		// that the connection is verified against.
		for _, uri := range s.PeerCertificates[0].URIs {
			if strings.HasPrefix(uri.String(), prefix) {
				return nil
			}
		}
		return errAccessForbidden
	}
}

func talk(conn net.Conn) {
	addr := conn.RemoteAddr()

	defer func() {
		_ = conn.Close()
	}()

	log.Println(addr, "connected")
	_, err := io.Copy(conn, conn) // send back what we get
	log.Println(addr, "disconnected:", err)
}

Run the server to accept TLS connections from the identity citadel:srv-cart only:

go run main.go -ca pki/ca.pem \
-cert pki/srv-invoice-host1.pem -key pki/srv-invoice-host1-key.pem \
-accept-prefix citadel:srv-cart
2020/10/27 16:58:44 Listen: 127.0.0.1:8080

✔️ The server is ready. Let’s run the client.

Issue the certificate for the cart service:

cfssl gencert \
-ca pki/ca.pem -ca-key pki/ca-key.pem \
-config config.json \
-profile service \
-hostname 'host2.infra.citadel.net,citadel:srv-cart' \
csr/service.json | cfssljson -bare pki/srv-cart-host2 -

Then run the client:

openssl s_client \
-connect 127.0.0.1:8080 \
-cert pki/srv-cart-host2.pem \
-key pki/srv-cart-host2-key.pem -CAfile pki/ca.pem
CONNECTED(00000003)
depth=1 C = US, ST = CA, L = San Francisco, O = "Citadel, Inc.", CN = Demo Citadel
verify return:1
depth=0 C = US, ST = CA, L = San Francisco, O = "Citadel, Inc.", CN = citadel.xyz
verify return:1
---
Certificate chain
 0 s:/C=US/ST=CA/L=San Francisco/O=Citadel, Inc./CN=citadel.xyz
   i:/C=US/ST=CA/L=San Francisco/O=Citadel, Inc./CN=Demo Citadel
...
    Verify return code: 0 (ok)
---

💚 The command openssl is hanging and waiting for the input. It means that the client granted successfully to bind the server.

😒 What happens if the client identity doesn’t match the server expectations?

Restart the server with the parameter -accept-prefix citadel:srv-noname. Then the same client terminates with non-zero code and the output contains the error:

openssl s_client \
-connect 127.0.0.1:8080 \
-cert pki/srv-cart-host2.pem \
-key pki/srv-cart-host2-key.pem -CAfile pki/ca.pem
...
4613156460:error:14020412:SSL routines:CONNECT_CR_SESSION_TICKET:sslv3 alert bad certificate:/AppleInternal/BuildRoot/Library/Caches/com.apple.xbs/Sources/libressl/libressl-47.140.1/libressl-2.8/ssl/ssl_p
kt.c:1200:SSL alert number 42
4613156460:error:140200E5:SSL routines:CONNECT_CR_SESSION_TICKET:ssl handshake failure:/AppleInternal/BuildRoot/Library/Caches/com.apple.xbs/Sources/libressl/libressl-47.140.1/libressl-2.8/ssl/ssl_pkt.c:5
85:
...

💚 The server declines the connection because the prefix citadel:srv-noname doesn’t match the client identity citadel:srv-cart:

go run main.go -ca pki/ca.pem \
-cert pki/srv-invoice-host1.pem -key pki/srv-invoice-host1-key.pem \
-accept-prefix citadel:srv-noname
2020/10/27 17:22:02 Listen: 127.0.0.1:8080
2020/10/27 17:22:10 127.0.0.1:50161 connected
2020/10/27 17:22:10 127.0.0.1:50161 disconnected: access forbidden

Summary

Hooray! We implemented service-to-service authorization based on well-known standards (X.509 certificates and the TLS protocol) with vital features out of the box:

  • Identity issuing through the standard toolkit for generating certificates.
  • Identity verification by the trusted Certificate Authority
  • Identity expiration and revocation to be confident that the temporary access or a compromised certificate have never been used for years.

And the approach has the undeniable advantage to be fluently integrated into the running infrastructure leveraging the TLS protocol. For instance, traffic in Service Mesh is not only secured by TLS, but it’s also hardened by access and identity management through X.509 certificates.

Your home task is to implement a lightweight TLS reverse-proxy to govern connections to a database (for example, MySQL). Look at Spiffe if you are not curious to start from scratch.

comments powered by Disqus