Step 1d of the protocol 2 core (docs/SECURITY_PLAN.md). - getSettings/saveSettings by section: general, network, mqtt, security, radio. A save is the settings route's request, so loop() applies it with the settings' own fromJSON(). ProtoSettings checks every field first: kind, range, string length, IP addresses. - Secrets are only reported as has*. A security save replies with the client's new token and closes the other sockets; other saves send settingsChanged to the other clients. - scanWifi runs an async scan from ESPNetwork, kept apart from the roaming scan, and wifiNetworks goes to the clients that asked. - beginFrequencyScan, endFrequencyScan and reboot run at once on async_tcp, as their routes do: a scan's end mustn't wait in the loop queue behind an unpair that waits for the scan to end (new op kind 'a'). - e2e: test_settings.py and a --reboot flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.6 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).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 closes every other socket, since their tokens may no longer be good, and a socket opened under None had none. They connect again and log in when refused. The client that saved gets its new token in the reply. - 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. 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
/loginattempts, refuse that IP for 30 s, doubling each time up to 15 min. A success resets it. - 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 that sends nohello, 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;
/backupneeds the token under Secured).