Files
ulweb/MQTT_DEVICE_AUTH.md
2026-08-20 15:22:40 -05:00

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).

  1. Initialize the root CAPOST /certificates/ca/init Generates a 4096-bit RSA key and a 10-year self-signed cert with CN=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
  2. Issue the MQTT broker's server certificatePOST /certificates/mqtt/provision {hostname} Generates a server key/cert pair signed by the CA (CN=<hostname>) and writes them, along with a copy of ca.crt, into webapp/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 an mqtt container restart to pick up.
  3. Issue a device certificatePOST /certificates {serial_number} Generates a 2048-bit RSA key and a CSR with CN=<SERIAL_NUMBER> (uppercased, alphanumeric + hyphens only), signs it with the CA, and stores the result in the device_certificates table (DeviceCertificate model — serial_number, common_name, certificate, private_key, fingerprint, issued_at, expires_at). Certs are valid for 10 years. The private key is marked hidden on the model but is stored in the DB in plaintext, so DB access is effectively key access.

  4. 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), and ca.crt to a local directory on the host running artisan.
  5. Revoke a certificateDELETE /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 the device_certificates table. 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 by cafile.
  • 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. %u is 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-12345 can publish and subscribe to devices/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 outside devices/* (e.g. sensor/data, $SYS/#).
  • Admin users (rows in mqtt_admin_users, authenticated by username/password on 1883 or 8884) get an explicit user <name> block granting readwrite on # 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/adminsstoreMqttAdmin — creates a row in mqtt_admin_users (MqttAdminUser model) with a Mosquitto-compatible PBKDF2 password hash ($7$<iterations>$<salt>$<hash>, matching mosquitto_passwd's format), then rewrites mosquitto.passwd and mosquitto.acl.
  • Password reset / delete endpoints follow the same pattern, always rewriting both files afterward.
  • Every admin mutation requires docker compose restart mqtt to 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 manual docker 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.