[ota] Use secrets module for OTA authentication cnonce (#13863)

This commit is contained in:
J. Nick Koston
2026-02-09 08:30:49 -06:00
committed by GitHub
parent 4ef238eb7b
commit 1c60efa4b6
2 changed files with 34 additions and 19 deletions
+3 -3
View File
@@ -6,7 +6,7 @@ import hashlib
import io import io
import logging import logging
from pathlib import Path from pathlib import Path
import random import secrets
import socket import socket
import sys import sys
import time import time
@@ -300,8 +300,8 @@ def perform_ota(
nonce = nonce_bytes.decode() nonce = nonce_bytes.decode()
_LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce) _LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce)
# Generate cnonce # Generate cnonce matching the hash algorithm's digest size
cnonce = hash_func(str(random.random()).encode()).hexdigest() cnonce = secrets.token_hex(nonce_size // 2)
_LOGGER.debug("Auth: %s CNonce is %s", hash_name, cnonce) _LOGGER.debug("Auth: %s CNonce is %s", hash_name, cnonce)
send_check(sock, cnonce, "auth cnonce") send_check(sock, cnonce, "auth cnonce")
+31 -16
View File
@@ -18,8 +18,8 @@ from esphome import espota2
from esphome.core import EsphomeError from esphome.core import EsphomeError
# Test constants # Test constants
MOCK_RANDOM_VALUE = 0.123456 MOCK_MD5_CNONCE = "a" * 32 # Mock 32-char hex string from secrets.token_hex(16)
MOCK_RANDOM_BYTES = b"0.123456" 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_MD5_NONCE = b"12345678901234567890123456789012" # 32 char nonce for MD5
MOCK_SHA256_NONCE = b"1234567890123456789012345678901234567890123456789012345678901234" # 64 char nonce for SHA256 MOCK_SHA256_NONCE = b"1234567890123456789012345678901234567890123456789012345678901234" # 64 char nonce for SHA256
@@ -55,10 +55,18 @@ def mock_time() -> Generator[None]:
@pytest.fixture @pytest.fixture
def mock_random() -> Generator[Mock]: def mock_token_hex() -> Generator[Mock]:
"""Mock random for predictable test values.""" """Mock secrets.token_hex for predictable test values."""
with patch("random.random", return_value=MOCK_RANDOM_VALUE) as mock_rand:
yield mock_rand 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 @pytest.fixture
@@ -236,7 +244,7 @@ def test_send_check_socket_error(mock_socket: Mock) -> None:
@pytest.mark.usefixtures("mock_time") @pytest.mark.usefixtures("mock_time")
def test_perform_ota_successful_md5_auth( 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: ) -> None:
"""Test successful OTA with MD5 authentication.""" """Test successful OTA with MD5 authentication."""
# Setup socket responses for recv calls # 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()) # Verify token_hex was called with MD5 digest size
cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() 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()) assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode())
# Verify auth result was computed correctly # 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") @pytest.mark.usefixtures("mock_time")
def test_perform_ota_md5_auth_wrong_password( 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: ) -> None:
"""Test OTA fails when MD5 authentication is rejected due to wrong password.""" """Test OTA fails when MD5 authentication is rejected due to wrong password."""
# Setup socket responses for recv calls # Setup socket responses for recv calls
@@ -390,7 +401,7 @@ def test_perform_ota_md5_auth_wrong_password(
@pytest.mark.usefixtures("mock_time") @pytest.mark.usefixtures("mock_time")
def test_perform_ota_sha256_auth_wrong_password( 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: ) -> None:
"""Test OTA fails when SHA256 authentication is rejected due to wrong password.""" """Test OTA fails when SHA256 authentication is rejected due to wrong password."""
# Setup socket responses for recv calls # Setup socket responses for recv calls
@@ -603,7 +614,7 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
# Tests for SHA256 authentication # Tests for SHA256 authentication
@pytest.mark.usefixtures("mock_time") @pytest.mark.usefixtures("mock_time")
def test_perform_ota_successful_sha256_auth( 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: ) -> None:
"""Test successful OTA with SHA256 authentication.""" """Test successful OTA with SHA256 authentication."""
# Setup socket responses for recv calls # 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()) # Verify token_hex was called with SHA256 digest size
cnonce = hashlib.sha256(MOCK_RANDOM_BYTES).hexdigest() 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()) assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode())
# Verify auth result was computed correctly with SHA256 # Verify auth result was computed correctly with SHA256
@@ -654,7 +668,7 @@ def test_perform_ota_successful_sha256_auth(
@pytest.mark.usefixtures("mock_time") @pytest.mark.usefixtures("mock_time")
def test_perform_ota_sha256_fallback_to_md5( 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: ) -> None:
"""Test SHA256-capable client falls back to MD5 for compatibility.""" """Test SHA256-capable client falls back to MD5 for compatibility."""
# This test verifies the temporary backward 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 # 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 = hashlib.md5()
expected_hash.update(b"testpass") expected_hash.update(b"testpass")
expected_hash.update(MOCK_MD5_NONCE) expected_hash.update(MOCK_MD5_NONCE)