Consolidate SSL ciphers and use them in SslTransport (#1798)
Some checks are pending
CI / Perform Lint Checks (3.14) (push) Waiting to run
CI / Python 3.11 on macos-latest (push) Blocked by required conditions
CI / Python 3.12 on macos-latest (push) Blocked by required conditions
CI / Python 3.13 on macos-latest (push) Blocked by required conditions
CI / Python 3.14 on macos-latest (push) Blocked by required conditions
CI / Python 3.11 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.12 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.13 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.14 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.11 on windows-latest (push) Blocked by required conditions
CI / Python 3.12 on windows-latest (push) Blocked by required conditions
CI / Python 3.13 on windows-latest (push) Blocked by required conditions
CI / Python 3.14 on windows-latest (push) Blocked by required conditions
CodeQL Checks / Analyze (python) (push) Waiting to run

- Add `kasa/transports/sslcontext.py` with one shared SSL context for
the https transports to avoid copies across transports.
- Use it in `SslTransport`, and convert KLAP and Linkie also to use it.
- Extend Python's default ciphers with the legacy suites instead of
replacing accepted ciphers for future compatibility.
This commit is contained in:
Teemu R.
2026-10-14 23:16:56 +02:00
committed by GitHub
parent d6e80d2c78
commit 2a81f74e9c
7 changed files with 65 additions and 100 deletions

View File

@@ -48,7 +48,6 @@ import datetime
import hashlib import hashlib
import logging import logging
import secrets import secrets
import ssl
import struct import struct
import time import time
from asyncio import Future from asyncio import Future
@@ -67,6 +66,7 @@ from kasa.json import loads as json_loads
from kasa.protocols.protocol import md5 from kasa.protocols.protocol import md5
from .basetransport import BaseTransport from .basetransport import BaseTransport
from .sslcontext import get_ssl_context
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -97,17 +97,6 @@ class KlapTransport(BaseTransport):
SESSION_COOKIE_NAME = "TP_SESSIONID" SESSION_COOKIE_NAME = "TP_SESSIONID"
TIMEOUT_COOKIE_NAME = "TIMEOUT" TIMEOUT_COOKIE_NAME = "TIMEOUT"
# Copy & paste from sslaestransport
CIPHERS = ":".join(
[
"AES256-GCM-SHA384",
"AES256-SHA256",
"AES128-GCM-SHA256",
"AES128-SHA256",
"AES256-SHA",
]
)
_ssl_context: ssl.SSLContext | None = None
def __init__( def __init__(
self, self,
@@ -193,7 +182,7 @@ class KlapTransport(BaseTransport):
url = self._app_url / "handshake1" url = self._app_url / "handshake1"
response_status, response_data = await self._http_client.post( response_status, response_data = await self._http_client.post(
url, data=payload, ssl=await self._get_ssl_context() url, data=payload, ssl=await get_ssl_context()
) )
if _LOGGER.isEnabledFor(logging.DEBUG): if _LOGGER.isEnabledFor(logging.DEBUG):
@@ -305,7 +294,7 @@ class KlapTransport(BaseTransport):
url, url,
data=payload, data=payload,
cookies_dict=self._session_cookie, cookies_dict=self._session_cookie,
ssl=await self._get_ssl_context(), ssl=await get_ssl_context(),
) )
if _LOGGER.isEnabledFor(logging.DEBUG): if _LOGGER.isEnabledFor(logging.DEBUG):
@@ -380,7 +369,7 @@ class KlapTransport(BaseTransport):
params={"seq": seq}, params={"seq": seq},
data=payload, data=payload,
cookies_dict=self._session_cookie, cookies_dict=self._session_cookie,
ssl=await self._get_ssl_context(), ssl=await get_ssl_context(),
) )
msg = ( msg = (
@@ -457,23 +446,6 @@ class KlapTransport(BaseTransport):
un = creds.username un = creds.username
return md5(un.encode()) return md5(un.encode())
# Copy & paste from sslaestransport.
def _create_ssl_context(self) -> ssl.SSLContext:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.set_ciphers(self.CIPHERS)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
# Copy & paste from sslaestransport.
async def _get_ssl_context(self) -> ssl.SSLContext:
if not self._ssl_context:
loop = asyncio.get_running_loop()
self._ssl_context = await loop.run_in_executor(
None, self._create_ssl_context
)
return self._ssl_context
class KlapTransportV2(KlapTransport): class KlapTransportV2(KlapTransport):
"""Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" """Implementation of the KLAP encryption protocol with v2 hanshake hashes."""

View File

@@ -2,10 +2,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import logging import logging
import ssl
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from urllib.parse import quote from urllib.parse import quote
@@ -19,6 +17,7 @@ from kasa.json import loads as json_loads
from kasa.transports.xortransport import XorEncryption from kasa.transports.xortransport import XorEncryption
from .basetransport import BaseTransport from .basetransport import BaseTransport
from .sslcontext import get_ssl_context
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -31,20 +30,10 @@ class LinkieTransportV2(BaseTransport):
""" """
DEFAULT_PORT: int = 10443 DEFAULT_PORT: int = 10443
CIPHERS = ":".join(
[
"AES256-GCM-SHA384",
"AES256-SHA256",
"AES128-GCM-SHA256",
"AES128-SHA256",
"AES256-SHA",
]
)
def __init__(self, *, config: DeviceConfig) -> None: def __init__(self, *, config: DeviceConfig) -> None:
super().__init__(config=config) super().__init__(config=config)
self._http_client = HttpClient(config) self._http_client = HttpClient(config)
self._ssl_context: ssl.SSLContext | None = None
self._app_url = URL(f"https://{self._host}:{self._port}/data/LINKIE2.json") self._app_url = URL(f"https://{self._host}:{self._port}/data/LINKIE2.json")
self._headers = { self._headers = {
@@ -78,7 +67,7 @@ class LinkieTransportV2(BaseTransport):
self._app_url, self._app_url,
headers=self._headers, headers=self._headers,
data=f"content={url_safe_cmd}".encode(), data=f"content={url_safe_cmd}".encode(),
ssl=await self._get_ssl_context(), ssl=await get_ssl_context(),
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -128,18 +117,3 @@ class LinkieTransportV2(BaseTransport):
raise _RetryableError( raise _RetryableError(
f"Unable to query the device {self._host}:{self._port}: {ex}" f"Unable to query the device {self._host}:{self._port}: {ex}"
) from ex ) from ex
async def _get_ssl_context(self) -> ssl.SSLContext:
if not self._ssl_context:
loop = asyncio.get_running_loop()
self._ssl_context = await loop.run_in_executor(
None, self._create_ssl_context
)
return self._ssl_context
def _create_ssl_context(self) -> ssl.SSLContext:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.set_ciphers(self.CIPHERS)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context

View File

@@ -2,12 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import hashlib import hashlib
import logging import logging
import secrets import secrets
import ssl
from contextlib import suppress from contextlib import suppress
from enum import Enum, auto from enum import Enum, auto
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@@ -29,6 +27,7 @@ from ..httpclient import HttpClient
from ..json import dumps as json_dumps from ..json import dumps as json_dumps
from ..json import loads as json_loads from ..json import loads as json_loads
from . import AesEncyptionSession, BaseTransport from . import AesEncyptionSession, BaseTransport
from .sslcontext import get_ssl_context
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -71,17 +70,6 @@ class SslAesTransport(BaseTransport):
"Accept-Encoding": "gzip, deflate", "Accept-Encoding": "gzip, deflate",
"User-Agent": "Tapo CameraClient Android", "User-Agent": "Tapo CameraClient Android",
} }
CIPHERS = ":".join(
[
"ECDHE-RSA-AES128-GCM-SHA256",
"ECDHE-RSA-AES256-GCM-SHA384",
"AES256-GCM-SHA384",
"AES256-SHA256",
"AES128-GCM-SHA256",
"AES128-SHA256",
"AES256-SHA",
]
)
DEFAULT_TIMEOUT = 10 DEFAULT_TIMEOUT = 10
def __init__( def __init__(
@@ -113,7 +101,6 @@ class SslAesTransport(BaseTransport):
self._host_port = f"{self._host}:{self._port}" self._host_port = f"{self._host}:{self._port}"
self._app_url = URL(f"https://{self._host_port}") self._app_url = URL(f"https://{self._host_port}")
self._token_url: URL | None = None self._token_url: URL | None = None
self._ssl_context: ssl.SSLContext | None = None
ref = str(self._token_url) if self._token_url else str(self._app_url) ref = str(self._token_url) if self._token_url else str(self._app_url)
self._headers = { self._headers = {
**self.COMMON_HEADERS, **self.COMMON_HEADERS,
@@ -210,21 +197,6 @@ class SslAesTransport(BaseTransport):
raise AuthenticationError(msg, error_code=error_code) raise AuthenticationError(msg, error_code=error_code)
raise DeviceError(msg, error_code=error_code) raise DeviceError(msg, error_code=error_code)
def _create_ssl_context(self) -> ssl.SSLContext:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.set_ciphers(self.CIPHERS)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
async def _get_ssl_context(self) -> ssl.SSLContext:
if not self._ssl_context:
loop = asyncio.get_running_loop()
self._ssl_context = await loop.run_in_executor(
None, self._create_ssl_context
)
return self._ssl_context
async def send_secure_passthrough(self, request: str) -> dict[str, Any]: async def send_secure_passthrough(self, request: str) -> dict[str, Any]:
"""Send encrypted message as passthrough.""" """Send encrypted message as passthrough."""
if self._state is TransportState.ESTABLISHED and self._token_url: if self._state is TransportState.ESTABLISHED and self._token_url:
@@ -255,7 +227,7 @@ class SslAesTransport(BaseTransport):
url, url,
json=passthrough_request_str, json=passthrough_request_str,
headers=headers, headers=headers,
ssl=await self._get_ssl_context(), ssl=await get_ssl_context(),
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -347,7 +319,7 @@ class SslAesTransport(BaseTransport):
url, url,
json=request, json=request,
headers=self._headers, headers=self._headers,
ssl=await self._get_ssl_context(), ssl=await get_ssl_context(),
) )
if status_code != 200: if status_code != 200:
@@ -433,7 +405,7 @@ class SslAesTransport(BaseTransport):
self._app_url, self._app_url,
json=body, json=body,
headers=self._headers, headers=self._headers,
ssl=await self._get_ssl_context(), ssl=await get_ssl_context(),
) )
if status_code != 200: if status_code != 200:
raise KasaException( raise KasaException(
@@ -477,7 +449,7 @@ class SslAesTransport(BaseTransport):
self._app_url, self._app_url,
json=body, json=body,
headers=self._headers, headers=self._headers,
ssl=await self._get_ssl_context(), ssl=await get_ssl_context(),
) )
if status_code != 200: if status_code != 200:
raise KasaException( raise KasaException(
@@ -660,7 +632,7 @@ class SslAesTransport(BaseTransport):
self._app_url, self._app_url,
json=body, json=body,
headers=self._headers, headers=self._headers,
ssl=await self._get_ssl_context(), ssl=await get_ssl_context(),
) )
_LOGGER.debug("Device responded with status %s: %s", status_code, resp_dict) _LOGGER.debug("Device responded with status %s: %s", status_code, resp_dict)

View File

@@ -0,0 +1,40 @@
"""Shared SSL context for the transports talking https to local devices.
Not security sensitive: certificates are not verified.
The extra ciphers exist only for compatibility,
as Python's defaults lack suites older firmware needs.
"""
from __future__ import annotations
import asyncio
import ssl
from functools import cache
LEGACY_CIPHERS = [
"AES256-GCM-SHA384",
"AES256-SHA256",
"AES128-GCM-SHA256",
"AES128-SHA256",
"AES256-SHA",
"AES128-SHA",
"ECDHE-RSA-AES128-SHA",
"ECDHE-RSA-AES256-SHA",
]
@cache
def _create_ssl_context() -> ssl.SSLContext:
"""Return the cached context, blocking on first use."""
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
# Append to the defaults so suites newer firmware needs are not dropped.
default = [cipher["name"] for cipher in context.get_ciphers()]
context.set_ciphers(":".join([*default, *LEGACY_CIPHERS]))
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
async def get_ssl_context() -> ssl.SSLContext:
"""Return the cached context, created in an executor as that blocks."""
return await asyncio.get_running_loop().run_in_executor(None, _create_ssl_context)

View File

@@ -31,6 +31,7 @@ from kasa.httpclient import HttpClient
from kasa.json import dumps as json_dumps from kasa.json import dumps as json_dumps
from kasa.json import loads as json_loads from kasa.json import loads as json_loads
from kasa.transports import BaseTransport from kasa.transports import BaseTransport
from kasa.transports.sslcontext import get_ssl_context
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -151,6 +152,7 @@ class SslTransport(BaseTransport):
url, url,
json=request, json=request,
headers=self.COMMON_HEADERS, headers=self.COMMON_HEADERS,
ssl=await get_ssl_context(),
) )
if status_code != 200: if status_code != 200:

View File

@@ -48,12 +48,6 @@ MOCK_STOCK = "abcdefghijklmnopqrstuvwxyz1234)("
MOCK_UNENCRYPTED_PASSTHROUGH_STOK = "32charLowerCaseHexStok" MOCK_UNENCRYPTED_PASSTHROUGH_STOK = "32charLowerCaseHexStok"
def test_ssl_ciphers_include_modern_tapo_suites() -> None:
"""Keep compatibility with cameras that dropped legacy RSA ciphers."""
assert "ECDHE-RSA-AES128-GCM-SHA256" in SslAesTransport.CIPHERS
assert "ECDHE-RSA-AES256-GCM-SHA384" in SslAesTransport.CIPHERS
@pytest.mark.parametrize( @pytest.mark.parametrize(
( (
"status_code", "status_code",

View File

@@ -0,0 +1,11 @@
import ssl
from kasa.transports.sslcontext import get_ssl_context
async def test_ssl_context_extends_python_defaults_with_legacy_suites() -> None:
"""Some ciphers are added on top of Python's defaults for compatibility."""
default = {c["name"] for c in ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT).get_ciphers()}
enabled = {c["name"] for c in (await get_ssl_context()).get_ciphers()}
assert default <= enabled
assert {"AES128-GCM-SHA256", "AES256-SHA"} <= enabled