Add BLE challenge-response for short-lived MQTT session certs
BLE-only locators can't hold the MQTT/TLS connection themselves — a phone relays their data — so handing the phone a device's permanent client-cert key would export its identity to every phone it pairs with. Instead the device signs a server-issued nonce with its permanent key over BLE; once verified, the backend mints a short-lived session certificate for the phone's actual MQTT connection, keeping the permanent key on-device always. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
33
README.md
33
README.md
@@ -123,6 +123,38 @@ init/broker cert) and each device's "Certificate" action (issue/download/
|
||||
revoke). See [Known gaps](#known-gaps) for the CA's on-disk storage and
|
||||
revocation caveats.
|
||||
|
||||
### BLE-relayed devices: short-lived session certs
|
||||
|
||||
Some locators are BLE-only and have no network stack of their own — a phone
|
||||
app relays their data, so it's the phone, not the locator, that would open
|
||||
the MQTT/TLS connection. Handing the phone the locator's *permanent*
|
||||
client-cert private key (as the mTLS flow above allows any ORG_ADMIN to
|
||||
download) would export that device's identity to every phone it ever pairs
|
||||
with, so `backend/src/device-mqtt-auth/` instead uses a challenge-response
|
||||
handshake that never moves the permanent key off the device:
|
||||
|
||||
1. `POST /api/devices/:serial/mqtt-session/challenge` (public, unauthenticated
|
||||
— same trust model as the device-status check below) returns a one-time
|
||||
`nonce` and the exact `payload` string
|
||||
(`ulhub-mqtt-auth-v1:<serial>:<nonce>`) the locator's firmware must sign
|
||||
with its permanent private key (RSA-SHA256, PKCS#1v1.5) over BLE.
|
||||
2. `POST /api/devices/:serial/mqtt-session` with `{ nonce, signature }`
|
||||
(base64) verifies that signature against the device's stored permanent
|
||||
certificate. On success it mints a **short-lived** client certificate
|
||||
(`MQTT_SESSION_CERT_HOURS`, default 24h) — same `CN`, so the existing ACL
|
||||
applies unchanged — and returns it plus the CA cert, for the phone to
|
||||
connect to the *same* 8883 listener with. On failure: 400 for an
|
||||
invalid/expired/reused nonce, 403 for a disabled device, 404 for an
|
||||
unknown serial or a device with no permanent cert yet, 401 for a bad
|
||||
signature.
|
||||
|
||||
Nonces live in memory only (single-use, `MQTT_CHALLENGE_TTL_SECONDS`, default
|
||||
120s) and session certs are never persisted — Mosquitto validates any
|
||||
CA-signed cert at connect time regardless of whether the backend remembers
|
||||
issuing it. Both endpoints are rate-limited (`@nestjs/throttler`, 10
|
||||
requests/min) since, unlike the read-only device-status check, each one does
|
||||
real work (an openssl signature verification and/or a fresh cert issuance).
|
||||
|
||||
### Realtime
|
||||
|
||||
A plain WebSocket gateway at `/api/ws` (not socket.io) authenticates off the
|
||||
@@ -201,6 +233,7 @@ backend/
|
||||
realtime/ WebSocket gateway + pub/sub service
|
||||
api-keys/ API key issuance/revocation
|
||||
certificates/ MQTT device mTLS CA (openssl-backed)
|
||||
device-mqtt-auth/ BLE challenge-response -> short-lived MQTT session certs
|
||||
sim/ simulator's publish-to-broker endpoint (HTTP relay + real MQTTS/mTLS transport)
|
||||
prisma/ PrismaService/PrismaModule
|
||||
prisma/
|
||||
|
||||
Reference in New Issue
Block a user