ESPSomfy-RTS/test/e2e/test_auth.py
cjkas 43191ba312 feat: sign tokens with a secret key, compare them in constant time
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>
2026-10-27 19:41:43 +01:00

105 lines
3.9 KiB
Python

"""The socket under the device's security mode (docs/adr/0006-two-security-modes-eleven-http-routes.md).
The suite never changes the device's security itself, so it tests the mode it
finds: pass --password or --pin for a Secured device.
"""
import hashlib
import hmac
import json
import socket
import time
import urllib.error
import urllib.request
import pytest
from proto_client import ProtoClient
def test_none_lets_anyone_connect(host, secured):
if secured:
pytest.skip("the device is Secured")
c = ProtoClient(host)
assert c.status == 101
c.close()
def test_secured_refuses_a_socket_without_a_token(host, secured):
if not secured:
pytest.skip("the device's security is None")
c = ProtoClient(host)
assert c.status == 401
def test_secured_refuses_a_wrong_token(host, secured):
if not secured:
pytest.skip("the device's security is None")
c = ProtoClient(host, params={"apikey": "0" * 64})
assert c.status == 401
def test_secured_refuses_a_token_keyed_with_the_server_id(request, host, login_context, token):
"""The token key used to be the public serverId, so anyone who saw a token could check PIN guesses against it."""
if token is None:
pytest.skip("the device's security is None")
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect((host, 80))
me = s.getsockname()[0]
if login_context["type"] == 2:
payload = f"{request.config.getoption('--username')}:{request.config.getoption('--password')}:{me}"
else:
payload = f"{request.config.getoption('--pin')}:{me}"
old = hmac.new(login_context["serverId"].encode(), payload.encode(), hashlib.sha256).hexdigest()
# A bool, so a failure doesn't print the tokens.
same = old == token
assert not same, "the device's token is the one keyed with its serverId"
c = ProtoClient(host, params={"apikey": old})
assert c.status == 401
def test_secured_accepts_the_token(host, secured, token):
if not secured:
pytest.skip("the device's security is None")
c = ProtoClient(host, params={"apikey": token})
assert c.status == 101
c.close()
def login_status(host, body):
"""/login's status and reply, refusals included."""
req = urllib.request.Request(
f"http://{host}/login",
data=json.dumps(body).encode(),
method="PUT",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=20) as r:
return r.status, json.loads(r.read()), r.headers
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b"{}"), e.headers
def test_failed_logins_lock_this_pc_out(request, host, secured, token):
"""Five wrong logins refuse this IP for 30 s, even with the right ones (SECURITY_PLAN.md, PR C)."""
if not request.config.getoption("--lockout"):
pytest.skip("locks this PC out of /login for 30 s: pass --lockout")
if not secured:
pytest.skip("the device's security is None")
password = request.config.getoption("--password")
pin = request.config.getoption("--pin")
right = {"username": request.config.getoption("--username"), "password": password or "", "pin": pin or ""}
wrong = {**right, "password": (password or "") + "x", "pin": "0000" if pin != "0000" else "1111"}
# The token fixture's login started this PC with a clean count.
for _ in range(5):
status, reply, _ = login_status(host, wrong)
assert status == 401 and reply["success"] is False
status, reply, headers = login_status(host, right)
assert status == 429, "the right credentials got through a lockout"
wait = int(headers["Retry-After"])
assert 25 <= wait <= 30
assert reply["retryAfter"] == wait
time.sleep(wait + 1)
# Over: the right credentials work, and reset the count.
status, reply, _ = login_status(host, right)
assert status == 200 and reply["apiKey"] == token