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 logging
import secrets
import ssl
import struct
import time
from asyncio import Future
@@ -67,6 +66,7 @@ from kasa.json import loads as json_loads
from kasa.protocols.protocol import md5
from .basetransport import BaseTransport
from .sslcontext import get_ssl_context
_LOGGER = logging.getLogger(__name__)
@@ -97,17 +97,6 @@ class KlapTransport(BaseTransport):
SESSION_COOKIE_NAME = "TP_SESSIONID"
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__(
self,
@@ -193,7 +182,7 @@ class KlapTransport(BaseTransport):
url = self._app_url / "handshake1"
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):
@@ -305,7 +294,7 @@ class KlapTransport(BaseTransport):
url,
data=payload,
cookies_dict=self._session_cookie,
ssl=await self._get_ssl_context(),
ssl=await get_ssl_context(),
)
if _LOGGER.isEnabledFor(logging.DEBUG):
@@ -380,7 +369,7 @@ class KlapTransport(BaseTransport):
params={"seq": seq},
data=payload,
cookies_dict=self._session_cookie,
ssl=await self._get_ssl_context(),
ssl=await get_ssl_context(),
)
msg = (
@@ -457,23 +446,6 @@ class KlapTransport(BaseTransport):
un = creds.username
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):
"""Implementation of the KLAP encryption protocol with v2 hanshake hashes."""

View File

@@ -2,10 +2,8 @@
from __future__ import annotations
import asyncio
import base64
import logging
import ssl
from typing import TYPE_CHECKING, cast
from urllib.parse import quote
@@ -19,6 +17,7 @@ from kasa.json import loads as json_loads
from kasa.transports.xortransport import XorEncryption
from .basetransport import BaseTransport
from .sslcontext import get_ssl_context
_LOGGER = logging.getLogger(__name__)
@@ -31,20 +30,10 @@ class LinkieTransportV2(BaseTransport):
"""
DEFAULT_PORT: int = 10443
CIPHERS = ":".join(
[
"AES256-GCM-SHA384",
"AES256-SHA256",
"AES128-GCM-SHA256",
"AES128-SHA256",
"AES256-SHA",
]
)
def __init__(self, *, config: DeviceConfig) -> None:
super().__init__(config=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._headers = {
@@ -78,7 +67,7 @@ class LinkieTransportV2(BaseTransport):
self._app_url,
headers=self._headers,
data=f"content={url_safe_cmd}".encode(),
ssl=await self._get_ssl_context(),
ssl=await get_ssl_context(),
)
if TYPE_CHECKING:
@@ -128,18 +117,3 @@ class LinkieTransportV2(BaseTransport):
raise _RetryableError(
f"Unable to query the device {self._host}:{self._port}: {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
import asyncio
import base64
import hashlib
import logging
import secrets
import ssl
from contextlib import suppress
from enum import Enum, auto
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 loads as json_loads
from . import AesEncyptionSession, BaseTransport
from .sslcontext import get_ssl_context
_LOGGER = logging.getLogger(__name__)
@@ -71,17 +70,6 @@ class SslAesTransport(BaseTransport):
"Accept-Encoding": "gzip, deflate",
"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
def __init__(
@@ -113,7 +101,6 @@ class SslAesTransport(BaseTransport):
self._host_port = f"{self._host}:{self._port}"
self._app_url = URL(f"https://{self._host_port}")
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)
self._headers = {
**self.COMMON_HEADERS,
@@ -210,21 +197,6 @@ class SslAesTransport(BaseTransport):
raise AuthenticationError(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]:
"""Send encrypted message as passthrough."""
if self._state is TransportState.ESTABLISHED and self._token_url:
@@ -255,7 +227,7 @@ class SslAesTransport(BaseTransport):
url,
json=passthrough_request_str,
headers=headers,
ssl=await self._get_ssl_context(),
ssl=await get_ssl_context(),
)
if TYPE_CHECKING:
@@ -347,7 +319,7 @@ class SslAesTransport(BaseTransport):
url,
json=request,
headers=self._headers,
ssl=await self._get_ssl_context(),
ssl=await get_ssl_context(),
)
if status_code != 200:
@@ -433,7 +405,7 @@ class SslAesTransport(BaseTransport):
self._app_url,
json=body,
headers=self._headers,
ssl=await self._get_ssl_context(),
ssl=await get_ssl_context(),
)
if status_code != 200:
raise KasaException(
@@ -477,7 +449,7 @@ class SslAesTransport(BaseTransport):
self._app_url,
json=body,
headers=self._headers,
ssl=await self._get_ssl_context(),
ssl=await get_ssl_context(),
)
if status_code != 200:
raise KasaException(
@@ -660,7 +632,7 @@ class SslAesTransport(BaseTransport):
self._app_url,
json=body,
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)

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 loads as json_loads
from kasa.transports import BaseTransport
from kasa.transports.sslcontext import get_ssl_context
_LOGGER = logging.getLogger(__name__)
@@ -151,6 +152,7 @@ class SslTransport(BaseTransport):
url,
json=request,
headers=self.COMMON_HEADERS,
ssl=await get_ssl_context(),
)
if status_code != 200:

View File

@@ -48,12 +48,6 @@ MOCK_STOCK = "abcdefghijklmnopqrstuvwxyz1234)("
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(
(
"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