ESPSomfy-RTS/docs/SECURITY_PLAN.md
cjkas e25747d395 docs: ADRs 0005-0007 for socket protocol 2
ADR 0005: the web UI and Home Assistant talk to the device over one
websocket protocol (ops on loop(), sync with next, a JSON envelope a
binary encoding can reuse, short keys described by a schema).
ADR 0006: two security modes and eleven HTTP routes on port 80.
ADR 0007: the firmware no longer updates itself from GitHub.

ADR 0002 is amended: its single-writer rule stands, but edits now get
one reply and reads no longer go through a ModelSnapshot.
SECURITY_PLAN.md is revised to match, and keeps the token key, login
backoff, CORS and Origin work. PROTOCOL.md starts as an outline, and
GLOSSARY.md gains the socket protocol and security terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:50:33 +02:00

7.9 KiB

Plan: authentication on every entry point, one HTTP server

Status: agreed, nothing built yet. 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.
  • 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. 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.
  • Comparison: constant-time, used for HTTP and the websocket handshake alike.
  • Migration: none needed. The browser logs in again when its token is refused, and HA logs in again at runtime.

Login throttling (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.
  • 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 that sends no hello, 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
1 firmware protocol core next to the existing HTTP: hello, socket auth, ops through the LoopQueue, sync, events, schema; the e2e suite starts
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
4 firmware removals: the old routes, ports 8081 and 8080, /discovery, ModelSnapshot, the GitHub update, "Secure Config Only"; static serving limited to web assets; CORS headers; Origin check

PR B (token key) and PR C (login backoff) are separate firmware PRs that can land at any point.

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 backoff schedule

The e2e suite (test/e2e) checks, when given credentials, that a socket without a token is refused, that the HTTP routes answer 401, 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).