mTLS onboarding
Issue device certificates from Voke's root CA and configure mutual TLS on your PLC.
mTLS is the production default. The PLC presents a client certificate signed by Voke's internal CA; the broker validates it against the CA chain and derives the MQTT username from the certificate's CN field. No password or token is exchanged — the certificate is the credential.
Listener ports: 8886 (production) · 8883 (local dev)
Overview of the PKI
Voke maintains a self-signed Root CA (4096-bit RSA, 10-year validity by default).
All scripts live in scripts/pki/:
| Script | Purpose |
|---|---|
init-ca.sh | Bootstrap the root CA — run once per environment. Generates ca.key + ca.crt and an empty CRL. |
ensure-ca.sh | Idempotent wrapper: runs init-ca.sh if the CA files are absent, exits silently otherwise. Called by bun run docker:up. |
generate-broker-cert.sh | Issue the broker TLS certificate (SAN for mqtt.voke.lovinka.com, localhost, cpi-mosquitto). Run once after init-ca.sh. |
generate-device-cert.sh | Issue a client certificate for one device. Run once per plant; re-run to rotate. CN = device ID = MQTT username. |
generate-mqtt-jwt-keys.sh | Generate the RSA key pair for the JWT listener (unrelated to mTLS). |
Certificates are written to docker/mosquitto/certs/ and mounted into the broker
container at runtime.
Never commit ca.key or any device.key file. They are in .gitignore by convention.
The CA private key grants the ability to issue trusted device identities for your entire
fleet.
Step 1: Bootstrap the CA
Run this once in the repository root. If the CA already exists the script exits with an error to protect the existing PKI.
cd scripts/pki
./init-ca.shDefault output:
Generating CPI Root CA...
Key size: 4096
Validity: 3650 days
Subject: /C=CZ/ST=Prague/O=CPI IoT Platform/CN=CPI Root CA
CA generated successfully!
CA Certificate: docker/mosquitto/certs/ca/ca.crt
CA Private Key: docker/mosquitto/certs/ca/ca.key (KEEP SECURE)Override subject or validity with environment variables:
CA_DAYS=7300 CA_SUBJECT="/C=DE/O=Acme/CN=Acme Root CA" ./init-ca.shStep 2: Generate the broker certificate
cd scripts/pki
./generate-broker-cert.shThe broker cert has SANs for mqtt.voke.lovinka.com, localhost, cpi-mosquitto, and
127.0.0.1. Override the CN if your broker domain differs:
BROKER_CN=mqtt.example.com ./generate-broker-cert.shStep 3: Issue a device certificate
Each plant gets its own 2048-bit RSA client certificate. Pass the plant UUID as the only argument — this becomes the certificate's CN and therefore the MQTT username.
cd scripts/pki
./generate-device-cert.sh <plantId>Example:
./generate-device-cert.sh a1b2c3d4-0000-0000-0000-000000000001Expected output:
Generating device certificate for 'a1b2c3d4-0000-0000-0000-000000000001'...
Device certificate generated!
Certificate: docker/mosquitto/certs/devices/a1b2c3d4-0000-0000-0000-000000000001/device.crt
Private Key: docker/mosquitto/certs/devices/a1b2c3d4-0000-0000-0000-000000000001/device.key
Fingerprint: 3A7F...
Expires: Apr 18 00:00:00 2028 GMT
Test connection:
mosquitto_sub --cafile docker/mosquitto/certs/ca/ca.crt \
--cert docker/mosquitto/certs/devices/.../device.crt \
--key docker/mosquitto/certs/devices/.../device.key \
-h localhost -p 8883 -t 'cpi/<plantId>/command'Default validity is 730 days (2 years). Override with DEVICE_DAYS:
DEVICE_DAYS=365 ./generate-device-cert.sh <plantId>Do NOT reuse a certificate across multiple plants. Voke's ACL maps one CN to exactly one plant's topics. A shared cert means both plants share the same ACL scope.
Step 4: Copy files to the device
Transfer the three files to the PLC and set restrictive permissions:
# Conventional paths (adjust for your device OS)
scp docker/mosquitto/certs/ca/ca.crt plc:/etc/voke/ca.crt
scp docker/mosquitto/certs/devices/<id>/device.crt plc:/etc/voke/device.crt
scp docker/mosquitto/certs/devices/<id>/device.key plc:/etc/voke/device.key
ssh plc "chmod 644 /etc/voke/ca.crt /etc/voke/device.crt && chmod 600 /etc/voke/device.key"Standard path conventions used in the Python examples:
| File | Path |
|---|---|
| Voke CA certificate | /etc/voke/ca.crt |
| Device certificate | /etc/voke/device.crt |
| Device private key | /etc/voke/device.key |
Step 5: Connect
The full Python example is in apps/docs/content/examples/plc/mqtt_mtls.py.
Key configuration:
import ssl
import paho.mqtt.client as mqtt
HOST = "mqtt.voke.lovinka.com"
PORT = 8886 # production; 8883 for local dev
PLANT_ID = "a1b2c3d4-0000-0000-0000-000000000001"
CA_CERT = "/etc/voke/ca.crt"
CLIENT_CERT = "/etc/voke/device.crt"
CLIENT_KEY = "/etc/voke/device.key"
client = mqtt.Client(client_id=PLANT_ID, protocol=mqtt.MQTTv5)
client.tls_set(
ca_certs=CA_CERT,
certfile=CLIENT_CERT,
keyfile=CLIENT_KEY,
tls_version=ssl.PROTOCOL_TLS_CLIENT,
)
client.connect(HOST, PORT, keepalive=60)No username_pw_set call is needed — the certificate is the credential. The broker
reads use_identity_as_username true and sets the MQTT username to the cert's CN
automatically.
ACL scope
mTLS devices share the same pattern-based ACL as JWT devices:
pattern write cpi/%u/telemetry
pattern write cpi/%u/status
pattern write cpi/%u/ack
pattern read cpi/%u/commands%u expands to the MQTT username which equals the cert CN which equals the plant UUID.
A plant can only write to its own telemetry/status/ack topics and read from its own
commands topic.
Cert monitoring and rotation
Expiry. The default device cert validity is 2 years. Track expiry by inspecting the certificate date:
openssl x509 -in /etc/voke/device.crt -enddate -noout
# notAfter=Apr 18 00:00:00 2028 GMTRotation. Re-run the issuance script with the same plant ID and redeploy the new
device.crt + device.key pair. The broker picks up the new cert on next connection
(no broker restart needed):
cd scripts/pki
./generate-device-cert.sh <plantId> # overwrites existing files with a warning
# copy device.crt + device.key to PLC and restart the PLC's MQTT clientRevocation. The broker loads ca.crl at startup. To revoke a certificate before
it expires:
- Add the cert serial to the CA's index and regenerate the CRL:
openssl ca -revoke docker/mosquitto/certs/devices/<id>/device.crt \ -keyfile docker/mosquitto/certs/ca/ca.key \ -cert docker/mosquitto/certs/ca/ca.crt openssl ca -gencrl \ -keyfile docker/mosquitto/certs/ca/ca.key \ -cert docker/mosquitto/certs/ca/ca.crt \ -out docker/mosquitto/certs/ca/ca.crl - Restart the broker so it reloads the CRL:
docker compose restart mosquitto
For a full fleet revocation (e.g. CA compromise), rotate the CA with init-ca.sh
(after removing ca.key) and re-issue all device certificates.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Connection refused | Wrong port (check prod 8886 vs dev 8883) |
SSL handshake failed | Wrong CA cert or cert signed by a different CA |
CONNACK rc=5 (not authorised) | Cert CN does not match any ACL entry |
certificate verify failed | Device trusting wrong CA; update ca.crt |
certificate revoked | Cert serial is in the CRL; issue a replacement |
Next steps
- Topics — topic reference and QoS guidance.
- HMAC signing — sign telemetry payloads.
- Auth methods — compare all three listeners.