# 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](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, Compose network only) | username/password | internal backend service; not host-published | | `8883` | MQTT over TLS | **client certificate** | field devices | | `443` (`/mqtt` → loopback `9001`) | MQTT over WSS/TLS | username/password | scoped app clients | | `8884` | MQTT over TLS | username/password (server cert only) | scoped app clients and administrators on networks that expose the raw port | The former anonymous WebSocket listener on `9001` is now authenticated, uses the same ACL as the TLS listeners, and is bound to host loopback only. Nginx exposes it as WSS at `/mqtt` on port 443; the web portal itself continues to receive live updates from the backend. The TLS listeners use `mosquitto/certs/public-fullchain.pem` and `mosquitto/certs/public-privkey.pem`, copied from the host's Let's Encrypt certificate during deployment with owner `1883:1883` and mode `0600`. These files are deployment secrets/artifacts and are excluded from Git. The live password database is likewise outside Git at `/home/ubuntu/.config/ul-platform/mosquitto.passwd`, bind-mounted read-only as `/mosquitto/secrets/passwd`. Provisioning updates that host file and restarts Mosquitto; the tracked `mosquitto/config/passwd` is only a legacy/bootstrap sample and must not receive new organization credentials. 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 CA** — `POST /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 certificate** — `POST /certificates/mqtt/provision {hostname}` Generates a server key/cert pair signed by the CA (`CN=`) 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 certificate** — `POST /certificates {serial_number}` Generates a 2048-bit RSA key and a CSR with `CN=` (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 --out=certs` — exports `.crt`, `.key` (chmod 600), and `ca.crt` to a local directory on the host running artisan. 5. **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 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: ```yaml 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: user 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 ` 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//` prefix. ## Admin users (port 8884 / 1883) Managed via the same `/certificates` page: - `POST /certificates/mqtt/admins` → `storeMqttAdmin` — creates a row in `mqtt_admin_users` (`MqttAdminUser` model) with a Mosquitto-compatible PBKDF2 password hash (`$7$$$`, 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//#` only | | App (username = orgId) | 8884 (TLS) | scoped per-org username/password | publish `ul//app/+/log/points`; read `ul//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//#` 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.