mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 22:02:17 +01:00
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>
98 lines
7.9 KiB
Markdown
98 lines
7.9 KiB
Markdown
# 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/0005-one-websocket-protocol.md), [ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)). Terms are defined in [GLOSSARY.md](../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](adr/0006-two-security-modes-eleven-http-routes.md))
|
|
- **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](adr/0006-two-security-modes-eleven-http-routes.md))
|
|
- 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](https://github.com/cjkas/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](../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).
|