From 1b7efdd0519ca441ecea0c334b5cd76158f701d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Feb 2026 07:11:56 -0600 Subject: [PATCH] Match cnonce length to hash algorithm digest size Use nonce_size // 2 as token_hex argument so MD5 auth produces a 32-char cnonce and SHA256 auth produces a 64-char cnonce, matching the original protocol behavior. Rename mock_random fixture to mock_token_hex and use separate mock cnonce constants per hash algorithm. --- esphome/espota2.py | 4 ++-- tests/unit_tests/test_espota2.py | 40 +++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index bdfa7cb242..c342eb4463 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -300,8 +300,8 @@ def perform_ota( nonce = nonce_bytes.decode() _LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce) - # Generate cnonce - cnonce = secrets.token_hex(32) + # Generate cnonce matching the hash algorithm's digest size + cnonce = secrets.token_hex(nonce_size // 2) _LOGGER.debug("Auth: %s CNonce is %s", hash_name, cnonce) send_check(sock, cnonce, "auth cnonce") diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 209fe81065..57e33e083e 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -18,7 +18,8 @@ from esphome import espota2 from esphome.core import EsphomeError # Test constants -MOCK_CNONCE = "a" * 64 # Mock 64-char hex string from secrets.token_hex(32) +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 @@ -54,12 +55,16 @@ def mock_time() -> Generator[None]: @pytest.fixture -def mock_random() -> Generator[Mock]: +def mock_token_hex() -> Generator[Mock]: """Mock secrets.token_hex for predictable test values.""" - with patch( - "esphome.espota2.secrets.token_hex", return_value=MOCK_CNONCE - ) as mock_rand: - yield mock_rand + + def _token_hex(nbytes: int) -> str: + if nbytes == 16: + return MOCK_MD5_CNONCE + return MOCK_SHA256_CNONCE + + with patch("esphome.espota2.secrets.token_hex", side_effect=_token_hex) as mock: + yield mock @pytest.fixture @@ -237,7 +242,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 @@ -273,8 +278,11 @@ def test_perform_ota_successful_md5_auth( ) ) + # Verify token_hex was called with MD5 digest size + mock_token_hex.assert_called_once_with(16) + # Verify cnonce was sent - cnonce = MOCK_CNONCE + cnonce = MOCK_MD5_CNONCE assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) # Verify auth result was computed correctly @@ -367,7 +375,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 @@ -391,7 +399,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 @@ -604,7 +612,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 @@ -640,8 +648,11 @@ def test_perform_ota_successful_sha256_auth( ) ) + # Verify token_hex was called with SHA256 digest size + mock_token_hex.assert_called_once_with(32) + # Verify cnonce was sent - cnonce = MOCK_CNONCE + cnonce = MOCK_SHA256_CNONCE assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) # Verify auth result was computed correctly with SHA256 @@ -655,7 +666,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 @@ -693,7 +704,8 @@ def test_perform_ota_sha256_fallback_to_md5( ) # But authentication was done with MD5 - cnonce = MOCK_CNONCE + 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)