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 Case | Minimal | Maximal |
| TLS Server | digitalSignature + serverAuth | + keyEncipherment |
| TLS Client | digitalSignature + clientAuth | + keyAgreement |
| TLS General | digitalSignature + keyEncipherment + serverAuth + clientAuth | + keyAgreement |
| CA | keyCertSign + cRLSign | + digitalSignature |
| OCSP | digitalSignature + OCSPSigning | n/a |
| Mail | S/MIME | digitalSignature + emailProtection | + keyEncipherment |
| Code | digitalSignature + codeSigning | n/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