mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-10-15 09:04:16 +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 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."""
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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 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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
11
tests/transports/test_sslcontext.py
Normal file
11
tests/transports/test_sslcontext.py
Normal 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
|
||||||
Reference in New Issue
Block a user