Files
ulweb/MQTT_DEVICE_AUTH.md
2026-08-20 15:52:14 -05:00

204 lines
10 KiB
Markdown

# 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=<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 certificate**`POST /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 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: <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/admins``storeMqttAdmin` — 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.