Files
ulweb/MQTT_DEVICE_AUTH.md
ulhub 842cb23e1f Add device-certificate mTLS auth, live position tracking, and API docs
Introduces a CA/PKI module so field devices can authenticate to Mosquitto
over TLS (8883) with per-device client certificates (CN = serial number)
instead of a shared password, with matching Devices/MQTT-Certs UI. Adds
live transmitter position tracking alongside logged points, an MQTTS
transport option in the simulator for exercising the real cert-auth path,
and Swagger API docs at /api/docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:40:12 +00:00

185 lines
9.0 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 four 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) |
| `9001` (mapped to host `9005`) | MQTT over WebSocket | none (anonymous) | browser clients (dashboard) |
| `8883` | MQTT over TLS | **client certificate** | field devices |
| `8884` | MQTT over TLS | username/password (server cert only) | administrators |
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/#
# 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 |
| 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) |
| Browser dashboard | 9001/9005 (WebSocket) | anonymous | no ACL applied — `allow_anonymous true`, so effectively unrestricted; treat this listener as untrusted/read-only in front-end code |
## 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`.
- The WebSocket listener (9001) is fully anonymous with no ACL, so anything
reachable on port 9005 should be treated as public.