Merge all hardening branches into integration

Resolve conflict in web_server_idf.cpp: keep constant-time compare
logic adapted to stack-allocated digest buffer from integration.
This commit is contained in:
J. Nick Koston
2026-02-08 08:03:23 -06:00
9 changed files with 321 additions and 50 deletions
+31 -16
View File
@@ -18,8 +18,8 @@ from esphome import espota2
from esphome.core import EsphomeError
# Test constants
MOCK_RANDOM_VALUE = 0.123456
MOCK_RANDOM_BYTES = b"0.123456"
MOCK_MD5_CNONCE = "a" * 32 # Mock 32-char hex string from secrets.token_hex(16)
MOCK_SHA256_CNONCE = "b" * 64 # Mock 64-char hex string from secrets.token_hex(32)
MOCK_MD5_NONCE = b"12345678901234567890123456789012" # 32 char nonce for MD5
MOCK_SHA256_NONCE = b"1234567890123456789012345678901234567890123456789012345678901234" # 64 char nonce for SHA256
@@ -55,10 +55,18 @@ def mock_time() -> Generator[None]:
@pytest.fixture
def mock_random() -> Generator[Mock]:
"""Mock random for predictable test values."""
with patch("random.random", return_value=MOCK_RANDOM_VALUE) as mock_rand:
yield mock_rand
def mock_token_hex() -> Generator[Mock]:
"""Mock secrets.token_hex for predictable test values."""
def _token_hex(nbytes: int) -> str:
if nbytes == 16:
return MOCK_MD5_CNONCE
if nbytes == 32:
return MOCK_SHA256_CNONCE
raise ValueError(f"Unexpected nbytes for token_hex mock: {nbytes}")
with patch("esphome.espota2.secrets.token_hex", side_effect=_token_hex) as mock:
yield mock
@pytest.fixture
@@ -236,7 +244,7 @@ def test_send_check_socket_error(mock_socket: Mock) -> None:
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_successful_md5_auth(
mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test successful OTA with MD5 authentication."""
# Setup socket responses for recv calls
@@ -272,8 +280,11 @@ def test_perform_ota_successful_md5_auth(
)
)
# Verify cnonce was sent (MD5 of random.random())
cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest()
# Verify token_hex was called with MD5 digest size
mock_token_hex.assert_called_once_with(16)
# Verify cnonce was sent
cnonce = MOCK_MD5_CNONCE
assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode())
# Verify auth result was computed correctly
@@ -366,7 +377,7 @@ def test_perform_ota_auth_without_password(mock_socket: Mock) -> None:
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_md5_auth_wrong_password(
mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test OTA fails when MD5 authentication is rejected due to wrong password."""
# Setup socket responses for recv calls
@@ -390,7 +401,7 @@ def test_perform_ota_md5_auth_wrong_password(
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_sha256_auth_wrong_password(
mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test OTA fails when SHA256 authentication is rejected due to wrong password."""
# Setup socket responses for recv calls
@@ -603,7 +614,7 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
# Tests for SHA256 authentication
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_successful_sha256_auth(
mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test successful OTA with SHA256 authentication."""
# Setup socket responses for recv calls
@@ -639,8 +650,11 @@ def test_perform_ota_successful_sha256_auth(
)
)
# Verify cnonce was sent (SHA256 of random.random())
cnonce = hashlib.sha256(MOCK_RANDOM_BYTES).hexdigest()
# Verify token_hex was called with SHA256 digest size
mock_token_hex.assert_called_once_with(32)
# Verify cnonce was sent
cnonce = MOCK_SHA256_CNONCE
assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode())
# Verify auth result was computed correctly with SHA256
@@ -654,7 +668,7 @@ def test_perform_ota_successful_sha256_auth(
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_sha256_fallback_to_md5(
mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test SHA256-capable client falls back to MD5 for compatibility."""
# This test verifies the temporary backward compatibility
@@ -692,7 +706,8 @@ def test_perform_ota_sha256_fallback_to_md5(
)
# But authentication was done with MD5
cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest()
mock_token_hex.assert_called_once_with(16)
cnonce = MOCK_MD5_CNONCE
expected_hash = hashlib.md5()
expected_hash.update(b"testpass")
expected_hash.update(MOCK_MD5_NONCE)
+12 -1
View File
@@ -2,7 +2,7 @@
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from pytest import MonkeyPatch
@@ -632,3 +632,14 @@ def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch):
# rpipico doesn't support WiFi, so no api_encryption_key or ota_password
assert "api_encryption_key" not in call_kwargs
assert "ota_password" not in call_kwargs
def test_fallback_psk_uses_secrets_choice(
default_config: dict[str, Any],
) -> None:
"""Test that fallback PSK is generated using secrets.choice."""
with patch("esphome.wizard.secrets.choice", return_value="X") as mock_choice:
config = wz.wizard_file(**default_config)
assert 'password: "XXXXXXXXXXXX"' in config
assert mock_choice.call_count == 12