191 lines
9.2 KiB
Markdown
191 lines
9.2 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) | 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 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.
|