Step 4a of docs/SECURITY_PLAN.md. Port 80 keeps the eleven HTTP routes of ADR 0006 and the socket at /ws; everything else moved to protocol 2. - Port 8081 (ApiRoutes) and the socket on 8080 (SocketEmitter) are gone. Protocol 2 owns its frame buffer and loop-task check. - Port 80 drops every route the old web UI called: reads, edits, settings, scans, reboot, /shades.cfg, /discovery. HttpRoutes.cpp holds what stays. - ModelSnapshot, SnapshotSlots and the read documents are gone. The item snapshot types stay, as ItemSnapshot, for events, MQTT and the command replies. - Model edits reply only to their protocol 2 op: requestResult goes. - The settings' toJSON writers, used only by the settings routes, go. - The _espsomfy_rts._tcp mDNS record moves to port 80, with path=/ws. - The memory event reports the loop-rate window it closes. - A deleted room no longer re-publishes its retained MQTT topics. - e2e: the heap check reads the sync's status; the sync is checked on its own instead of against /controller. - README gets an API and ports section. BREAKING CHANGE: ports 8081 and 8080 and the old HTTP routes are gone. Home Assistant needs integration 3.0.0 or newer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9.8 KiB
Plan: authentication on every entry point, one HTTP server
Status: steps 1 to 3, PR B and PR C merged; step 4 in progress: 4a removes the old API, 4b adds the auth rules. Written: 2026-10-18, on top of PR #64 (stored secrets are write-only). Revised: 2026-10-22 for socket protocol 2: two security modes, eleven HTTP routes, the socket on port 80 (ADR 0005, ADR 0006). Terms are defined in GLOSSARY.md.
Why
- Port 80 doesn't check login. About 60 of its 70 routes call no auth check at all: settings, security, network, MQTT, radio, firmware,
/restore, pairing, rolling codes. Only the ten that share a handler with port 8081 check it (/controller,/rooms,/shades,/groups,/room,/shade,/group,/backup,/reboot,/downloadFirmware). Logging in only hides parts of the UI. - "Secure Config Only" lets everything through. No caller ever asks for the config level, so with that mode every check passes.
- The token key is public. API tokens are
HMAC(serverId, PIN-or-password + client IP).serverIdis the last 3 bytes of the MAC address, which every device on the LAN can see, and it is also published in mDNS, in the retained MQTTserverIdtopic and in/discovery. Anyone who sees a token can recover a 4-character PIN offline. Tokens never expire. /loginhas no rate limit. A digits-only PIN (10,000 options) falls to an online attack within minutes.- CORS is wide open. Every response carries
Access-Control-Allow-Origin: *andAllow-Headers: *. Any website open in a browser on the LAN can call the device and read the replies. - The websocket has no auth. With protocol 2 it carries every command and edit, and its frame feed carries received remote addresses and rolling codes.
serveStatic("/")serves all of LittleFS, includingshades.cfgandshades.tmp.
Threat model
- In scope: another client on the same LAN that can send HTTP and websocket requests. That includes a browser on the LAN running a malicious page.
- Out of scope: someone sniffing traffic (everything is plain HTTP, and TLS on the ESP32 is out of reach), and a device exposed to the internet.
Decisions
Security modes
- None: everything is open.
- Secured (PIN or password): the socket upgrade and every HTTP route need a valid token, except the public ones below.
- "Secure Config Only" goes. A device set to it becomes Secured when it upgrades, so its main page asks for login. The 4.0.0 release notes say so.
serverIdstays public everywhere, including/loginContext. It is an identity, and HA uses it as the device's unique id. Only the token key changes (PR B).
HTTP routes (ADR 0006)
- Public: static web assets,
/loginContext,/login,/upnp.xml(SSDP), OPTIONS preflight requests. - Token under Secured:
/backup,/restore,/updateFirmware,/updateApplication,/shadeCommand,/groupCommand,/tiltCommand. - Which routes are public is one pure function with native tests.
- Static serving covers the web assets only, so the rolling codes in
shades.cfgare reachable only through the authenticated/backup. - The web UI sends
apikeyon its backup and upload requests too (today those XHRs leave it out).
Websocket
- Handshake: the socket is
ws://host/wson port 80. The token goes in?apikey=<token>, because browsers can't set headers on a websocket.handleHandshakechecks it with the same comparison the HTTP routes use.request->url()is the path only, so the token doesn't reach the slow-request log. - Rules: under None anyone can connect. Under Secured the handshake needs a valid token, and a connection that has one may send every op and subscribe to every feed.
- Logging in with a socket already open: the UI reconnects the socket with the token. There is no in-band upgrade message.
- Changing the security settings: a
saveSettingsof the security section that leaves the device Secured, or changes its mode, closes every socket, since their tokens may no longer be good, and a socket opened under None had none. The client that saved is closed once its reply, which carries its new token, has gone, so no socket stays open on a check it never passed. The others log in when refused. - Origin: a handshake whose
Originheader doesn't matchHostis refused.
CORS
- The wildcard
Access-Control-Allow-*default headers go. The UI is served from the same origin, and HA and scripts aren't browsers, so CORS doesn't affect them. - A build flag turns the headers back on for UI development from
file://, whichbaseUrlinindex.jssupports.
UI
- The token is kept in
sessionStorage: it survives a reload, is dropped when the tab closes, and is cleared when the socket upgrade is refused. - On a 401, or a refused upgrade, the UI opens the login panel (
security.authUser()) and drops that request, with no automatic retry. The user repeats the action.
Token key (PR B)
- The token keeps its shape,
HMAC(key, PIN-or-password + client IP): stateless, and it survives reboots. Only the key changes. - Key: 32 random bytes from
esp_fill_random, generated on first boot in their own NVS namespace (TOKEN). Never sent anywhere, never in a backup, replaced only when NVS is erased. There is no rotate button: changing the PIN or password already changes every token.- The key is made in
Web::begin, before WiFi starts, so the RNG takes its entropy from the bootloader's source (bootloader_random_enable) rather than the radio.
- The key is made in
- Comparison: constant-time (
tokenMatchesin src/ApiToken.h), used for HTTP, the websocket handshake and/loginalike. - Migration: none needed. The browser logs in again when its token is refused, and HA logs in again at runtime.
Login throttle (PR C)
- Per client IP: after 5 failed
/loginattempts, refuse that IP for 30 s, doubling each time up to 15 min. A success resets it.- After the first lockout, each failure starts the next one, so an IP gets one try per lockout rather than five.
- An hour without a failure forgets the IP.
- A refused IP gets 429 with
Retry-After, before its credentials are checked. Home Assistant tries again later instead of asking for new credentials. - The device keeps 8 IPs (src/LoginThrottle.h). While all of them hold recent failures, every other IP shares one more slot. That way many addresses don't each get five free tries, but an unknown IP may have to wait during such an attack.
- Only
/loginis throttled. After PR B a token is a 256-bit HMAC with a secret key, so guessing one throughapikeyis hopeless.
One server on port 80 (ADR 0006)
- Port 80 serves the UI, the eleven HTTP routes and the socket at
/ws. Ports 8081 and 8080 are removed in 4.0.0 with no alias period. - The
_espsomfy_rts._tcpmDNS record moves from port 8080 to 80, with the socket path as a TXT entry. - The README gets an "API and ports" section for script and Node-RED users.
Home Assistant (ESPSomfy-RTS-HA)
- Today HA logs in only during the config flow, on a temporary API object, and sends
apikey: ""at runtime. It only works with security None or Secure Config Only. - Setup: the config flow reads the device info and security mode from
/loginContext, then logs in with/login. - Login: at
async_setup_entryit logs in with the PIN or password stored inentry.data. On a refusal it logs in again once and retries; if that fails, it raises HA's reauth flow. - Socket: it connects to
ws://host/ws?apikey=<token>and speaks protocol 2. Against firmware whose/wsanswers 404 or serves a web page, it falls back to the old HTTP API on 8081 and events on 8080.
Order
Protocol 2 lands as one PR per step, and main keeps working throughout because the HTTP routes are removed last:
| # | Repo | Scope |
|---|---|---|
| 1a | firmware | protocol core next to the existing HTTP: hello, socket auth, sync, events, feeds, schema; the e2e suite starts |
| 1b | firmware | command ops through the LoopQueue, with replies from loop() |
| 1c | firmware | model edit ops, replacing requestResult, and the reads their forms need; messages reassembled from their pieces |
| 1d | firmware | settings ops (getSettings/saveSettings by section), the WiFi and frequency scans, reboot |
| 2 | firmware | web UI on ops; the CSS files bundled; token in sessionStorage; banner on a version mismatch |
| 3 | HA | protocol 2 with the old-firmware fallback; /loginContext setup; runtime login and reauth |
| 4a | firmware | removals: the old routes, ports 8081 and 8080, /discovery, ModelSnapshot; the mDNS record on port 80 |
| 4b | firmware | "Secure Config Only" goes; the token on every non-public route; static serving limited to web assets; CORS headers; Origin check |
PR B (token key) and PR C (login throttle) are separate firmware PRs that can land at any point. The GitHub update was removed ahead of the rest (ADR 0007), as protocol 2 didn't fit beside it.
Tests
The rules go into small pure helpers with native tests, following src/Secrets.h:
- which routes are public
- the token comparison
- the Origin/Host match
- the login throttle's schedule
The e2e suite (test/e2e) checks, when given credentials, that a socket without a token is refused, that the HTTP routes answer 401, that a token keyed with serverId is refused, and that a token works. It never changes the device's security settings itself.
Not in this plan
- TLS.
- Session tokens with expiry.
- Contents of the binary backup file (it holds no passwords, but it does hold rolling codes;
/backupneeds the token under Secured).