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>
This commit is contained in:
184
MQTT_DEVICE_AUTH.md
Normal file
184
MQTT_DEVICE_AUTH.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user