This post sets up a separate workstation to sign IoT device (client) certificates and return a single leaf PEM per device.
The previous post installs and hardens the CA server only:
Prerequisite: Deploying Step CA: Your Own Certificate Authority for Internal Services
What you’ll build
- A template that sets SAN=CN; the device generates an EC P-256 key.
- A workstation that holds a JWK provisioner private key, mints a short-lived JWT (OTT), and exchanges a device CSR for one file only:
<DEVICE_ID>.crt.pem
. - A device that generates its own key + CSR (private key never leaves the device).
- Workstation — Generate the JWK first
This machine is not your CA; it only mints short-lived tokens and calls the CA.
# Install tools
sudo apt-get update && sudo apt-get install -y --no-install-recommends curl vim gpg ca-certificates
sudo curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg -o /etc/apt/trusted.gpg.d/smallstep.asc
echo 'deb [signed-by=/etc/apt/trusted.gpg.d/smallstep.asc] https://packages.smallstep.com/stable/debian debs main' \
| sudo tee /etc/apt/sources.list.d/smallstep.list
sudo apt-get update && sudo apt-get -y install step-cli
# Create the JWK keypair (demo: --insecure; for production, omit and use a passphrase/HSM)
mkdir -p ~/enroll && cd ~/enroll
step crypto jwk create enroll.pub.jwk enroll.priv.jwk --insecure
chmod 600 enroll.priv.jwk
# Copy the PUBLIC key to the CA host (adjust user/host)
scp ~/enroll/enroll.pub.jwk administrator@stepca.maksonlee.com:/tmp/
- CA server — Template + Provisioner (uses the workstation’s public JWK)
Run this on the CA host.
# 1) Create the IoT client template (SAN = CN)
sudo tee /etc/step-ca/templates/iot-device.tpl >/dev/null <<'TPL'
{
"subject": {
"country": "TW",
"province": "Taipei",
"locality": "Taipei",
"organization": "maksonlee.com",
"organizationalUnit": "Devices",
"commonName": "{{ .Subject.CommonName }}"
},
"dnsNames": ["{{ .Subject.CommonName }}"],
"keyUsage": ["digitalSignature"],
"extKeyUsage": ["clientAuth"],
"validity": "26280h"
}
TPL
sudo chown step:step /etc/step-ca/templates/iot-device.tpl
# 2) Place the workstation public JWK
sudo mkdir -p /etc/step-ca/provisioners
sudo mv /tmp/enroll.pub.jwk /etc/step-ca/provisioners/
# 3) Add (or update) the JWK provisioner named "enroll"
sudo step ca provisioner add enroll \
--type JWK \
--public-key /etc/step-ca/provisioners/enroll.pub.jwk \
--x509-template /etc/step-ca/templates/iot-device.tpl \
--disable-renewal \
--ca-url https://stepca.maksonlee.com \
--root /etc/step-ca/certs/root_ca.crt \
--ca-config /etc/step-ca/config/ca.json || \
sudo step ca provisioner update enroll \
--public-key /etc/step-ca/provisioners/enroll.pub.jwk \
--x509-template /etc/step-ca/templates/iot-device.tpl \
--disable-renewal \
--ca-config /etc/step-ca/config/ca.json \
--ca-url https://stepca.maksonlee.com \
--root /etc/step-ca/certs/root_ca.crt
# 4) Reload the CA
sudo systemctl restart step-ca
- Workstation — Trust the CA + install the leaf-only helper
# On the CA, get the Root fingerprint:
# sudo step certificate fingerprint /etc/step-ca/certs/root_ca.crt
# On the workstation, bootstrap with that fingerprint:
step ca bootstrap \
--ca-url https://stepca.maksonlee.com \
--fingerprint "<PASTE_ROOT_FINGERPRINT>"
# Install a minimal helper that returns only <CN>.crt.pem
sudo tee /usr/local/bin/ws-enroll-lite >/dev/null <<'SH'
#!/usr/bin/env bash
# ws-enroll-lite: sign a CSR via Step CA and output only <CN>.crt.pem
# Requires: step-cli, openssl (and you've already run `step ca bootstrap`)
set -euo pipefail
CA_URL="${CA_URL:-https://stepca.maksonlee.com}" # not strictly needed if bootstrapped
PROV="${PROV:-enroll}"
JWK="${JWK:-$HOME/enroll/enroll.priv.jwk}"
# If your JWK has a passphrase and you want non-interactive use, add:
# PWFILE="${PWFILE:-$HOME/enroll/jwk.pw}"
CSR="${1:-}"; [ -f "$CSR" ] || { echo "Usage: ws-enroll-lite /path/to/DEVICE.csr"; exit 1; }
# Extract CN for the output filename
CN="$(openssl req -in "$CSR" -noout -subject -nameopt RFC2253 | sed -n 's/^subject=.*CN=\([^,]*\).*/\1/p')"
[ -n "$CN" ] || { echo "CSR has no CN"; exit 2; }
# Mint a short-lived token with the JWK provisioner
# (Step sets the correct audience automatically after bootstrap)
TOKEN="$(step ca token "$CN" --key "$JWK" --issuer "$PROV")"
# If using a passphrase file:
# TOKEN="$(step ca token "$CN" --key "$JWK" --issuer "$PROV" --password-file "$PWFILE")"
# Sign CSR -> write **leaf-only** PEM (no chain)
step ca sign "$CSR" "${CN}.crt.pem" --token "$TOKEN"
echo "leaf cert -> $(pwd)/${CN}.crt.pem"
SH
sudo chmod +x /usr/local/bin/ws-enroll-lite
- Device — Generate key & CSR (2 commands)
Run these on the device (the private key stays on the device).
openssl ecparam -name prime256v1 -genkey -noout -out device.key
chmod 600 device.key
CN=SN-000001
openssl req -new -key device.key \
-subj "/C=TW/ST=Taipei/L=Taipei/O=maksonlee.com/OU=Devices/CN=$CN" \
-out "$CN.csr"
Transfer SN-000001.csr
to the workstation.
- Workstation — Enroll (leaf-only output)
ws-enroll-lite SN-000001.csr
# Output: SN-000001.crt.pem
Return SN-000001.crt.pem
to the device (it already has device.key
).
Done. No full chain or JSON files are kept.
(Optional) Sanity checks
# CN and SAN should both show the device ID
step certificate inspect SN-000001.crt.pem --format json \
| jq -r '.subject.common_name[0], (.extensions.subject_alt_name.dns_names[]?)'
# Public key curve should be prime256v1 (P-256)
openssl x509 -in SN-000001.crt.pem -noout -text \
| sed -n '/Subject Public Key Info/,+3p'