mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-10-15 17:14:15 +00:00
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
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:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
40
kasa/transports/sslcontext.py
Normal file
40
kasa/transports/sslcontext.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user