Set up an ACME CA for your network.
Small and simple for let’s say your DEV or QA environment.

Software

I decided for step-ca
Here’s a repo for RPMs

# cat /etc/yum.repos.d/smallstep.repo
[smallstep]
name=Smallstep
baseurl=https://packages.smallstep.com/stable/fedora/
enabled=1
gpgcheck=0
gpgkey=https://packages.smallstep.com/keys/smallstep-0x889B19391F774443.gpg

Install and create user

# yum makecache
# yum install -y step-cli step-ca

# useradd -m step-ca

Configuration

Initialize your new CA

# su - step-ca
$ step ca init

Create your ACME endpoint and start your server

$ step ca provisioner add v1 --type ACME
$ step-ca .step/config/ca.json

My config ended up like this.
As you can see, I added some templates to the ACME and the default JWK provisioners.
Also, I added/enabled a CRL endpoint as well as publishing the CRL and an OCSP endpoint to each certificate.

# cat /home/step-ca/.step/config/ca.json
{
        "root": "/home/step-ca/.step/certs/root_ca.crt",
        "federatedRoots": null,
        "crt": "/home/step-ca/.step/certs/intermediate_ca.crt",
        "key": "/home/step-ca/.step/secrets/intermediate_ca_key",
        "address": "127.0.0.1:1666",
        "insecureAddress": "127.0.0.1:1667",
        "dnsNames": [
                "ca.domain.de",
                "ca.domain2.de"
        ],
        "logger": {
                "format": "text"
        },
        "db": {
                "type": "badgerv2",
                "dataSource": "/home/step-ca/.step/db",
                "badgerFileLoadingMode": ""
        },
        "crl": {
                "enabled": true,
                "disable": false,
                "idpURL": "http://ca.domain.de/1.0/crl"
        },
        "authority": {
                "provisioners": [
                        {
                                "type": "JWK",
                                "name": "certmaster@domain.de",
                                "key": {
                                        "use": "sig",
                                        "kty": "EC",
                                        "kid": "-VkzSzPOi[...]3qoedQ",
                                        "crv": "P-256",
                                        "alg": "ES256",
                                        "x": "V8h5ftw[...]RDnNgCI",
                                        "y": "9GxfB9M[...]hzshi_s"
                                },
                                "encryptedKey": "eyJhbGci[...]6D0RBaCvTHq1k5ccg8g",
                                "claims": {
                                        "disableRenewal": false,
                                        "disableSmallstepExtensions": true,
                                        "defaultTLSCertDuration": "2160h",
                                        "maxTLSCertDuration": "87600h"
                                },
                                "options": {
                                        "x509": {
                                                "templateFile": "templates/ocsp.tpl",
                                                "authorityInfoAccess": ["OCSP;URI:http://ca.domain.de/ocsp"],
                                                "crlDistributionPoints": ["http://ca.domain.de/crl"]
                                        },
                                        "ssh": {}
                                }
                        },
                        {
                                "type": "ACME",
                                "name": "v1",
                                "claims": {
                                        "enableSSHCA": false,
                                        "disableRenewal": false,
                                        "allowRenewalAfterExpiry": true,
                                        "disableSmallstepExtensions": true,
                                        "defaultTLSCertDuration": "2160h",
                                        "maxTLSCertDuration": "2160h"
                                },
                                "options": {
                                        "x509": {
                                                "templateFile": "templates/default.tpl",
                                                "authorityInfoAccess": ["OCSP;URI:http://ca.domain.de/ocsp"],
                                                "crlDistributionPoints": ["http://ca.domain.de/crl"]
                                        },
                                        "ssh": {}
                                }
                        }
                ],
                "options": {
                        "x509": {}
                },
                "template": {
                        "x509": {
                            "default": "templates/default.tpl"
                        }
                },
                "crl": {
                        "disable": false,
                        "cacheDuration": "1h",
                        "generateOnRevoke": true
                },
                "backdate": "1m0s"
        },
        "tls": {
                "cert": "/etc/letsencrypt/live/domain.de/fullchain.pem",
                "key": "/etc/letsencrypt/live/domain.de/privkey.pem",
                "cipherSuites": [
                        "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
                        "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
                ],
                "minVersion": 1.2,
                "maxVersion": 1.3,
                "renegotiation": false
        },
        "commonName": "Step Online CA"
}

Templates

Templates are files describing which information to put into the certificates.
My ACME template is minimal and may needs adjustments.
See step-ca documentation at https://u.step.sm/docs/ca or join a Discord community at https://u.step.sm/discord

keyUsage and EKU basics

Use CaseMinimalMaximal
TLS ServerdigitalSignature + serverAuth+ keyEncipherment
TLS ClientdigitalSignature + clientAuth+ keyAgreement
TLS GeneraldigitalSignature + keyEncipherment
+ serverAuth + clientAuth
+ keyAgreement
CAkeyCertSign + cRLSign+ digitalSignature
OCSPdigitalSignature + OCSPSigningn/a
Mail | S/MIMEdigitalSignature + emailProtection+ keyEncipherment
CodedigitalSignature + codeSigningn/a

ACME default template.

$ cat .step/templates/default.tpl
{
  "subject": {
    "commonName": {{ toJson ((index .SANs 0).Value) }}
  },
  "sans": {{ toJson .SANs }},
  "keyUsage": ["digitalSignature", "keyEncipherment"],
  "extKeyUsage": ["serverAuth", "clientAuth"],
  "crlDistributionPoints": ["http://ca.domain.de/1.0/crl"],
  "ocspServer": ["http://ca.domain.de/ocsp"]
}

Here’s a template template file.
You can’t use this directly with step-ca but need to create a single file for each of the use cases, like the one above. So, this file is just a template for reference!

{
  "x509": {
    "server": {
      "subject": {
        "commonName": {{ toJson ((index .SANs 0).Value) }}
      },
      "keyUsage": [
        "digitalSignature",
        "keyEncipherment"
      ],
      "extKeyUsage": [
        "serverAuth"
      ]
    },

    "client": {
      "subject": {
        "commonName": {{ toJson ((index .SANs 0).Value) }}
      },
      "keyUsage": [
        "digitalSignature"
      ],
      "extKeyUsage": [
        "clientAuth"
      ]
    },

    "ocsp": {
      "subject": {
        "commonName": {{ toJson ((index .SANs 0).Value) }}
      },
      "keyUsage": [
        "digitalSignature"
      ],
      "extKeyUsage": [
        "ocspSigning"
      ]
    },

    "email": {
      "subject": {
        "commonName": {{ toJson ((index .SANs 0).Value) }}
      },
      "keyUsage": [
        "digitalSignature",
        "keyEncipherment"
      ],
      "extKeyUsage": [
        "emailProtection"
      ]
    },

    "codesign": {
      "subject": {
        "commonName": {{ toJson ((index .SANs 0).Value) }}
      },
      "keyUsage": [
        "digitalSignature"
      ],
      "extKeyUsage": [
        "codeSigning"
      ]
    },

    "timestamp": {
      "subject": {
        "commonName": {{ toJson ((index .SANs 0).Value) }}
      },
      "keyUsage": [
        "digitalSignature"
      ],
      "extKeyUsage": [
        "timeStamping"
      ]
    }
  }
}

CA CRL

We already enabled a CRL and defined an idpURL by adding this to our config.

        "crl": {
                "enabled": true,
                "disable": false,
                "idpURL": "http://ca.domain.de/1.0/crl"
        },

Don’t be worried about ‘enabled = true’ and ‘disable = false’.
Both exist and the documentation is not clear about which to use, so I decided to put in both, as it doen’t hurt.

We made sure the idpURL is put into our certs, by adding this to our ACME default template

"crlDistributionPoints": ["http://ca.domain.de/1.0/crl"],

So, we’re fine.

OCSP responder

step-ca does not include an OCSP responder with the comunity version

So, what to do?
After looking for some kind of open source solution I found that everything out there is massively oversized for my development environment or not working well with an HTTP idpURL. So, I decided to write some lines of python code to achieve this.

At first we need to create a certificate that has the correct keyUsage and EKU values.
You can find a template for an OSCP Signing cert in the templates file. Create an ocsp.tpl file in the ‘templates’ folder and put it into JWK provisioner. (In the config example above, this is already done, so just create the tpl file.)

Create the OSCP Responder certificate

$ step ca certificate --provisioner certmaster@domain.de \
     --password-file ./ca-password.txt --san ca.domain.de \
     --not-after 87600h --offline "OCSP Responder" \
     ~/.step/certs/ocspd_cert.pem ~/.step/secrets/ocspd_key.pem

The my script does the following:

  • It loads various certificates such as the signing intrmediate ca cert.
  • It opens a stream from journalctl for unit step-ca (unit file for step-ca can be found below)
  • It writes a database containing all certificates that step-ca issued
  • It opens the idpURL CRL list to see which certs have been revoked.
  • It’s waiting for OCSP requests and will answer these.
    • Good -> If cert is valid and signed by this ca
    • Revoked -> If cert has been found in CRL
    • Unknown -> If cert is not revoked or not signed by this ca

The python code

#!/usr/bin/env python3
#
# pyocspd
#
import re
import sqlite3
import subprocess
import threading

from bottle import Bottle, request, response
from datetime import datetime, timedelta, timezone

import requests
from cryptography import x509
from cryptography.x509 import ocsp
from cryptography.hazmat.primitives import hashes, serialization

INSTALL_PATH = "/opt/pyocsp"
ISSUER_CERT_FILE = "/home/step-ca/.step/certs/intermediate_ca.crt"
OCSP_CERT_FILE = "/home/step-ca/.step/certs/ocspd_cert.pem"
OCSP_KEY_FILE = "/home/step-ca/.step/secrets/ocspd_key.pem"

CRL_URL = "http://ca.domain.de/1.0/crl"

# Load certs
with open(ISSUER_CERT_FILE, "rb") as f:
    issuer_cert = x509.load_pem_x509_certificate(f.read())

with open(OCSP_CERT_FILE, "rb") as f:
    responder_cert = x509.load_pem_x509_certificate(f.read())

with open(OCSP_KEY_FILE, "rb") as f:
    from cryptography.hazmat.primitives.serialization import load_pem_private_key
    responder_key = load_pem_private_key(f.read(), password=None)

def follow_logs(conn):
    p = subprocess.Popen(
        ["journalctl", "-u", "step-ca", "-f", "-o", "cat"],
        stdout=subprocess.PIPE,
        text=True
    )

    for line in p.stdout:
        cert = parse_line(line)
        if cert:
            store_cert(conn, cert)

def parse_line(line):
    # nur relevante Zeilen
    if 'sans="map' not in line:
        return None

    data = {}

    for match in re.finditer(r'([\w-]+)=(".*?"|\S+)', line):
        key = match.group(1)
        value = match.group(2).strip('"')
        data[key] = value

    if "serial" not in data:
        return None

    return {
        "serial": data["serial"],
        "subject": data.get("subject"),
        "valid_from": data.get("valid-from"),
        "valid_to": data.get("valid-to"),
    }

def init_db(path=f"{INSTALL_PATH}/certs.db"):
    conn = sqlite3.connect(path, check_same_thread=False)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS certs (
            serial TEXT PRIMARY KEY,
            subject TEXT,
            valid_from TEXT,
            valid_to TEXT
        )
    """)
    return conn

def store_cert(conn, cert):
    conn.execute(
        "INSERT OR IGNORE INTO certs VALUES (?, ?, ?, ?)",
        (cert["serial"], cert["subject"], cert["valid_from"], cert["valid_to"])
    )
    conn.commit()

def fetch_crl():
    r = requests.get(CRL_URL, timeout=5)
    data = r.content

    try:
        return x509.load_der_x509_crl(data)
    except:
        return x509.load_pem_x509_crl(data)

def is_issued(conn, serial):
    cur = conn.execute(
        "SELECT 1 FROM certs WHERE serial=?",
        (str(serial),)
    )
    return cur.fetchone() is not None

def is_revoked(serial):
    crl = fetch_crl()
    for rc in crl:
        if rc.serial_number == serial:
            return True, rc.revocation_date_utc
    return False, None

def check_status(serial):
    if is_revoked(serial):
        return "revoked"

    if is_issued(conn, serial):
        return "good"

    return "unknown"

app = Bottle()

@app.post("/ocsp")
def ocsp_handler():
    try:
        raw = request.body.read()
        ocsp_req = ocsp.load_der_ocsp_request(raw)

        serial = ocsp_req.serial_number

        # Nur EINMAL revocation prüfen
        revoked, rev_time = is_revoked(serial)

        # good/unknown aus DB ableiten
        if revoked:
            status = "revoked"
        elif is_issued(conn, serial):
            status = "good"
        else:
            status = "unknown"

        now = datetime.now(timezone.utc)

        builder = ocsp.OCSPResponseBuilder()

        if status == "revoked":
            builder = builder.add_response_by_hash(
                issuer_name_hash=ocsp_req.issuer_name_hash,
                issuer_key_hash=ocsp_req.issuer_key_hash,
                serial_number=serial,
                algorithm=hashes.SHA1(),
                cert_status=ocsp.OCSPCertStatus.REVOKED,
                this_update=now,
                next_update=now + timedelta(minutes=10),
                revocation_time=rev_time,
                revocation_reason=None,
            )
        elif status == "good":
            builder = builder.add_response_by_hash(
                issuer_name_hash=ocsp_req.issuer_name_hash,
                issuer_key_hash=ocsp_req.issuer_key_hash,
                serial_number=serial,
                algorithm=hashes.SHA1(),
                cert_status=ocsp.OCSPCertStatus.GOOD,
                this_update=now,
                next_update=now + timedelta(minutes=10),
                revocation_time=None,
                revocation_reason=None,
            )
        else:
            builder = builder.add_response_by_hash(
                issuer_name_hash=ocsp_req.issuer_name_hash,
                issuer_key_hash=ocsp_req.issuer_key_hash,
                serial_number=serial,
                algorithm=hashes.SHA1(),
                cert_status=ocsp.OCSPCertStatus.UNKNOWN,
                this_update=now,
                next_update=now + timedelta(minutes=10),
                revocation_time=None,
                revocation_reason=None,
            )

        resp = builder.responder_id(
            ocsp.OCSPResponderEncoding.NAME,
            responder_cert
        ).sign(
            responder_key,
            hashes.SHA256()
        )

        response.status = 200
        response.content_type = "application/ocsp-response"
        return resp.public_bytes(serialization.Encoding.DER)

    except Exception:
        import traceback
        traceback.print_exc()
        response.status = 500
        response.content_type = "application/ocsp-response"
        return b""


conn = init_db()
threading.Thread(target=follow_logs, args=(conn,), daemon=True).start()
app.run(host="127.0.0.1", port=1668)

System Integration

At this point we have everything set up listening on 127.0.0.1
To integrate into the system and make it publicly available I use nginx.
(Publicly means every system that is able to reach out to your box, if that is the internet, so be it)
At first you need to create unit files for systemd to start the 2 services.

Systemd Unit-Files

step-ca

# /usr/lib/systemd/system/step-ca.service
[Unit]
Description=SH ACME CA
After=network.target

[Service]
Type=simple

# User / Group
User=step-ca
Group=step-ca

# Passwordfile 
ExecStart=/usr/bin/step-ca \
    --password-file /home/step-ca/ca-password.txt \
    /home/step-ca/.step/config/ca.json

# Restart
Restart=on-failure
RestartSec=5

# Limits
LimitNOFILE=65536

# Security Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
LockPersonality=true
MemoryDenyWriteExecute=true

# Optional Debug
Environment=STEPDEBUG=1

[Install]
WantedBy=multi-user.target

Now reload systemctl daemon and start step-ca

# systemctl daemon-reload
# systemctl enable --now step-ca

pyocsp

Install the python script

# mkdir -p /opt/pyocsp/bin
# cp pyocsp.py /opt/pyocsp/bin/
# chown -R step-ca:step-ca /opt/pyocsp

To start the service create the systemd unit file

# /usr/lib/systemd/system/pyocspd.service
[Unit]
Description=Python OCSP Responder
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=step-ca
Group=step-ca
WorkingDirectory=/opt/pyocsp
ExecStart=/usr/bin/python3 /opt/pyocsp/bin/pyocsp.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Reload systend and start OCSP responder

# systemctl daemon-reload
# systemctl enable --now pyocspd

Nginx

After both, step-ca and OCSP responder are running and listening on 127.0.0.1, we put a proxy in front of them.
Create an nginx server-config file like this:

# CA ACME
limit_req_zone $binary_remote_addr zone=acme:10m rate=10r/s;
server {
  listen 80;
  listen [::]:80;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  keepalive_timeout   70;

  server_name ca.domain.de;
  root /var/www/domain.de/ca;

  ssl_certificate /etc/letsencrypt/live/ca.domain.de/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/ca.domain.de/privkey.pem;
  ssl_protocols       TLSv1.3 TLSv1.2;
  # TLS 1.3 only
  #ssl_ciphers                    TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:TLS_AES_128_CCM_8_SHA256:TLS_AES_128_CCM_SHA256;

  ssl_ciphers                     TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:TLS_AES_256_GCM_SHA384:TLS-AES-256-GCM-SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS-CHACHA20-POLY1305-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-SHA:DHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;

  ssl_prefer_server_ciphers On;
  ssl_session_cache   shared:SSL:10m;
  ssl_session_timeout 15m;
  ssl_dhparam /etc/nginx/ssl/dhparam.pem;
  ssl_ecdh_curve              secp521r1:secp384r1;

  add_header Strict-Transport-Security "max-age=31536000" always;

  location /ocsp {
    try_files $uri $uri/ @ocspd;
  }

  location / {
    index index.html;
    try_files $uri $uri/ @acmed;
  }

  location @acmed {
    limit_req zone=acme burst=20 nodelay;

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_http_version 1.1;

    proxy_pass https://127.0.0.1:1666;
  }

  location @ocspd {
    proxy_buffering off;

    proxy_pass http://127.0.0.1:1668;
  }
}

Reload nginx

# systemctl reload nginx

Using the CA

On a system that can reach your ACME server create an account while getting your first cert.

Install certbot on that system and create an .ini file /etc/myCA/cli.ini

# ACME Server (step-ca)
server = https://ca.domain.de/acme/v1/directory

# Default Installer
installer = none

# Account
email = you@youremaildomain.com
agree-tos = true
non-interactive = true

# Paths
config-dir = /etc/myCA
work-dir = /etc/myCA/work
logs-dir = /etc/myCA/log

# Standard Challenge (optional Fallback)
preferred-challenges = http,dns

Get your first cert for your CA

# certbot certonly --config /etc/myCA/cli.ini --authenticator standalone -d test1.test.com

The rest is the same like with letsencrypt.
You will find your cert/fullchain/key here:

# ls -al /etc/myCA/live/test1.test.com/
/etc/myCA/live/test1.test.com:
total 12
drwxr-xr-x 2 root root 4096 May 28 12:46 .
drwx------ 4 root root 4096 May 28 12:46 ..
-rw-r--r-- 1 root root  692 May 28 12:46 README
lrwxrwxrwx 1 root root   44 May 28 12:46 cert.pem -> ../../archive/test1.test.com/cert1.pem
lrwxrwxrwx 1 root root   45 May 28 12:46 chain.pem -> ../../archive/test1.test.com/chain1.pem
lrwxrwxrwx 1 root root   49 May 28 12:46 fullchain.pem -> ../../archive/test1.test.com/fullchain1.pem
lrwxrwxrwx 1 root root   47 May 28 12:46 privkey.pem -> ../../archive/test1.test.com/privkey1.pem

Leave a Reply