ESPSomfy-RTS/docs/SECURITY_PLAN.md
cjkas 2a954aa158 feat!: remove the old API, ports 8081 and 8080 (step 4a)
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>
2026-10-27 21:49:38 +01:00

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). serverId is 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 MQTT serverId topic and in /discovery. Anyone who sees a token can recover a 4-character PIN offline. Tokens never expire.
  • /login has 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: * and Allow-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, including shades.cfg and shades.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.
  • serverId stays 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.cfg are reachable only through the authenticated /backup.
  • The web UI sends apikey on its backup and upload requests too (today those XHRs leave it out).

Websocket

  • Handshake: the socket is ws://host/ws on port 80. The token goes in ?apikey=<token>, because browsers can't set headers on a websocket. handleHandshake checks 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 saveSettings of 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 Origin header doesn't match Host is 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://, which baseUrl in index.js supports.

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.
  • Comparison: constant-time (tokenMatches in src/ApiToken.h), used for HTTP, the websocket handshake and /login alike.
  • 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 /login attempts, 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 /login is throttled. After PR B a token is a 256-bit HMAC with a secret key, so guessing one through apikey is 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._tcp mDNS 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_entry it logs in with the PIN or password stored in entry.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 /ws answers 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; /backup needs the token under Secured).