THE ENGINEERING JOURNAL

FIELD NOTES / Nginx

Build a certificate authority hierarchy with CFSSL

A 2023 CFSSL lab for understanding root, intermediate and server certificates, with notes on signing profiles and trust.

A branching hierarchy of white and blue prisms representing digital trust.

A server certificate is only one part of a trust chain. The server presents it, an intermediate CA signs it, and the client needs a trusted root to verify the chain.

This 2023 lab uses CFSSL, Cloudflare’s PKI toolkit, to create those three certificates. The example domain is example.com. Creating this private CA does not make browsers trust it, and generating the files does not configure a web server.

Example CA (root)
└── Example Intermediate CA
    └── example.com (server)

The original article did not record its CFSSL version. Keep that limit in mind when reproducing it. You’ll need cfssl and cfssljson; use the upstream installation instructions for your platform.

Use a disposable lab directory. These commands write private keys to disk, so keep the directory out of Git and shared storage. A production CA also needs a plan for key protection, renewal and revocation; this lab does not provide one.

Keep the root, intermediate and server files separate

The relative paths below assume you start with these sibling directories:

mkdir root intermediate server
cd root

Describe the root before generating its key

In root, create ca_csr.json. The subject fields describe the CA; the key block requests an RSA key. These are the names and key settings from the original lab.

{
  "CN": "Example CA",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "C": "AM",
      "L": "Yerevan",
      "O": "Thalamus",
      "OU": "IT",
      "ST": "Yerevan"
    }
  ]
}

A signing profile decides what a certificate may do

Still in root, create ca-config.json. This file keeps the original profiles for reference. The lab uses intermediate_ca and server; peer and client are unused.

The intermediate’s is_ca flag allows it to act as a CA. Together, max_path_len: 0 and max_path_len_zero: true prevent it from issuing another subordinate CA. CFSSL documents this pair in its signing configuration reference.

The intermediate profile also contains server and client authentication usages. Those broad permissions are part of the historical example, not a suggested production policy. Review each CA’s permitted uses before adapting it.

{
  "signing": {
    "default": {
      "expiry": "8760h"
    },
    "profiles": {
      "intermediate_ca": {
        "usages": [
            "signing",
            "digital signature",
            "key encipherment",
            "cert sign",
            "crl sign",
            "server auth",
            "client auth"
        ],
        "expiry": "8760h",
        "ca_constraint": {
            "is_ca": true,
            "max_path_len": 0,
            "max_path_len_zero": true
        }
      },
      "peer": {
        "usages": [
            "signing",
            "digital signature",
            "key encipherment",
            "client auth",
            "server auth"
        ],
        "expiry": "8760h"
      },
      "server": {
        "usages": [
          "signing",
          "digital signature",
          "key encipherment",
          "server auth"
        ],
        "expiry": "8760h"
      },
      "client": {
        "usages": [
          "signing",
          "digital signature",
          "key encipherment",
          "client auth"
        ],
        "expiry": "8760h"
      }
    }
  }
}

The root signs its own certificate

From root, run the original generation command:

cfssl gencert -initca ca_csr.json | cfssljson -bare ca -

CFSSL emits JSON; cfssljson writes ca.pem, ca-key.pem and ca.csr. The certificate can be shared with clients that should trust this CA. The private key must stay private. See the CFSSL output-file reference for the naming rules.

The 8760h signing profile above does not set the lifetime of this self-signed root: this command does not read ca-config.json. CFSSL’s CA initialization code uses the request’s CA settings or its initialization defaults. Inspect the resulting certificate instead of assuming its expiry.

The root signs the intermediate’s request

Move to intermediate and create intermediate.json:

cd ../intermediate/
{
  "CN": "Example Intermediate CA",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "C": "AM",
      "L": "Yerevan",
      "O": "Thalamus",
      "OU": "IT",
      "ST": "Yerevan"
    }
  ],
  "ca": {
    "expiry": "42720h"
  }
}

The two commands below do different jobs. The first creates the intermediate’s key, CSR and an initial self-signed certificate. The second signs that CSR with the root and replaces the intermediate certificate with the root-signed one.

cfssl gencert -initca intermediate.json | cfssljson -bare intermediate_ca

cfssl sign -ca ../root/ca.pem \
  -ca-key ../root/ca-key.pem \
  -config ../root/ca-config.json \
  -profile intermediate_ca intermediate_ca.csr | cfssljson -bare intermediate_ca

There are two expiry values here. The request’s 42720h applies to the initial self-signed certificate. The final signing command selects intermediate_ca, whose expiry is 8760h (365 days). Do not read the request’s longer value as the final intermediate’s lifetime.

Put the server’s DNS name in the request

Move to server and create example.json:

cd ../server/
{
  "CN": "example.com",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "C": "AM",
      "L": "Yerevan",
      "O": "Thalamus",
      "OU": "IT",
      "ST": "Yerevan"
    }
  ],
  "hosts": [
    "example.com"
  ]
}

The hosts entry supplies the DNS name for the Subject Alternative Name extension. Replace it with the name your client will connect to. Do not rely on changing only CN.

Sign the request with the intermediate’s certificate and private key:

cfssl gencert -ca ../intermediate/intermediate_ca.pem \
  -ca-key ../intermediate/intermediate_ca-key.pem \
  -config ../root/ca-config.json \
  -profile=server example.json | cfssljson -bare example

This produces the server certificate and key. Keep both CA private keys away from the web server. The server needs its own key and the certificate chain required by its TLS configuration.

Check the chain before changing system trust

The original article jumped straight to installing the root. You can inspect the chain without doing that. From server, the following diagnostic checks the certificate for the example hostname and TLS server purpose:

openssl verify -CAfile ../root/ca.pem \
  -untrusted ../intermediate/intermediate_ca.pem \
  -purpose sslserver -verify_hostname example.com example.pem

-CAfile identifies the trusted root for this command. -untrusted supplies the intermediate so OpenSSL can build the chain; it does not make the intermediate a trust anchor. An OK result means this verification passed, not that a web server has been configured or tested. The OpenSSL verification manual explains the options.

Also inspect validity dates and extensions with openssl x509 -in example.pem -noout -text, then inspect each CA certificate. Check the DNS name, issuer, CA constraints and expiry dates. These inspection steps have not been run against a recreated version of this lab.

Installing a root changes what the machine trusts

Only install this root on a machine where you intend to trust certificates issued beneath it. The original Debian/Ubuntu commands were:

sudo cp ../root/ca.pem /usr/local/share/ca-certificates/example.crt
sudo update-ca-certificates --fresh

Copy ca.pem, never ca-key.pem. Debian’s update-ca-certificates manual requires PEM certificates with a .crt extension in this directory, one certificate per file. The original --fresh option rebuilds the certificate symlinks; it is not required just to add this root. Applications with a separate trust store may need their own configuration.

To take this lab further, configure a local TLS server to send its server certificate and intermediate, then check the connection from a client that trusts the root. Check the hostname and the chain the server sends. A successful local certificate check alone cannot tell you whether that connection works.