Split KLAP IoT updates into smaller requests and validate KLAP responses (#1829)
Some checks failed
CI / Perform Lint Checks (3.15) (push) Has been cancelled
CodeQL Checks / Analyze (python) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.14 on macos-latest (push) Has been cancelled
CI / Python 3.15 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.14 on ubuntu-latest (push) Has been cancelled
CI / Python 3.15 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
CI / Python 3.14 on windows-latest (push) Has been cancelled
CI / Python 3.15 on windows-latest (push) Has been cancelled
Stale / stale (push) Has been cancelled

Some IoT devices respond over KLAP with a plain `200 OK` html page
instead of a proper response when the response would be too large to
construct, causing a "not a multiple of the block length" decryption
error (e.g., KS200M in #1828).

This PR lowers the maximum response size to 2048 bytes for IoT devices
using KLAP, so that the update is split into multiple smaller requests
like it is already done for bulbs.

In details:
- `IotDevice.max_device_response_size` returns 2048 over KLAP. Bulbs
  keep 4096 and XOR devices keep 16 KiB.
- `KlapTransport` checks the response length and signature before
  decrypting, and raises a `KasaException` telling what was wrong
  instead of the decryption error.

Fixes #1828

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZeliardM
2026-11-04 21:14:48 -05:00
committed by GitHub
parent 693bc4f7ed
commit 9478ed25ac
3 changed files with 209 additions and 15 deletions

View File

@@ -24,7 +24,7 @@ from warnings import warn
from ..device import Device, DeviceInfo, WifiNetwork
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..deviceconfig import DeviceConfig, DeviceEncryptionType
from ..exceptions import KasaException, TimeoutError, UnsupportedDeviceError
from ..feature import Feature
from ..module import Module
@@ -730,6 +730,11 @@ class IotDevice(Device):
@property
def max_device_response_size(self) -> int:
"""Returns the maximum response size the device can safely construct."""
# Over KLAP, some devices answer a request whose response would be
# larger than about 4 KiB with a plain html page instead of a response,
# and have even less room right after a state change.
if self.config.connection_type.encryption_type is DeviceEncryptionType.Klap:
return 2048
return 16 * 1024
@property

View File

@@ -46,6 +46,7 @@ import asyncio
import base64
import datetime
import hashlib
import hmac
import logging
import secrets
import struct
@@ -396,6 +397,18 @@ class KlapTransport(BaseTransport):
if TYPE_CHECKING:
assert self._encryption_session
assert isinstance(response_data, bytes)
# A message is a 32 byte signature followed by whole AES blocks.
# Some devices answer with a plain html page instead.
if len(response_data) < 48 or (len(response_data) - 32) % 16:
raise KasaException(
f"Device {self._host} sent a response that is not a KLAP "
f"message ({len(response_data)} bytes): {response_data[:64]!r}"
)
if not self._encryption_session.is_signed(response_data, seq):
raise KasaException(
f"Device {self._host} sent a response with an invalid "
f"signature for request with seq {seq}"
)
try:
decrypted_response = self._encryption_session.decrypt(response_data)
except Exception as ex:
@@ -535,6 +548,11 @@ class KlapEncryptionSession:
).digest()
return (signature + ciphertext, self._seq)
def is_signed(self, msg: bytes, seq: int) -> bool:
"""Return whether the message is signed for the request with seq."""
signature = _sha256(self._sig + PACK_SIGNED_LONG(seq) + msg[32:])
return hmac.compare_digest(msg[:32], signature)
def decrypt(self, msg: bytes) -> str:
"""Decrypt the data."""
decryptor = self._cipher.decryptor()