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
.
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 issuercsr.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
oftls.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.