from __future__ import annotations import json import logging import re import secrets import time from collections.abc import Callable from contextlib import AbstractContextManager from contextlib import nullcontext as does_not_raise import aiohttp import pytest from cryptography.hazmat.primitives.ciphers import Cipher, modes from pytest_mock import MockerFixture from yarl import URL from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials from kasa.deviceconfig import ( DeviceConfig, DeviceConnectionParameters, DeviceEncryptionType, DeviceFamily, ) from kasa.exceptions import ( AuthenticationError, KasaException, TimeoutError, _ConnectionError, _RetryableError, ) from kasa.httpclient import HttpClient from kasa.iot import IotBulb, IotDevice, IotPlug, IotWallSwitch from kasa.protocols import IotProtocol, SmartProtocol from kasa.transports.aestransport import AesTransport from kasa.transports.klaptransport import ( PACK_SIGNED_LONG, KlapEncryptionSession, KlapTransport, KlapTransportV2, _sha256, ) from ..device_fixtures import device_iot, parametrize DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} KLAP_CONNECTION = DeviceConnectionParameters( DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Klap, login_version=2 ) ks200m_iot = parametrize("ks200m", model_filter={"KS200M"}, protocol_filter={"IOT"}) # Transport tests are not designed for real devices pytestmark = [pytest.mark.requires_dummy] class _mock_response: def __init__(self, status, content: bytes): self.status = status self.content = content async def __aenter__(self): return self async def __aexit__(self, exc_t, exc_v, exc_tb): pass async def read(self): return self.content @pytest.mark.parametrize( ("error", "retry_expectation"), [ (Exception("dummy exception"), False), (aiohttp.ServerTimeoutError("dummy exception"), True), (aiohttp.ServerDisconnectedError("dummy exception"), True), (aiohttp.ClientOSError("dummy exception"), True), ], ids=("Exception", "ServerTimeoutError", "ServerDisconnectedError", "ClientOSError"), ) @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @pytest.mark.parametrize("retry_count", [1, 3, 5]) async def test_protocol_retries_via_client_session( mocker: MockerFixture, retry_count: int, protocol_class: type, transport_class: type, error: Exception, retry_expectation: bool, ) -> None: host = "127.0.0.1" conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=error) config = DeviceConfig(host) with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=retry_count ) expected_count = retry_count + 1 if retry_expectation else 1 assert conn.call_count == expected_count @pytest.mark.parametrize( ("error", "retry_expectation"), [ (KasaException("dummy exception"), False), (_RetryableError("dummy exception"), True), (TimeoutError("dummy exception"), True), ], ids=("KasaException", "_RetryableError", "TimeoutError"), ) @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @pytest.mark.parametrize("retry_count", [1, 3, 5]) async def test_protocol_retries_via_httpclient( mocker: MockerFixture, retry_count: int, protocol_class: type, transport_class: type, error: Exception, retry_expectation: bool, ) -> None: host = "127.0.0.1" conn = mocker.patch.object(HttpClient, "post", side_effect=error) mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=retry_count ) expected_count = retry_count + 1 if retry_expectation else 1 assert conn.call_count == expected_count @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) async def test_protocol_no_retry_on_connection_error( mocker: MockerFixture, protocol_class: type, transport_class: type ) -> None: host = "127.0.0.1" conn = mocker.patch.object( aiohttp.ClientSession, "post", side_effect=AuthenticationError("foo"), ) mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=5 ) assert conn.call_count == 1 @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) async def test_protocol_retry_recoverable_error( mocker: MockerFixture, protocol_class: type, transport_class: type ) -> None: host = "127.0.0.1" conn = mocker.patch.object( aiohttp.ClientSession, "post", side_effect=aiohttp.ClientOSError("foo"), ) config = DeviceConfig(host) with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=5 ) assert conn.call_count == 6 @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @pytest.mark.parametrize("retry_count", [1, 3, 5]) async def test_protocol_reconnect( mocker: MockerFixture, retry_count: int, protocol_class: type, transport_class: type ) -> None: host = "127.0.0.1" remaining = retry_count mock_response = {"result": {"great": "success"}, "error_code": 0} def _fail_one_less_than_retry_count(*_, **__): nonlocal remaining remaining -= 1 if remaining: raise _ConnectionError("Simulated connection failure") return mock_response mocker.patch.object(transport_class, "perform_handshake") if hasattr(transport_class, "perform_login"): mocker.patch.object(transport_class, "perform_login") send_mock = mocker.patch.object( transport_class, "send", side_effect=_fail_one_less_than_retry_count, ) config = DeviceConfig(host) response = await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=retry_count ) assert "result" in response or "foobar" in response assert send_mock.call_count == retry_count @pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) @pytest.mark.xdist_group(name="caplog") async def test_protocol_logging( mocker: MockerFixture, caplog: pytest.LogCaptureFixture, log_level: int ) -> None: caplog.set_level(log_level) logging.getLogger("kasa").setLevel(log_level) def _return_encrypted(*_, **__): # Devices sign the response for the sequence number of the request encryption_session._seq -= 1 encrypted, seq = encryption_session.encrypt('{"great":"success"}') return 200, encrypted seed = secrets.token_bytes(16) auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) encryption_session = KlapEncryptionSession(seed, seed, auth_hash) config = DeviceConfig("127.0.0.1") transport = KlapTransport(config=config) protocol = IotProtocol(transport=transport) transport._handshake_done = True transport._session_expire_at = time.time() + 86400 transport._encryption_session = encryption_session mocker.patch.object(HttpClient, "post", side_effect=_return_encrypted) response = await protocol.query({}) assert response == {"great": "success"} if log_level == logging.DEBUG: assert "success" in caplog.text else: assert "success" not in caplog.text def test_encrypt() -> None: d = json.dumps({"foo": 1, "bar": 2}) seed = secrets.token_bytes(16) auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) encryption_session = KlapEncryptionSession(seed, seed, auth_hash) encrypted, seq = encryption_session.encrypt(d) assert d == encryption_session.decrypt(encrypted) def test_encrypt_unicode() -> None: d = "{'snowman': '\u2603'}" seed = secrets.token_bytes(16) auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) encryption_session = KlapEncryptionSession(seed, seed, auth_hash) encrypted, seq = encryption_session.encrypt(d) decrypted = encryption_session.decrypt(encrypted) assert d == decrypted async def test_transport_decrypt(mocker: MockerFixture) -> None: """Test transport decryption.""" d = {"great": "success"} seed = secrets.token_bytes(16) auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) encryption_session = KlapEncryptionSession(seed, seed, auth_hash) transport = KlapTransport(config=DeviceConfig(host="127.0.0.1")) transport._handshake_done = True transport._session_expire_at = time.monotonic() + 60 transport._encryption_session = encryption_session async def _return_response(url: URL, params=None, data=None, *_, **__): assert transport._encryption_session is not None encryption_session = KlapEncryptionSession( transport._encryption_session.local_seed, transport._encryption_session.remote_seed, transport._encryption_session.user_hash, ) seq = params.get("seq") encryption_session._seq = seq - 1 encrypted, seq = encryption_session.encrypt(json.dumps(d)) seq = seq return 200, encrypted mocker.patch.object(HttpClient, "post", side_effect=_return_response) resp = await transport.send(json.dumps({})) assert d == resp def _logged_in_transport() -> KlapTransport: seed = secrets.token_bytes(16) auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) transport = KlapTransport(config=DeviceConfig(host="127.0.0.1")) transport._handshake_done = True transport._session_expire_at = time.monotonic() + 60 transport._encryption_session = KlapEncryptionSession(seed, seed, auth_hash) return transport @pytest.mark.parametrize("mismatch", ["key", "seq"]) async def test_transport_signature_error(mocker: MockerFixture, mismatch: str) -> None: """Test that a response not signed for the request raises a kasa exception.""" transport = _logged_in_transport() async def _return_response(url: URL, params=None, data=None, *_, **__): assert transport._encryption_session is not None encryption_session = KlapEncryptionSession( secrets.token_bytes(16) if mismatch == "key" else transport._encryption_session.local_seed, transport._encryption_session.remote_seed, transport._encryption_session.user_hash, ) seq = params.get("seq") encryption_session._seq = seq - 1 if mismatch == "key" else seq encrypted, _seq = encryption_session.encrypt(json.dumps({"great": "success"})) return 200, encrypted mocker.patch.object(HttpClient, "post", side_effect=_return_response) with pytest.raises( KasaException, match=re.escape( "Device 127.0.0.1 sent a response with an invalid signature " "for request with seq" ), ): await transport.send(json.dumps({})) async def test_transport_decrypt_error(mocker: MockerFixture) -> None: """Test that a signed response that fails to decrypt raises a kasa exception.""" transport = _logged_in_transport() async def _return_response(url: URL, params=None, data=None, *_, **__): session = transport._encryption_session assert session is not None seq = PACK_SIGNED_LONG(params.get("seq")) # A block that decrypts to zeros, which is not valid PKCS7 padding encryptor = Cipher(session._aes, modes.CBC(session._iv + seq)).encryptor() ciphertext = encryptor.update(bytes(16)) + encryptor.finalize() return 200, _sha256(session._sig + seq + ciphertext) + ciphertext mocker.patch.object(HttpClient, "post", side_effect=_return_response) with pytest.raises( KasaException, match=re.escape("Error trying to decrypt device 127.0.0.1 response:"), ): await transport.send(json.dumps({})) HTML_PAGE = b"