From e3699613bc8fa430587f621796a8bba83ca2ee64 Mon Sep 17 00:00:00 2001 From: jbjardine Date: Thu, 20 Aug 2026 18:41:17 +0200 Subject: [PATCH 1/2] feat(profiles): Add official Tuya application profiles Bundle versioned Smart Life and Tuya Smart identities so standalone callers can use password login without reconstructing application profiles. Keep selection explicit and preserve the custom-profile constructor for other Tuya-based applications. Refs https://github.com/ha-tuya-ble/ha_tuya_ble/issues/163 --- README.md | 50 +++++++++++++--- tests/test_profiles.py | 103 +++++++++++++++++++++++++++++++++ tuya_mobile/__init__.py | 11 +++- tuya_mobile/password_client.py | 22 +++++++ tuya_mobile/profiles.py | 81 ++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 tests/test_profiles.py create mode 100644 tuya_mobile/profiles.py diff --git a/README.md b/README.md index 5b598c6..056c391 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,27 @@ 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. + +Other Tuya-based applications remain supported through a caller-supplied +`TuyaMobileAppProfile`; for example, `petsseries` supplies the Philips Pet +Series values. ## Usage @@ -59,11 +78,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_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..60c437f --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,103 @@ +"""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 + + +def test_password_client_resolves_a_bundled_application_profile() -> None: + """Official applications need no caller-constructed profile.""" + client = TuyaPasswordClient.for_application( + TuyaMobileApp.SMART_LIFE, + 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.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..5aea0da 100644 --- a/tuya_mobile/password_client.py +++ b/tuya_mobile/password_client.py @@ -30,6 +30,7 @@ TuyaMobileAppProfile, TuyaMobileSession, ) +from .profiles import TuyaMobileApp, get_mobile_app_profile from .signer import PurePythonTuyaSigner TOKEN_API = ("thing.m.user.username.token.get", "2.0") @@ -168,6 +169,27 @@ 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, + endpoint: str | None = None, + request_timeout: float = 20, + max_login_attempts: int = DEFAULT_MAX_LOGIN_ATTEMPTS, + ) -> TuyaPasswordClient: + """Create a client from an explicitly selected bundled profile.""" + return cls( + get_mobile_app_profile(application), + session, + username=username, + endpoint=endpoint, + request_timeout=request_timeout, + max_login_attempts=max_login_attempts, + ) + def __init__( self, profile: TuyaMobileAppProfile, diff --git a/tuya_mobile/profiles.py b/tuya_mobile/profiles.py new file mode 100644 index 0000000..dac4b07 --- /dev/null +++ b/tuya_mobile/profiles.py @@ -0,0 +1,81 @@ +"""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.""" + + 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 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] From 3e8c50375a5fc6cb1697acd6a29ff15192e894b5 Mon Sep 17 00:00:00 2001 From: jbjardine Date: Wed, 2 Sep 2026 13:42:23 +0200 Subject: [PATCH 2/2] fix(profiles): report bundled profile rotation Identify stale bundled Smart Life and Tuya Smart profiles in typed authentication errors so downstream users can distinguish app rotation from account failures. Document the public origin of the application constants and keep client defaults owned by TuyaPasswordClient. --- README.md | 3 +- tests/test_password_client.py | 51 ++++++++++++++++++++++++++++++++ tests/test_profiles.py | 11 +++++-- tuya_mobile/password_client.py | 53 ++++++++++++++++++++++++---------- tuya_mobile/profiles.py | 10 ++++++- 5 files changed, 109 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 056c391..1ee90dc 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ 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. +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 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 index 60c437f..ebd54f9 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -80,10 +80,16 @@ def test_builtin_profiles_are_immutable_and_redacted() -> None: assert profile.cert_sha256_hex not in rendered -def test_password_client_resolves_a_bundled_application_profile() -> None: +@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( - TuyaMobileApp.SMART_LIFE, + application, Mock(), username="owner@example.com", endpoint="https://example.invalid/api.json", @@ -92,6 +98,7 @@ def test_password_client_resolves_a_bundled_application_profile() -> None: ) 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 diff --git a/tuya_mobile/password_client.py b/tuya_mobile/password_client.py index 5aea0da..92c260c 100644 --- a/tuya_mobile/password_client.py +++ b/tuya_mobile/password_client.py @@ -30,7 +30,11 @@ TuyaMobileAppProfile, TuyaMobileSession, ) -from .profiles import TuyaMobileApp, get_mobile_app_profile +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") @@ -57,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 @@ -87,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 @@ -97,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): @@ -125,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"), @@ -176,18 +200,14 @@ def for_application( session: aiohttp.ClientSession, *, username: str, - endpoint: str | None = None, - request_timeout: float = 20, - max_login_attempts: int = DEFAULT_MAX_LOGIN_ATTEMPTS, + **client_kwargs: Any, ) -> TuyaPasswordClient: """Create a client from an explicitly selected bundled profile.""" return cls( get_mobile_app_profile(application), session, username=username, - endpoint=endpoint, - request_timeout=request_timeout, - max_login_attempts=max_login_attempts, + **client_kwargs, ) def __init__( @@ -238,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}" @@ -301,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" @@ -329,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, @@ -386,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 index dac4b07..79290bd 100644 --- a/tuya_mobile/profiles.py +++ b/tuya_mobile/profiles.py @@ -12,7 +12,10 @@ class TuyaMobileApp(str, Enum): - """Official Tuya mobile applications with bundled profiles.""" + """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" @@ -68,6 +71,11 @@ class TuyaMobileApp(str, Enum): ) +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: