- Load the token key in Web::startup(), before either web server starts,
so no token can be made with the all-zero key; log a failed
pref.begin().
- createAPIToken checks mbedtls' return codes. On a failure it leaves
the token empty, which tokenMatches never accepts, instead of hex
from uninitialised stack.
- API_TOKEN_SIZE in ApiToken.h replaces the five char[65] buffers; the
HMAC's description moves to createAPIToken, with the None form.
- handleLogin builds the candidate token once and compares it once.
- e2e: the serverId test skips on `secured` like its neighbours, names
its values, and says it assumes the device sees this PC's address.
- KNOWN_ISSUES keeps #81, which #108 removes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tokens were HMAC(serverId, credentials + client IP). serverId is public
(mDNS, MQTT, /loginContext), so anyone who saw a token could test PIN
guesses offline. The key is now 32 random bytes made on first boot in
their own NVS namespace, which no settings save clears and no backup
holds. It is made before WiFi starts, from the bootloader's entropy
source.
Tokens are compared with tokenMatches, which takes the same time
whatever the guess, on the apikey header, the socket upgrade and
/login.
createAPIToken never freed its mbedtls context, so every token it made
leaked: 563 bytes per login on the device, until it ran out of memory
after about 150 logins. It now frees it: 0 bytes over 500 logins.
Every token made before the upgrade is refused once. The web UI asks
for login again, and Home Assistant 3.0 logs in again by itself.
Closes#80
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR C of SECURITY_PLAN.md: after 5 failed /login attempts an IP is refused
for 30 s, and each failure after that refuses it for twice as long, up
to 15 min. A success resets it, as does an hour without a failure.
- LoginThrottle.h holds the schedule and 8 IPs, with the time passed in
so native tests cover it. While all 8 hold recent failures, every
other IP shares one more slot, so many addresses don't each get five
free tries.
- A refused IP gets 429 with Retry-After before its credentials are
checked, so a right guess during a lockout reveals nothing. Home
Assistant retries a 429 instead of asking for new credentials.
- The web UI's login panel shows a 401's or 429's message, where it
popped a service error.
- test/e2e --lockout checks it on a Secured device and waits it out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pytest suite run by hand against a device (test/e2e/README.md): hello, the schema and its hash, the sync with next and resync, replies to bad frames, feed subscriptions, the status event, every client syncing at once, a sixth client refused, a page load, and the socket under the device's security mode. It never transmits, changes settings or reboots. --record saves hello, the sync and the schema as fixtures.
PROTOCOL.md now describes the protocol as built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>