9.2 KiB
MQTT Device Authentication & Authorization
This document describes how devices authenticate to the Mosquitto broker using client certificates, and how the ACL restricts each device to its own topic namespace. It covers the same broker that MQTT_SETUP.md describes from a usage/dashboard perspective.
Overview
The broker (eclipse-mosquitto, service mqtt / container ul-hub-mqtt)
exposes three listeners, each with a different trust model:
| Port | Protocol | Auth | Who it's for |
|---|---|---|---|
1883 |
MQTT (plaintext) | username/password | internal services (e.g. the Laravel subscriber, Python publisher) |
8883 |
MQTT over TLS | client certificate | field devices |
8884 |
MQTT over TLS | username/password (server cert only) | scoped app clients and administrators |
The former anonymous WebSocket listener on 9001 is disabled. The current web portal receives
live updates from the backend rather than connecting directly to Mosquitto.
Device authentication happens on port 8883. A device presents a client certificate signed by the app's own Certificate Authority (CA); Mosquitto verifies the chain and uses the certificate's Common Name (CN) as the MQTT username, which the ACL then uses to scope the device to its own topic tree.
Certificate authority & issuance (Laravel)
All PKI operations are handled in the webapp, not by hand with openssl on
the host. See CertificateController (webapp/app/Http/Controllers/CertificateController.php)
and the /certificates admin page (certificates.index route,
webapp/resources/views/certificates/index.blade.php).
-
Initialize the root CA —
POST /certificates/ca/initGenerates a 4096-bit RSA key and a 10-year self-signed cert withCN=UL Hub Device CA. Stored at:webapp/storage/app/private/ca/ca.key(kept secret, never leaves the server)webapp/storage/app/private/ca/ca.crt
-
Issue the MQTT broker's server certificate —
POST /certificates/mqtt/provision {hostname}Generates a server key/cert pair signed by the CA (CN=<hostname>) and writes them, along with a copy ofca.crt, intowebapp/storage/app/private/mosquitto-certs/:ca.crt,server.crt,server.key(chmod 644 so the Mosquitto container, running as a different uid, can read them) Requires anmqttcontainer restart to pick up.
-
Issue a device certificate —
POST /certificates {serial_number}Generates a 2048-bit RSA key and a CSR withCN=<SERIAL_NUMBER>(uppercased, alphanumeric + hyphens only), signs it with the CA, and stores the result in thedevice_certificatestable (DeviceCertificatemodel —serial_number,common_name,certificate,private_key,fingerprint,issued_at,expires_at). Certs are valid for 10 years. The private key is markedhiddenon the model but is stored in the DB in plaintext, so DB access is effectively key access. -
Distribute the cert/key to a device:
- Download from the UI (
/certificates/{id}/cert,/certificates/{id}/key) and the CA cert (/certificates/ca/download), or php artisan cert:export <SERIAL_NUMBER> --out=certs— exports<SERIAL>.crt,<SERIAL>.key(chmod 600), andca.crtto a local directory on the host running artisan.
- Download from the UI (
-
Revoke a certificate —
DELETE /certificates/{id}simply deletes the DB row. There is no CRL or OCSP — Mosquitto only checks the cert against the CA chain and expiry, not against thedevice_certificatestable. A deleted/"revoked" certificate will still authenticate successfully against the broker until it expires, since revocation is Laravel-side bookkeeping only, not broker-enforced.
Broker TLS configuration (port 8883)
From mosquitto/mosquitto.conf:
listener 8883 0.0.0.0
cafile /mosquitto/certs/ca.crt
certfile /mosquitto/certs/server.crt
keyfile /mosquitto/certs/server.key
require_certificate true
use_identity_as_username true
allow_anonymous false
acl_file /mosquitto/config/devices.acl
cafile— the CA used to verify client certs presented by devices (also serves as the trust anchor for the server's own cert chain).require_certificate true— TLS handshake fails unless the client presents a certificate signed bycafile.use_identity_as_username true— the certificate's CN is used directly as the MQTT username for ACL purposes. No separate password is needed or accepted on this listener.allow_anonymous false— belt-and-suspenders; without a valid client cert, the connection is rejected outright by the TLS handshake anyway.
Where the runtime files actually come from
docker-compose.yml does not mount mosquitto/devices.acl,
mosquitto/passwd, or a local certs folder — it mounts the Laravel-managed
copies instead:
volumes:
- ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
- ./webapp/storage/app/private/mosquitto.acl:/mosquitto/config/devices.acl:ro
- ./webapp/storage/app/private/mosquitto.passwd:/mosquitto/config/passwd:ro
- ./webapp/storage/app/private/mosquitto-certs:/mosquitto/certs:ro
So webapp/storage/app/private/mosquitto.acl and mosquitto.passwd (both
generated by CertificateController::writePasswdFile()) are the live ACL
and password files — the checked-in mosquitto/devices.acl and
mosquitto/passwd in the repo root are stale/unused leftovers. Any change to
admin users regenerates these files, but Mosquitto only rereads them on
container restart (docker compose restart mqtt) — there's no SIGHUP reload
wired up.
Topic authorization (ACL)
The ACL file is generated by writePasswdFile() in CertificateController
and applies to all three authenticated listeners (1883, 8883, 8884),
since they all share acl_file /mosquitto/config/devices.acl:
# Certificate CN becomes the MQTT username — restrict each device to its own namespace.
pattern readwrite devices/%u/#
# App username is orgId; app clients may publish durable points and read acks only in that org.
pattern write ul/%u/app/+/log/points
pattern read ul/%u/app/+/ack
# Admin: <username>
user <username>
topic readwrite #
topic readwrite $SYS/#
pattern readwrite devices/%u/#applies to every authenticated client, including devices.%uis substituted with the connection's username at auth time — for a cert-authenticated device on 8883, that's the certificate's CN (i.e. its serial number). This means:- A device with
CN=UL-12345can publish and subscribe todevices/UL-12345/#and nothing else (e.g.devices/UL-12345/status,devices/UL-12345/telemetry/temp). - It cannot read or write another device's namespace
(
devices/UL-99999/#), nor any topic outsidedevices/*(e.g.sensor/data,$SYS/#).
- A device with
- Admin users (rows in
mqtt_admin_users, authenticated by username/password on 1883 or 8884) get an explicituser <name>block grantingreadwriteon#and$SYS/#— full access to every topic, including all devices' namespaces. Mosquitto's#wildcard does not match$SYS/#, hence the second explicit line. - There is currently no per-device fine-grained restriction beyond the
serial-number namespace — a device has full read/write on its entire
subtree, so a compromised device credential can, for example, forge its
own "ack" topics or overwrite its own config topics if those live under
the same
devices/<serial>/prefix.
Admin users (port 8884 / 1883)
Managed via the same /certificates page:
POST /certificates/mqtt/admins→storeMqttAdmin— creates a row inmqtt_admin_users(MqttAdminUsermodel) with a Mosquitto-compatible PBKDF2 password hash ($7$<iterations>$<salt>$<hash>, matchingmosquitto_passwd's format), then rewritesmosquitto.passwdandmosquitto.acl.- Password reset / delete endpoints follow the same pattern, always rewriting both files afterward.
- Every admin mutation requires
docker compose restart mqttto take effect — the UI messages remind the operator of this each time.
Summary: who can talk to what
| Client | Listener | Auth | Can publish/subscribe |
|---|---|---|---|
| Field device (cert CN = serial) | 8883 (TLS) | client cert | devices/<serial>/# only |
| App (username = orgId) | 8884 (TLS) | scoped per-org username/password | publish ul/<orgId>/app/+/log/points; read ul/<orgId>/app/+/ack |
| Internal service (e.g. subscriber) | 1883 | username/password | depends on ACL entry for that username — none defined by default beyond devices/%u/#, so a plain username with no matching device row is effectively scoped to devices/<username>/# too, unless added as an admin |
| Admin | 8884 (TLS) or 1883 | username/password | # and $SYS/# (everything) |
Known gaps
- No certificate revocation enforcement at the broker (DB delete ≠ broker rejection) — mitigate with short-lived certs or a CRL/OCSP setup if this needs to be production-grade.
- No automatic reload of
mosquitto.conf/ACL/passwd changes — every provisioning action requires a manualdocker compose restart mqtt. - Sprint 2's per-org app credential permits ack visibility across clients in the same org. This accepted interim limitation is removed when OIDC-derived per-client broker identity lands.