Update SslAesTransport for older firmware versions (#1362)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run

Older firmware versions do not encrypt the payload.

Tested to work with C110 hw 2.0 fw 1.3.7 Build 230823 Rel.57279n(5553)

---------

Co-authored-by: Teemu R. <tpr@iki.fi>
This commit is contained in:
Steven B.
2025-01-03 20:00:57 +00:00
committed by GitHub
parent 883d52209e
commit 0a95a41ab6
3 changed files with 363 additions and 23 deletions

View File

@@ -25,16 +25,19 @@ from kasa.transports.aestransport import AesEncyptionSession
from kasa.transports.sslaestransport import (
SslAesTransport,
TransportState,
_md5_hash,
_sha256_hash,
)
# Transport tests are not designed for real devices
pytestmark = [pytest.mark.requires_dummy]
# SslAesTransport use a socket to get it's own ip address
pytestmark = [pytest.mark.requires_dummy, pytest.mark.enable_socket]
MOCK_ADMIN_USER = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"]).username
MOCK_PWD = "correct_pwd" # noqa: S105
MOCK_USER = "mock@example.com"
MOCK_STOCK = "abcdefghijklmnopqrstuvwxyz1234)("
MOCK_UNENCRYPTED_PASSTHROUGH_STOK = "32charLowerCaseHexStok"
@pytest.mark.parametrize(
@@ -202,6 +205,124 @@ async def test_unencrypted_response(mocker, caplog):
)
@pytest.mark.parametrize(("want_default"), [True, False])
@pytest.mark.xdist_group(name="caplog")
async def test_unencrypted_passthrough(mocker, caplog, want_default):
host = "127.0.0.1"
mock_ssl_aes_device = MockSslAesDevice(
host, unencrypted_passthrough=True, want_default_username=want_default
)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
transport = SslAesTransport(
config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD))
)
request = {
"method": "getDeviceInfo",
"params": None,
}
caplog.set_level(logging.DEBUG)
res = await transport.send(json_dumps(request))
assert "result" in res
assert (
f"Succesfully logged in to {host} with less secure passthrough" in caplog.text
)
@pytest.mark.parametrize(("want_default"), [True, False])
@pytest.mark.xdist_group(name="caplog")
async def test_unencrypted_passthrough_errors(mocker, caplog, want_default):
host = "127.0.0.1"
request = {
"method": "getDeviceInfo",
"params": None,
}
transport = SslAesTransport(
config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD))
)
caplog.set_level(logging.DEBUG)
# Test bad password
mock_ssl_aes_device = MockSslAesDevice(
host,
unencrypted_passthrough=True,
want_default_username=want_default,
digest_password_fail=True,
)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
msg = f"Unable to log in to {host} with less secure login"
with pytest.raises(AuthenticationError):
await transport.send(json_dumps(request))
assert msg in caplog.text
# Test bad status code in handshake
mock_ssl_aes_device = MockSslAesDevice(
host,
unencrypted_passthrough=True,
want_default_username=want_default,
status_code=401,
)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
msg = f"{host} responded with an unexpected " f"status code 401 to handshake1"
with pytest.raises(KasaException, match=msg):
await transport.send(json_dumps(request))
# Test bad status code in login
mock_ssl_aes_device = MockSslAesDevice(
host,
unencrypted_passthrough=True,
want_default_username=want_default,
status_code_list=[200, 401],
)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
msg = f"{host} responded with an unexpected " f"status code 401 to login"
with pytest.raises(KasaException, match=msg):
await transport.send(json_dumps(request))
# Test bad status code in send
mock_ssl_aes_device = MockSslAesDevice(
host,
unencrypted_passthrough=True,
want_default_username=want_default,
status_code_list=[200, 200, 401],
)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
msg = f"{host} responded with an unexpected " f"status code 401 to unencrypted send"
with pytest.raises(KasaException, match=msg):
await transport.send(json_dumps(request))
# Test error code in send response
mock_ssl_aes_device = MockSslAesDevice(
host,
unencrypted_passthrough=True,
want_default_username=want_default,
send_error_code=SmartErrorCode.BAD_USERNAME.value,
)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
msg = f"Error sending message: {host}:"
with pytest.raises(KasaException, match=msg):
await transport.send(json_dumps(request))
async def test_device_blocked_response(mocker):
host = "127.0.0.1"
mock_ssl_aes_device = MockSslAesDevice(host, device_blocked=True)
@@ -300,6 +421,38 @@ class MockSslAesDevice:
"error_code": SmartErrorCode.SESSION_EXPIRED.value,
}
UNENCRYPTED_PASSTHROUGH_BAD_USER_RESP = {
"error_code": SmartErrorCode.SESSION_EXPIRED.value,
"result": {
"data": {
"code": SmartErrorCode.BAD_USERNAME.value,
"encrypt_type": ["1", "2"],
"key": "Someb64keyWithUnknownPurpose",
"nonce": "MixedCaseAlphaNumericWithUnknownPurpose",
}
},
}
UNENCRYPTED_PASSTHROUGH_HANDSHAKE_RESP = {
"error_code": SmartErrorCode.SESSION_EXPIRED.value,
"result": {
"data": {
"code": SmartErrorCode.SESSION_EXPIRED.value,
"time": 9,
"max_time": 10,
"sec_left": 0,
"encrypt_type": ["1", "2"],
"key": "Someb64keyWithUnknownPurpose",
"nonce": "MixedCaseAlphaNumericWithUnknownPurpose",
}
},
}
UNENCRYPTED_PASSTHROUGH_GOOD_LOGIN_RESPONSE = {
"error_code": 0,
"result": {"stok": MOCK_UNENCRYPTED_PASSTHROUGH_STOK, "user_group": "root"},
}
class _mock_response:
def __init__(self, status, request: dict):
self.status = status
@@ -321,6 +474,7 @@ class MockSslAesDevice:
host,
*,
status_code=200,
status_code_list=None,
want_default_username: bool = False,
do_not_encrypt_response=False,
send_response=None,
@@ -329,6 +483,7 @@ class MockSslAesDevice:
secure_passthrough_error_code=0,
digest_password_fail=False,
device_blocked=False,
unencrypted_passthrough=False,
):
self.host = host
self.http_client = HttpClient(DeviceConfig(self.host))
@@ -338,15 +493,22 @@ class MockSslAesDevice:
# test behaviour attributes
self.status_code = status_code
self.status_code_list = status_code_list if status_code_list else []
self.send_error_code = send_error_code
self.secure_passthrough_error_code = secure_passthrough_error_code
self.do_not_encrypt_response = do_not_encrypt_response
self.want_default_username = want_default_username
self.digest_password_fail = digest_password_fail
self.device_blocked = device_blocked
self.unencrypted_passthrough = unencrypted_passthrough
self._next_responses: list[dict | bytes] = []
def _get_status_code(self):
if self.status_code_list:
return self.status_code_list.pop(0)
return self.status_code
async def post(self, url: URL, params=None, json=None, data=None, *_, **__):
if data:
json = json_loads(data)
@@ -360,12 +522,25 @@ class MockSslAesDevice:
return await self._return_handshake1_response(url, json)
if method == "login" and self.handshake1_complete:
if self.unencrypted_passthrough:
return await self._return_unencrypted_passthrough_login_response(
url, json
)
return await self._return_handshake2_response(url, json)
elif method == "securePassthrough":
assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds")
return await self._return_secure_passthrough_response(url, json)
else:
assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds")
# The unencrypted passthrough with have actual query method names.
# This path is also used by the mock class to return unencrypted
# responses to single 'get' queries which the secure fw returns as unencrypted
stok = (
MOCK_UNENCRYPTED_PASSTHROUGH_STOK
if self.unencrypted_passthrough
else MOCK_STOCK
)
assert url == URL(f"https://{self.host}/stok={stok}/ds")
return await self._return_send_response(url, json)
async def _return_handshake1_response(self, url: URL, request: dict[str, Any]):
@@ -378,12 +553,23 @@ class MockSslAesDevice:
if (self.want_default_username and request_username != MOCK_ADMIN_USER) or (
not self.want_default_username and request_username != MOCK_USER
):
return self._mock_response(self.status_code, self.BAD_USER_RESP)
resp = (
self.UNENCRYPTED_PASSTHROUGH_BAD_USER_RESP
if self.unencrypted_passthrough
else self.BAD_USER_RESP
)
return self._mock_response(self.status_code, resp)
device_confirm = SslAesTransport.generate_confirm_hash(
request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode())
)
self.handshake1_complete = True
if self.unencrypted_passthrough:
return self._mock_response(
self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_HANDSHAKE_RESP
)
resp = {
"error_code": SmartErrorCode.INVALID_NONCE.value,
"result": {
@@ -396,7 +582,29 @@ class MockSslAesDevice:
}
},
}
return self._mock_response(self.status_code, resp)
return self._mock_response(self._get_status_code(), resp)
async def _return_unencrypted_passthrough_login_response(
self, url: URL, request: dict[str, Any]
):
request_username = request["params"].get("username")
request_password = request["params"].get("password")
if (self.want_default_username and request_username != MOCK_ADMIN_USER) or (
not self.want_default_username and request_username != MOCK_USER
):
return self._mock_response(
self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_BAD_USER_RESP
)
expected_pwd = _md5_hash(MOCK_PWD.encode())
if request_password != expected_pwd or self.digest_password_fail:
return self._mock_response(
self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_HANDSHAKE_RESP
)
return self._mock_response(
self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_GOOD_LOGIN_RESPONSE
)
async def _return_handshake2_response(self, url: URL, request: dict[str, Any]):
request_nonce = request["params"].get("cnonce")
@@ -404,14 +612,14 @@ class MockSslAesDevice:
if (self.want_default_username and request_username != MOCK_ADMIN_USER) or (
not self.want_default_username and request_username != MOCK_USER
):
return self._mock_response(self.status_code, self.BAD_USER_RESP)
return self._mock_response(self._get_status_code(), self.BAD_USER_RESP)
request_password = request["params"].get("digest_passwd")
expected_pwd = SslAesTransport.generate_digest_password(
request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode())
)
if request_password != expected_pwd or self.digest_password_fail:
return self._mock_response(self.status_code, self.BAD_PWD_RESP)
return self._mock_response(self._get_status_code(), self.BAD_PWD_RESP)
lsk = SslAesTransport.generate_encryption_token(
"lsk", request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode())
@@ -424,7 +632,7 @@ class MockSslAesDevice:
"error_code": 0,
"result": {"stok": MOCK_STOCK, "user_group": "root", "start_seq": 100},
}
return self._mock_response(self.status_code, resp)
return self._mock_response(self._get_status_code(), resp)
async def _return_secure_passthrough_response(self, url: URL, json: dict[str, Any]):
encrypted_request = json["params"]["request"]
@@ -458,11 +666,11 @@ class MockSslAesDevice:
"result": {"response": response.decode()},
"error_code": self.secure_passthrough_error_code,
}
return self._mock_response(self.status_code, result)
return self._mock_response(self._get_status_code(), result)
async def _return_send_response(self, url: URL, json: dict[str, Any]):
result = {"result": {"method": None}, "error_code": self.send_error_code}
return self._mock_response(self.status_code, result)
return self._mock_response(self._get_status_code(), result)
def put_next_response(self, request: dict | bytes) -> None:
self._next_responses.append(request)