ESPSomfy-RTS/docs/SECURITY_PLAN.md
cjkas 77237111ea feat: edit ops and the reads their forms need, for socket protocol 2
The edit routes become ops 26-44 (addRoom ... groupSortOrder): ProtoEdits
turns an op's payload into the route's request, full names and real
booleans, and loop() applies it with applyModelEdit() as the route
does. The reply goes to the client that sent it, with an add's new id,
or the route's reason and a shade's validation code; the HTTP routes
keep sending requestResult.

getNextRoom, getNextShade, getNextGroup and groupOptions (45-48) are
answered by loop() from the shade model, through a new LoopQueue item.

Messages that arrive in pieces, a frame over one TCP segment or several
frames, are put back together by ProtoAssembly: one at a time across
clients, up to 4 KB, in a buffer held only while one is on its way.
A message in pieces is now told from its first frame's opcode, which
also stops a fragmented message's last frame drawing a second reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:29:53 +02:00

8.4 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
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
4 firmware removals: the old routes, ports 8081 and 8080, /discovery, ModelSnapshot, "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. 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 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).