Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -24,19 +24,39 @@ 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
secondary client such as a local bridge.
- **`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

Expand All @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions tests/test_password_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
TuyaDeviceCredentials,
TuyaMobileAccountLocked,
TuyaMobileApiError,
TuyaMobileApp,
TuyaMobileAppProfile,
TuyaMobileCaptchaRequired,
TuyaMobileDeviceNotFound,
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
110 changes: 110 additions & 0 deletions tests/test_profiles.py
Original file line number Diff line number Diff line change
@@ -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")
11 changes: 8 additions & 3 deletions tuya_mobile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand All @@ -44,7 +47,9 @@
"TuyaMobileClient",
"canonical_string",
"TuyaPasswordClient",
"TuyaMobileApp",
"TuyaMobileAppProfile",
"get_mobile_app_profile",
"TuyaMobileSession",
"TuyaDeviceCredentials",
"TuyaMobileError",
Expand Down
Loading