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:
ulhub
2026-08-13 20:51:58 +00:00
parent 842cb23e1f
commit 5de7b4d7a2
10 changed files with 264 additions and 3 deletions

View File

@@ -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/