diff --git a/README.md b/README.md index 5b598c6..1ee90dc 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ Python (HMAC-SHA256 / SHA256 / MD5 over ASCII) — so you can call the Tuya mobi API with **no external signer service, no qemu, and no native `.so`**. It is **generic across Tuya-based apps**: only a handful of *application* -constants differ per app (extracted from that app's APK). Supply them and it -works. +constants differ per app. Versioned Smart Life and Tuya Smart profiles are +included; callers can supply a profile for any other Tuya-based application. ## What it provides @@ -24,6 +24,10 @@ works. password login and atomic `localKey` + `secKey` retrieval for a specific device. Passwords and session tokens are never written to durable storage; authenticated session state is retained only in memory for subsequent calls. +- **`TuyaPasswordClient.for_application(application, …)`** — create that client + directly from an explicitly selected bundled application profile. +- **`get_mobile_app_profile(application)`** — explicit selection of a bundled, + versioned Smart Life or Tuya Smart application identity. - **`mqtt_credentials(signer, uid=…, ecode=…, partner_id=…)`** — MQTT broker username/password + signaling topics for the `smart/mb` channel. - **`mqtt_client_id(package)`** — isolated mobile-format client ID for a @@ -31,12 +35,28 @@ works. - **`NativeTuyaSigner`** — optional legacy fallback that shells out to an external signer (executable or HTTP), for parity/testing. -## App credentials +## Application profiles -The five app constants (`app_id`, `app_secret`, `cert_sha256_hex`, `app_key`, -`package`) are the only app-specific inputs; the algorithm is identical across -Tuya apps. This package intentionally ships **no** vendor credentials — the -caller supplies them (e.g. `petsseries` supplies the Philips Pet Series values). +Smart Life and Tuya Smart use different signing identities and request +metadata, so callers select the application explicitly. The package never +probes another application profile after an authentication failure. + +```python +from tuya_mobile import TuyaMobileApp, get_mobile_app_profile + +profile = get_mobile_app_profile(TuyaMobileApp.SMART_LIFE) +``` + +Each bundled profile is an immutable snapshot of one public Android application +build. The profile carries its app and SDK versions because those values may +rotate in a future build. The five signing constants (`app_id`, `app_secret`, +`cert_sha256_hex`, `app_key`, and `package`) are application-level inputs, not +user credentials. They come from public Android builds and are already published +in several open-source projects, so this package is not a user credential store. + +Other Tuya-based applications remain supported through a caller-supplied +`TuyaMobileAppProfile`; for example, `petsseries` supplies the Philips Pet +Series values. ## Usage @@ -59,11 +79,24 @@ profiles contain the version-specific APK constants used to identify and sign as that application: `app_id`, `app_secret`, certificate SHA-256, `app_key`, and package name. They also carry the request's app version, SDK/core versions, channel, platform, `ttid`, `et`, optional React Native version, and optional -business domain. These are application-level inputs, not user credentials; -this package deliberately does not bundle a Smart Life or Tuya Smart profile. +business domain. Callers can use a bundled profile or construct their own. `TuyaPasswordClient` passes the profile inputs to `PurePythonTuyaSigner`, which remains the sole implementation of derived global material and `chKey`. +```python +import aiohttp +from tuya_mobile import TuyaMobileApp, TuyaPasswordClient + +async with aiohttp.ClientSession() as session: + client = TuyaPasswordClient.for_application( + TuyaMobileApp.SMART_LIFE, + session, + username="owner@example.com", + ) + await client.login_with_password(password, country_code="33") + credentials = await client.get_device_credentials(device_id) +``` + Telephone endpoint probing is bounded to three password submissions by default. Every permitted variant receives a fresh short-lived token, and only an explicit "unsupported API" response permits another password submission. diff --git a/tests/test_password_client.py b/tests/test_password_client.py index f0296d6..c2f38a3 100644 --- a/tests/test_password_client.py +++ b/tests/test_password_client.py @@ -16,6 +16,7 @@ TuyaDeviceCredentials, TuyaMobileAccountLocked, TuyaMobileApiError, + TuyaMobileApp, TuyaMobileAppProfile, TuyaMobileCaptchaRequired, TuyaMobileDeviceNotFound, @@ -27,6 +28,7 @@ TuyaMobileSession, TuyaMobileTransportError, TuyaPasswordClient, + get_mobile_app_profile, ) from tuya_mobile.client import TuyaMobileClient, _decrypt, _encrypt from tuya_mobile.password_client import MOBILE_LOGIN_APIS, _rsa_encrypt_password @@ -422,6 +424,55 @@ async def test_login_errors_are_typed_and_redacted( assert "private-password" not in str(raised.value) assert "fixture-session" not in str(raised.value) + if exception is TuyaMobileProfileExpired: + assert "bundled" not in str(raised.value) + + +@pytest.mark.parametrize( + ("application", "expected_message"), + [ + ( + TuyaMobileApp.SMART_LIFE, + "bundled Smart Life profile 7.10.0 was rejected; " + "the app build has probably rotated", + ), + ( + TuyaMobileApp.TUYA_SMART, + "bundled Tuya Smart profile 7.8.6 was rejected; " + "the app build has probably rotated", + ), + ], +) +@pytest.mark.parametrize("rejection_stage", ["token", "login"]) +async def test_bundled_profile_rotation_error_identifies_profile( + token_result: dict[str, str], + application: TuyaMobileApp, + expected_message: str, + rejection_stage: str, +) -> None: + """Bundled profile rejections identify the likely rotated app build.""" + client = TuyaPasswordClient.for_application( + application, + Mock(), + username="owner@example.com", + ) + rejection = { + "success": False, + "errorCode": "ILLEGAL_CLIENT", + "errorMsg": "private-password fixture-session", + } + responses = [rejection] + if rejection_stage == "login": + responses.insert(0, token_result) + client._call = AsyncMock(side_effect=responses) + + with pytest.raises(TuyaMobileProfileExpired) as raised: + await client.login_with_password("private-password", "33") + + assert str(raised.value) == expected_message + assert client.profile is get_mobile_app_profile(application) + assert "private-password" not in str(raised.value) + assert "fixture-session" not in str(raised.value) def test_client_uses_encrypted_signer_and_stable_installation_id( diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..ebd54f9 --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,110 @@ +"""Tests for the bundled Smart Life and Tuya Smart application profiles.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from unittest.mock import Mock + +import pytest + +from tuya_mobile import ( + TuyaMobileApp, + TuyaPasswordClient, + get_mobile_app_profile, +) +from tuya_mobile.profiles import _MOBILE_APP_PROFILES + + +@pytest.mark.parametrize( + ("application", "name", "package", "version"), + [ + ( + TuyaMobileApp.SMART_LIFE, + "Smart Life", + "com.tuya.smartlife", + "7.10.0", + ), + ( + TuyaMobileApp.TUYA_SMART, + "Tuya Smart", + "com.tuya.smart", + "7.8.6", + ), + ], +) +def test_builtin_profiles_are_versioned_and_complete( + application: TuyaMobileApp, + name: str, + package: str, + version: str, +) -> None: + """Each supported application resolves to one complete versioned identity.""" + profile = get_mobile_app_profile(application) + assert get_mobile_app_profile(application) is profile + assert get_mobile_app_profile(application.value) is profile + assert _MOBILE_APP_PROFILES[application] is profile + assert profile.name == name + assert profile.package == package + assert profile.app_version == version + assert profile.app_id + assert profile.app_secret + assert profile.app_key + assert len(profile.cert_sha256_hex) == 64 + int(profile.cert_sha256_hex, 16) + assert profile.ttid + assert profile.sdk_version + assert profile.device_core_version + assert profile.channel == "sdk" + assert profile.et == "3" + assert profile.endpoints + + +def test_builtin_profile_registry_is_read_only() -> None: + """Callers cannot replace a process-wide application identity accidentally.""" + with pytest.raises(TypeError): + _MOBILE_APP_PROFILES[TuyaMobileApp.SMART_LIFE] = get_mobile_app_profile( + TuyaMobileApp.TUYA_SMART + ) + + +def test_builtin_profiles_are_immutable_and_redacted() -> None: + """Versioned identities cannot be mutated and hide reusable key material.""" + smart_life = get_mobile_app_profile(TuyaMobileApp.SMART_LIFE) + with pytest.raises(FrozenInstanceError): + smart_life.app_version = "newer" + + for profile in _MOBILE_APP_PROFILES.values(): + rendered = repr(profile) + assert profile.app_secret not in rendered + assert profile.app_key not in rendered + assert profile.cert_sha256_hex not in rendered + + +@pytest.mark.parametrize( + "application", + [TuyaMobileApp.SMART_LIFE, TuyaMobileApp.SMART_LIFE.value], +) +def test_password_client_resolves_a_bundled_application_profile( + application: TuyaMobileApp | str, +) -> None: + """Official applications need no caller-constructed profile.""" + client = TuyaPasswordClient.for_application( + application, + Mock(), + username="owner@example.com", + endpoint="https://example.invalid/api.json", + request_timeout=7, + max_login_attempts=2, + ) + + assert client.profile is get_mobile_app_profile(TuyaMobileApp.SMART_LIFE) + assert client.username == "owner@example.com" + assert client.mobile_url == "https://example.invalid/api.json" + assert client.request_timeout == 7 + assert client.max_login_attempts == 2 + + +def test_unknown_application_is_rejected_without_fallback() -> None: + """Profile selection remains explicit and never probes another application.""" + with pytest.raises(ValueError, match="Unsupported Tuya mobile application"): + get_mobile_app_profile("unknown") diff --git a/tuya_mobile/__init__.py b/tuya_mobile/__init__.py index d1559ea..5a43c21 100644 --- a/tuya_mobile/__init__.py +++ b/tuya_mobile/__init__.py @@ -2,9 +2,8 @@ A dependency-free reimplementation of Tuya's ``thing_security`` mobile-app request signing, the encrypted mobile API client, and the MQTT signaling -credential derivation. Generic across Tuya-based apps — supply your app's Tuya -application credentials (extracted from its APK) and it works with no external -signer service, no qemu, and no native libraries. +credential derivation. It includes versioned Smart Life and Tuya Smart profiles +and accepts caller-supplied profiles for other Tuya-based applications. """ from .signer import ( @@ -35,6 +34,10 @@ ) from .mqtt_auth import mqtt_client_id, mqtt_credentials, mqtt_password, mqtt_username from .password_client import TuyaPasswordClient +from .profiles import ( + TuyaMobileApp, + get_mobile_app_profile, +) __all__ = [ "PurePythonTuyaSigner", @@ -44,7 +47,9 @@ "TuyaMobileClient", "canonical_string", "TuyaPasswordClient", + "TuyaMobileApp", "TuyaMobileAppProfile", + "get_mobile_app_profile", "TuyaMobileSession", "TuyaDeviceCredentials", "TuyaMobileError", diff --git a/tuya_mobile/password_client.py b/tuya_mobile/password_client.py index 88c11dc..92c260c 100644 --- a/tuya_mobile/password_client.py +++ b/tuya_mobile/password_client.py @@ -30,6 +30,11 @@ TuyaMobileAppProfile, TuyaMobileSession, ) +from .profiles import ( + TuyaMobileApp, + _is_bundled_mobile_app_profile, + get_mobile_app_profile, +) from .signer import PurePythonTuyaSigner TOKEN_API = ("thing.m.user.username.token.get", "2.0") @@ -56,7 +61,11 @@ def _walk(value: Any) -> Iterable[dict[str, Any]]: yield from _walk(child) -def _business_error(value: Any, context: str) -> TuyaMobileApiError | None: +def _business_error( + value: Any, + context: str, + profile: TuyaMobileAppProfile | None = None, +) -> TuyaMobileApiError | None: for response in _walk(value): if response.get("success") is not False and not response.get("errorCode"): continue @@ -86,6 +95,11 @@ def _business_error(value: Any, context: str) -> TuyaMobileApiError | None: item in marker for item in ("CLIENT", "SIGN", "APP VERSION", "ILLEGAL APP", "APPKEY") ): + if profile is not None and _is_bundled_mobile_app_profile(profile): + return TuyaMobileProfileExpired( + f"bundled {profile.name} profile {profile.app_version} was " + "rejected; the app build has probably rotated" + ) return TuyaMobileProfileExpired(safe_message) if any( item in marker @@ -96,8 +110,14 @@ def _business_error(value: Any, context: str) -> TuyaMobileApiError | None: return None -def _required_dict(value: Any, fields: set[str], context: str) -> dict[str, Any]: - if error := _business_error(value, context): +def _required_dict( + value: Any, + fields: set[str], + context: str, + *, + profile: TuyaMobileAppProfile | None = None, +) -> dict[str, Any]: + if error := _business_error(value, context, profile): raise error for candidate in _walk(value): if fields.issubset(candidate): @@ -124,8 +144,13 @@ def _normalized_mobile(username: str, country_code: str) -> str: return mobile -def _required_login(value: Any, context: str) -> dict[str, Any]: - if error := _business_error(value, context): +def _required_login( + value: Any, + context: str, + *, + profile: TuyaMobileAppProfile | None = None, +) -> dict[str, Any]: + if error := _business_error(value, context, profile): raise error aliases = { "sid": ("sid", "session", "sessionId"), @@ -168,6 +193,23 @@ def _rsa_encrypt_password(password: str, token: dict[str, Any]) -> str: class TuyaPasswordClient(TuyaMobileClient): """Encrypted Tuya mobile client with password authentication.""" + @classmethod + def for_application( + cls, + application: TuyaMobileApp | str, + session: aiohttp.ClientSession, + *, + username: str, + **client_kwargs: Any, + ) -> TuyaPasswordClient: + """Create a client from an explicitly selected bundled profile.""" + return cls( + get_mobile_app_profile(application), + session, + username=username, + **client_kwargs, + ) + def __init__( self, profile: TuyaMobileAppProfile, @@ -216,7 +258,9 @@ async def _mobile_call( ) from error except RuntimeError as error: typed = _business_error( - {"errorCode": "MOBILE_API", "errorMsg": str(error)}, action + {"errorCode": "MOBILE_API", "errorMsg": str(error)}, + action, + self.profile, ) raise typed or TuyaMobileApiError( f"Tuya mobile request failed for {action}" @@ -279,6 +323,7 @@ async def _get_login_token(self, country_code: str) -> dict[str, Any]: token_envelope, {"publicKey", "exponent", "token"}, "login token", + profile=self.profile, ) raise last_transport or TuyaMobileTransportError( "No Tuya mobile endpoint accepted the request" @@ -307,7 +352,7 @@ async def _submit_login( """Submit a password exactly once and account for that attempt.""" self._claim_login_attempt() response = await self._mobile_call(action, version, payload) - return _required_login(response, context) + return _required_login(response, context, profile=self.profile) async def _login_mobile( self, @@ -364,7 +409,7 @@ async def get_device_credentials( *DEVICE_CREDENTIALS_API, {"devId": device_id}, ) - if error := _business_error(response, "device credentials"): + if error := _business_error(response, "device credentials", self.profile): raise error device = next( ( diff --git a/tuya_mobile/profiles.py b/tuya_mobile/profiles.py new file mode 100644 index 0000000..79290bd --- /dev/null +++ b/tuya_mobile/profiles.py @@ -0,0 +1,89 @@ +"""Versioned application profiles for official Tuya mobile applications.""" + +from __future__ import annotations + +from collections.abc import Mapping +from enum import Enum +from types import MappingProxyType + +from .models import TuyaMobileAppProfile + +__all__ = ["TuyaMobileApp", "get_mobile_app_profile"] + + +class TuyaMobileApp(str, Enum): + """Official Tuya mobile applications with bundled profiles. + + Use ``.value`` whenever a member crosses a serialization or request boundary. + """ + + SMART_LIFE = "smart_life" + TUYA_SMART = "tuya_smart" + + +# These values identify public Android application builds. They are not user +# credentials, but they are version-specific and may rotate in a future build. +_SMART_LIFE_PROFILE = TuyaMobileAppProfile( + name="Smart Life", + app_id="ekmnwp9f5pnh3trdtpgy", + app_secret="r3me7ghmxjevrvnpemwmhw3fxtacphyg", # noqa: S106 + cert_sha256_hex=( + "0FC361999CC0C35BA8ACA57DAA5593A2" "0CF55727702EA85AD7B3228949F888FE" + ), + app_key="jfg5rs5kkmrj5mxahugvucrsvw43t48x", + package="com.tuya.smartlife", + app_version="7.10.0", + ttid="sdk_international@ekmnwp9f5pnh3trdtpgy", + sdk_version="7.9.0", + device_core_version="7.9.0", + os_system="14", + platform="SM-M115F", + channel="sdk", + app_rn_version="7.8", + et="3", +) + +_TUYA_SMART_PROFILE = TuyaMobileAppProfile( + name="Tuya Smart", + app_id="3cxxt3au9x33ytvq3h9j", + app_secret="5gdtanjtf38vyxkqh87cjwfcqjhvjjqa", # noqa: S106 + cert_sha256_hex=( + "93219FC273E2200F4ADEE5F7191DC656" "BA2A2D7B2FF5D24CD55C4B6155001E40" + ), + app_key="f3hd7pet4p83kemjdf5wqsa5tavrv579", + package="com.tuya.smart", + app_version="7.8.6", + ttid="international", + sdk_version="5.24.0", + device_core_version="5.17.0", + os_system="15", + platform="y", + channel="sdk", + app_rn_version="5.84", + et="3", +) + +_MOBILE_APP_PROFILES: Mapping[TuyaMobileApp, TuyaMobileAppProfile] = MappingProxyType( + { + TuyaMobileApp.SMART_LIFE: _SMART_LIFE_PROFILE, + TuyaMobileApp.TUYA_SMART: _TUYA_SMART_PROFILE, + } +) + + +def _is_bundled_mobile_app_profile(profile: TuyaMobileAppProfile) -> bool: + """Return whether a profile is one of the immutable bundled instances.""" + return any(profile is bundled for bundled in _MOBILE_APP_PROFILES.values()) + + +def get_mobile_app_profile( + application: TuyaMobileApp | str, +) -> TuyaMobileAppProfile: + """Return the bundled profile selected explicitly by the caller.""" + try: + selected = TuyaMobileApp(application) + except ValueError as error: + raise ValueError( + f"Unsupported Tuya mobile application: {application!r}" + ) from error + return _MOBILE_APP_PROFILES[selected]