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>
This commit is contained in:
cjkas 2026-10-27 19:41:43 +01:00
parent ff7517e10f
commit 43191ba312
8 changed files with 144 additions and 13 deletions

View file

@ -3,7 +3,10 @@
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
@ -35,6 +38,25 @@ def test_secured_refuses_a_wrong_token(host, secured):
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")

View file

@ -0,0 +1,54 @@
#include <unity.h>
#include "ApiToken.h"
void setUp() {}
void tearDown() {}
static const char TOKEN[] = "3f9a0c1e5b7d2468ace013579bdf2468ace013579bdf2468ace013579bdf2468";
/*********************************************************************
* Comparing tokens
********************************************************************/
static void test_the_same_token_matches() {
TEST_ASSERT_TRUE(tokenMatches(TOKEN, "3f9a0c1e5b7d2468ace013579bdf2468ace013579bdf2468ace013579bdf2468"));
}
static void test_a_token_differing_in_the_first_character_does_not_match() {
TEST_ASSERT_FALSE(tokenMatches(TOKEN, "4f9a0c1e5b7d2468ace013579bdf2468ace013579bdf2468ace013579bdf2468"));
}
static void test_a_token_differing_in_the_last_character_does_not_match() {
TEST_ASSERT_FALSE(tokenMatches(TOKEN, "3f9a0c1e5b7d2468ace013579bdf2468ace013579bdf2468ace013579bdf2469"));
}
static void test_a_prefix_of_the_token_does_not_match() {
TEST_ASSERT_FALSE(tokenMatches(TOKEN, "3f9a0c1e5b7d2468ace013579bdf2468ace013579bdf2468ace013579bdf246"));
TEST_ASSERT_FALSE(tokenMatches(TOKEN, "3f9a"));
}
static void test_the_token_with_more_after_it_does_not_match() {
TEST_ASSERT_FALSE(tokenMatches(TOKEN, "3f9a0c1e5b7d2468ace013579bdf2468ace013579bdf2468ace013579bdf24680"));
}
static void test_an_empty_token_does_not_match() {
TEST_ASSERT_FALSE(tokenMatches(TOKEN, ""));
}
static void test_a_missing_token_does_not_match() {
TEST_ASSERT_FALSE(tokenMatches(TOKEN, nullptr));
}
static void test_nothing_matches_an_empty_expected_token() {
TEST_ASSERT_FALSE(tokenMatches("", ""));
TEST_ASSERT_FALSE(tokenMatches(nullptr, TOKEN));
}
static void test_case_matters() {
TEST_ASSERT_FALSE(tokenMatches(TOKEN, "3F9A0C1E5B7D2468ACE013579BDF2468ACE013579BDF2468ACE013579BDF2468"));
}
int main(int, char **) {
UNITY_BEGIN();
RUN_TEST(test_the_same_token_matches);
RUN_TEST(test_a_token_differing_in_the_first_character_does_not_match);
RUN_TEST(test_a_token_differing_in_the_last_character_does_not_match);
RUN_TEST(test_a_prefix_of_the_token_does_not_match);
RUN_TEST(test_the_token_with_more_after_it_does_not_match);
RUN_TEST(test_an_empty_token_does_not_match);
RUN_TEST(test_a_missing_token_does_not_match);
RUN_TEST(test_nothing_matches_an_empty_expected_token);
RUN_TEST(test_case_matters);
return UNITY_END();
}