mitchellrj / eufy_robovac

Other
105 stars 52 forks source link

Discover local key from Eufy login details #1

Open mitchellrj opened 5 years ago

mitchellrj commented 5 years ago

The Robovac 30C (and presumably future models) back off onto Tuya for command and control of the devices. The Eufy integration also uses a new (or custom) URL signing mechanism for Tuya cloud APIs. All previous versions of the Tuya cloud API use variations of an MD5 (32-bit) hex digest as the request signing mechanic. The Eufy app uses some other signing mechanism with produces a 64-bit signature (possibly SHA256-based).

Authentication with Tuya itself, and fetching the device key is fairly simple (and worryingly insecure), but without understanding the signing method, we can't emulate requests.

Authenticating with Eufy

This is already well-understood and implemented in google/python-lakeside.

import requests

# hardcoded in the app
CLIENT_ID = 'eufyhome-app'
CLIENT_SECRET = 'GQCpr9dSp3uQpsOMgJ4xQ'
payload = {
        'client_id': CLIENT_ID,
        'client_Secret': CLIENT_SECRET,
        'email': username,
        'password': password
        }
r = requests.post(
    "https://home-api.eufylife.com/v1/user/email/login",
    json=payload)

token = r.json()['access_token']

Fetching the Tuya settings from Eufy

The Eufy APIs serve up some information needed to link to the Tuya API.

headers = {'token': token, 'category': 'Home'}
settings = requests.get(
    "https://home-api.eufylife.com/v1/user/setting",
    headers=headers).json()['setting']
uid = settings['user_id']
home_id = settings['home_setting']['tuya_home']['tuya_home_id']
region_code = settings['home_setting']['tuya_home']['tuya_region_code']

Using those settings to log in to Tuya

Eufy takes its own user ID, formats it to an alternate ID (that it hopes doesn't overlap with an existing Tuya one), and then also encrypts that user ID to make a password. If the user doesn't exist, the Eufy app automatically registers it, and then logs in.

This is horrible security. I hate this. This isn't a bug, this is just terrible design. The only information you need to derive the user login and control their devices is the user ID (a 6-digit number).

from cryptography.hazmat.backends.openssl import backend as openssl_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

# These are hardcoded in the app
TUYA_PASSWORD_KEY = bytearray([
    36, 78, 109, 138, 86, 172, 135, 145,
    36, 67, 45, 139, 108, 188, 162, 196
    ])
TUYA_PASSWORD_IV = bytearray([
    119, 36, 86, 242, 167, 102, 76, 243,
    57, 44, 53, 151, 233, 62, 87, 71
    ])
phone_code = {'EU': '44', 'AY': '86'}.get(region_code, '1')
new_uid = "eh-{}".format(uid)
password_uid = new_uid.zfill(16)

cipher = Cipher(
    algorithms.AES(TUYA_PASSWORD_KEY),
    modes.CBC(TUYA_PASSWORD_IV),
    backend=openssl_backend)

encryptor = cipher.encryptor()
encrypted_uid = encryptor.update(password_uid.encode('utf8'))
encrypted_uid += encryptor.finalize()
password = encrypted_uid.hex()

So you now have a user ID and password. Now we just need to do the login and get the device details. This is where I'm stuck, but given a function get_request_signature, it works something like this...

First, request a login token - this is a public key used to encrypt the login request

login_token = request(
    {'a': 'tuya.m.user.uid.token.create', 'v': '1.0'},
    json.dumps({"countryCode": phone_code, "uid": uid}).encode('utf8'),
    authenticated=False
    )

Then do the login/reg:

from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers

session_detail = None
rsa_data = RSAPublicNumbers(
    token_data['publicKey'], token_data['exponent'])
rsa_key = rsa_data.public_key(openssl_backend)
padding = PKCS1v15()
encrypted_password = rsa_key.encrypt(password.encode('ascii'), padding)
session_detail = request(
    {'a': 'tuya.m.user.uid.password.login.reg', 'v': '1.0'},
    json.dumps({
        'countryCode': phone_code,
        'uid': uid,
        'passwd': encrypted_password,
        'token': token_data['token'],
        'ifencrypt': 1,
        'createGroup': True,
        'options': '{"group":1}',
    }).encode('utf8'),
    authenticated=False)['result']

Then you list the locations (homes):

home_id = request({'a': 'tuya.m.location.list', 'v': '2.1'})['result'][0]['home_id']

And get the devices:

devices = request(
    {'a': 'tuya.m.my.group.device.list', 'v': '1.0'},
    json.dumps({
       'homeId': home_id
    }.encode('utf8'))
)['result']
for d in devices:
    print(d['devId'], d['localKey'])

Here's the utility methods used above for the Tuya part:

import time
import uuid

device_id = uuid.uuid4().hex
BASE_URL = {
        'EU': 'https://a1.tuyaeu.com/api.json',
        'US': 'https://a1.tuyaus.com/api.json',
        'AY': 'https://a1.tuyacn.com/api.json'}.get(region_code)

# Overrides are set as TUYA_SMART_APPKEY & TUYA_SMART_SECRET in the
# app manifest, depending on which territory's app store you download the
# app from.
app_key, app_secret = {
        'EU': ('yx5v9uc3ef9wg3v9atje', 's8x78u7xwymasd9kqa7a73pjhxqsedaj'),
        # This one is hardcoded in the app as a fallback
        'US': ('j7ckwaq3phd8p4gmxe8r', 'mqrguxmvg5cexk3vek5t8cj3d48aw5uk')
}.get(region_code)

def request(request_params, body=None, authenticated=True):
        params = {
            'appVersion': '2.1.3',
            'deviceId': device_id,
            'platform': 'Samsung Galaxy s10',
            'requestId': '',
            'lang': 'en',
            'clientId': app_key,
            'osSystem': '9',
            'os': 'Android',
            'timeZoneId': 'Europe/London',
            'ttid': 'android',
            'et': '0.0.1',
            'sdkVersion': '3.0.0cAnker',
            'time': str(int(time.time())),
        }
        headers = {
            'User-Agent': "TY-UA=APP/Android",
        }
        if authenticated and session_detail:
            params['sid'] = session_detail['sid']
        params.update(request_params)
        params['sign'] = get_request_signature(params, body)
        if body is not None:
            response = requests.post(BASE_URL, params=params, data=body, headers=headers)
        else:
            response = requests.get(BASE_URL, params=params, headers=headers)

        result = response.json()
        return result

If anyone else can figure out the signing part, that will go a long way to resolving this.

Other approaches that haven't worked

  1. The local key doesn't seem to be gettable from the device itself, even during initial pairing with the app.
  2. The other, known cloud signature mechanisms don't work as a fallback.
joshstrange commented 5 years ago

I just got my eufy RoboVac 15C today and was planning on helping out with the efforts to get it to show up in HomeBridge. I assume there is a vast amount of information that can be shared between HomeAssistant/HomeBridge (I know the existing Eufy bulb HomeBridge support came from a porting of what HA uses).

I was wondering how you managed to get your local key. I can grab the device key from the API along with my user id but even emulating Android I can't find the local key (I use iOS but to your point #1 it looks like it makes sense I can't see it in the logs).

EDIT: I just saw your post here and that was exactly what I needed. I had been logcat-ing my emulator but either missed that or needed the -e 'tuya.m.my.group.device.list' to make it work. THANK YOU!

abalakov commented 4 years ago

The Home Assistant Eufy Page shows how to get the local key (access token):

https://www.home-assistant.io/components/eufy/

curl -H "Content-Type: application/json" -d '{"client_id":"eufyhome-app", "client_Secret":"GQCpr9dSp3uQpsOMgJ4xQ", "email":"[replace by your eufy login email", "password":"[replace by your eufy login password]"}' https://home-api.eufylife.com/v1/user/email/login
curl -H token:[replace by access_token from previous command] -H category:Home https://home-api.eufylife.com/v1/device/list/devices-and-groups

"access_token" can be get from the first command "id" is "device":{"id" from 2nd command "type" is "product_code" from 2nd command

mitchellrj commented 4 years ago

@abalakov That is correct for older devices, but does not apply to the 30C line. The local key for these newer devices is not held by the Eufy API.

jimmyeao commented 4 years ago

is there a working implementation for the 30c? Have also had trouble finding the devices IP on my router, not sure what range of mac addresses they are using either

joshstrange commented 4 years ago

@jimmyeao Have you tried using the deviceId instead of the IP? I have it working with my 30C https://github.com/joshstrange/eufy-robovac but ended up not using it as it a little buggy (probably my code) and at the end of the day I just use mine on a schedule or use the Eufy App as it's decent.

jimmyeao commented 4 years ago

Unfortunately I haven’t got an android device to be able to do this. The app works fine, was just a nice to have really :)

Jimmy White


From: Josh Strange notifications@github.com Sent: Monday, August 19, 2019 3:41:54 PM To: mitchellrj/eufy_robovac eufy_robovac@noreply.github.com Cc: Jimmy White jimmy@deviousweb.com; Mention mention@noreply.github.com Subject: Re: [mitchellrj/eufy_robovac] Discover local key from Eufy login details (#1)

@jimmyeaohttps://github.com/jimmyeao Have you tried using the deviceId instead of the IP? I have it working with my 30C https://github.com/joshstrange/eufy-robovac but ended up not using it as it a little buggy (probably my code) and at the end of the day I just use mine on a schedule or use the Eufy App as it's decent.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHubhttps://github.com/mitchellrj/eufy_robovac/issues/1?email_source=notifications&email_token=ABHVAB7SEV7BSI35TZAVZ3LQFKWLFA5CNFSM4HHQTUVKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOD4TFMQI#issuecomment-522606145, or mute the threadhttps://github.com/notifications/unsubscribe-auth/ABHVAB2LID3WCD5T6M7ZCRLQFKWLFANCNFSM4HHQTUVA.

joshstrange commented 4 years ago

@jimmyeao I don't either but I used an android emulator on my mac to get the keys/ids.

jimmyeao commented 4 years ago

Ah, hadn’t thought of that!

Jimmy White


From: Josh Strange notifications@github.com Sent: Monday, August 19, 2019 4:47:48 PM To: mitchellrj/eufy_robovac eufy_robovac@noreply.github.com Cc: Jimmy White jimmy@deviousweb.com; Mention mention@noreply.github.com Subject: Re: [mitchellrj/eufy_robovac] Discover local key from Eufy login details (#1)

@jimmyeaohttps://github.com/jimmyeao I don't either but I used an android emulator on my mac to get the keys/ids.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHubhttps://github.com/mitchellrj/eufy_robovac/issues/1?email_source=notifications&email_token=ABHVABZYETK55DSC7S4NOATQFK6CJA5CNFSM4HHQTUVKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOD4TM3GY#issuecomment-522636699, or mute the threadhttps://github.com/notifications/unsubscribe-auth/ABHVABYLVY46D6QD3NLDIUTQFK6CJANCNFSM4HHQTUVA.

abrahamduran commented 4 years ago

Hey, @joshstrange @mitchellrj is it still accurate to use ADB to get the localKey and the deviceId? I've been struggling to get them.

Running the command will output:

$ adb logcat -e 'tuya.m.my.group.device.list'
--------- beginning of system
--------- beginning of main
resain commented 4 years ago

Hi, @abrahamduran

$ adb shell logcat -e 'tuya.m.my.group.device.list'

Worked for me.

abrahamduran commented 4 years ago

@resain did you use a real device or BlueStacks? 🤔

resain commented 4 years ago

BlueStacks on Windows 10

robbrad commented 4 years ago

My 15c MAX is running 1.1.3 and it no longer puts out the LocalID and DevID - Im hoping https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md can get me that info

@mitchellrj - any luck with the signing process? would be great to get the localkey online

pabsi commented 4 years ago

Figured I'd chip in too. Same as @robbrad I can't get LocalKey nor DevId from logcat when opening the Eufy Home app.

  1. RoboVac 30C firmware:
    • Wifi: 1.1.3
    • MCU: 1.1.4
  2. Eufy app: 2.3.9
  3. adb: 1.0.39

Also tried emptying the storage of the app, and having adb open from a fresh phone reboot. Still nothing.

I am on a Galaxy S7, running Android 7.0

The thing is that I got these 2 values in the past, around the time when this repo was first created but ended up not using them. Now I wanted to give it another try, but they don't show up using ADB.

Happy to help in any way I can should you need extra info @mitchellrj

bmtKIA6 commented 4 years ago

Ok. I was able to get the local_key, by downloading an older apk version of EufyHome (2.3.2).

pabsi commented 4 years ago

+1 for @bmtKIA6 's solution. Worked for me to get the localKey

pabsi commented 4 years ago

Briefly tested my RoboVac 30C (https://github.com/mitchellrj/eufy_robovac/issues/1#issuecomment-599771209) with the recently acquired localKey and both start and back to home commands worked fine. I'll test it more thoroughly later but great work so far!

Thank you so much @mitchellrj and @bmtKIA6 :clap:

pedrof1gueiredo commented 3 years ago

Was anyone able to use this method with a RoboVac 11c? I can sniff the deviceID, but not the localKey. Is that the current name of the string?

I am using BlueStacks on Mac and the EufyHome 2.3.2.

eh8 commented 3 years ago

Not entirely the same issue, but I'm a Home Assistant user who similarly cannot fetch local_code for my Eufy smart bulbs. It looks like something has shifted from their end and we are at a loss on where the new endpoint might be.

I used the official guide with auto-discovery turned on and off.

jimmyeao commented 3 years ago

Does anyone have this working for a 30c that can share the config yaml settings?

mitchellrj commented 3 years ago

Sorry everyone, my 30C has now broken and I won't be replacing it any time soon. I can't help with resolving this issue at this point.

mitchellrj commented 3 years ago

Happy to update documentation or accept fixes though if people find them.

pabsi commented 3 years ago

Does anyone have this working for a 30c that can share the config yaml settings?

@jimmyeao I do. I'll share the code as soon as I have my hass setup back online (just moved houses and I have all my stuff in boxes)

pabsi commented 3 years ago

@jimmyeao

My config card: https://gist.github.com/pabsi/d5a9d4211a4c0da5bb88c89a0311dd0d

image

Had edge mode cleaning by doing this: https://github.com/home-assistant/core/pull/25483

I don't have this vacuum anymore but happy to help in any way I can.

Regards,

lorenzofattori commented 3 years ago

found an easier waty to get the token, thanks to an old version of the app:

https://github.com/lorenzofattori/robovac30c_homeassistant hope to save some time to someone else :)

Rjevski commented 2 years ago

I've spent the past few hours implementing API clients for both the Eufy API as well as the Tuya API, complete with the request signature hash, copious amounts of MD5 hashing (were the original developers paid per amount of MD5() calls?) and cargo-cult, poorly-implemented cryptography.

The file below can be ran as a script and takes Eufy Home credentials - email and password as positional command-line arguments. You'll need to pip install requests cryptography beforehand. It'll print out all devices detected on the underlying Tuya account including device ID and local key.

The clients can of course be used to also send commands to the Tuya cloud and obtain cloud control of the Robovac (or other devices) if desired.

License is public domain. I'd love for this to be integrated into Home Assistant at some point with an easy GUI-based set up process that just takes Eufy credentials and autodiscovers every device on there. I may clean it up later and restructure the code, put it in a dedicated repo, etc but in the meantime I figured I'd post it here if anyone finds this useful.

Enjoy!

Edit: the code is now on GitLab.

cvbraeuer commented 2 years ago

I've spent the past few hours implementing API clients for both the Eufy API as well as the Tuya API, complete with the request signature hash, copious amounts of MD5 hashing (were the original developers paid per amount of MD5() calls?) and cargo-cult, poorly-implemented cryptography.

The file below can be ran as a script and takes Eufy Home credentials - email and password as positional command-line arguments. You'll need to pip install requests cryptography beforehand. It'll print out all devices detected on the underlying Tuya account including device ID and local key.

The clients can of course be used to also send commands to the Tuya cloud and obtain cloud control of the Robovac (or other devices) if desired.

License is public domain. I'd love for this to be integrated into Home Assistant at some point with an easy GUI-based set up process that just takes Eufy credentials and autodiscovers every device on there. I may clean it up later and restructure the code, put it in a dedicated repo, etc but in the meantime I figured I'd post it here if anyone finds this useful.

Enjoy!

import hmac
import json
import math
import random
import string
import sys
import time
import uuid
from hashlib import md5, sha256
from urllib.parse import urljoin

import requests
from cryptography.hazmat.backends.openssl import backend as openssl_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

def unpadded_rsa(key_exponent: int, key_n: int, plaintext: bytes) -> bytes:
    # RSA with no padding, as per https://github.com/pyca/cryptography/issues/2735#issuecomment-276356841
    keylength = math.ceil(key_n.bit_length() / 8)
    input_nr = int.from_bytes(plaintext, byteorder="big")
    crypted_nr = pow(input_nr, key_exponent, key_n)
    return crypted_nr.to_bytes(keylength, byteorder="big")

# from Android Eufy App
EUFY_CLIENT_ID = "eufyhome-app"
EUFY_CLIENT_SECRET = "GQCpr9dSp3uQpsOMgJ4xQ"

# from capturing traffic
EUFY_BASE_URL = "https://home-api.eufylife.com/v1/"

PLATFORM = "sdk_gphone64_arm64"
LANGUAGE = "en"
TIMEZONE = "Europe/London"

DEFAULT_EUFY_HEADERS = {
    "User-Agent": "EufyHome-Android-2.4.0",
    "timezone": TIMEZONE,
    "category": "Home",
    # the original app has a bug/oversight where sends these as blank on the initial request
    # we replicate that to avoid detection as much as possible
    # when we login these will be overridden by the real values
    "token": "",
    "uid": "",
    "openudid": PLATFORM,
    "clientType": "2",
    "language": LANGUAGE,
    "country": "US",
    "Accept-Encoding": "gzip",
}

class EufyHomeSession:
    base_url = None
    email = None
    password = None

    def __init__(self, email, password):
        self.session = requests.session()
        self.session.headers = DEFAULT_EUFY_HEADERS.copy()
        self.base_url = EUFY_BASE_URL
        self.email = email
        self.password = password

    def url(self, url):
        return urljoin(self.base_url, url)

    def login(self, email, password):
        resp = self.session.post(
            self.url("user/email/login"),
            json={
                "client_Secret": EUFY_CLIENT_SECRET,
                "client_id": EUFY_CLIENT_ID,
                "email": email,
                "password": password,
            },
        )

        resp.raise_for_status()
        data = resp.json()

        access_token = data["access_token"]
        user_id = data["user_info"]["id"]
        new_base_url = data["user_info"]["request_host"]

        self.session.headers["uid"] = user_id
        self.session.headers["token"] = access_token
        self.base_url = new_base_url

    def _request(self, *args, **kwargs):
        if not self.session.headers["token"] or not self.session.headers["uid"]:
            self.login(self.email, self.password)

        resp = self.session.request(*args, **kwargs)
        resp.raise_for_status()

        return resp.json()

    def get_devices(self):
        # Not actually needed; Eufy uses a single Tuya account per user so we can lookup the devices there
        # however you may still want to consult this for human-readable product names
        # as Tuya only returns generic product IDs and presumably has no knowledge of user-facing names
        return self._request("GET", self.url("device/v2")).get("devices", [])

    def get_user_info(self):
        return self._request("GET", self.url("user/info"))["user_info"]

# from Eufy Home Android app
TUYA_CLIENT_ID = "yx5v9uc3ef9wg3v9atje"

DEFAULT_TUYA_QUERY_PARAMS = {
    "appVersion": "2.4.0",
    "deviceId": "",
    "platform": PLATFORM,
    "clientId": TUYA_CLIENT_ID,
    "lang": LANGUAGE,
    "osSystem": "12",
    "os": "Android",
    "timeZoneId": TIMEZONE,
    "ttid": "android",
    "et": "0.0.1",
    "sdkVersion": "3.0.8cAnker",
}

# that for other regions you may need to change this?
# TODO: is this somehow returned by the Eufy API, and if so, can we set this automatically?
TUYA_ENDPOINT = "https://a1.tuyaeu.com/api.json"

DEFAULT_TUYA_HEADERS = {"User-Agent": "TY-UA=APP/Android/2.4.0/SDK/null"}

# from decompiling the Android app
SIGNATURE_RELEVANT_PARAMETERS = {
    "a",
    "v",
    "lat",
    "lon",
    "lang",
    "deviceId",
    "appVersion",
    "ttid",
    "isH5",
    "h5Token",
    "os",
    "clientId",
    "postData",
    "time",
    "requestId",
    "et",
    "n4h5",
    "sid",
    "sp",
}

def shuffled_md5(value: str) -> str:
    # shuffling the hash reminds me of https://security.stackexchange.com/a/25588
    # from https://github.com/TuyaAPI/cloud/blob/9b108f4d347c81c3fd6d73f3a2bb08a646a2f6e1/index.js#L99
    _hash = md5(value.encode("utf-8")).hexdigest()
    return _hash[8:16] + _hash[0:8] + _hash[24:32] + _hash[16:24]

# Eufy Home "TUYA_SMART_SECRET" Android app metadata value
APPSECRET = "s8x78u7xwymasd9kqa7a73pjhxqsedaj"

# obtained using instructions at https://github.com/nalajcie/tuya-sign-hacking
BMP_SECRET = "cepev5pfnhua4dkqkdpmnrdxx378mpjr"

# turns out this is not used by the Eufy app but FYI this value is from the Eufy Home app in case it's useful
# APP_CERT_HASH = "A4:0D:A8:0A:59:D1:70:CA:A9:50:CF:15:C1:8C:45:4D:47:A3:9B:26:98:9D:8B:64:0E:CD:74:5B:A7:1B:F5:DC"

# hmac_key = f'{APP_CERT_HASH}_{BMP_SECRET}_{APPSECRET}'.encode('utf-8')
# turns out this app just uses "A" instead of the app's certificate hash
EUFY_HMAC_KEY = f"A_{BMP_SECRET}_{APPSECRET}".encode("utf-8")

# from https://github.com/mitchellrj/eufy_robovac/issues/1
TUYA_PASSWORD_KEY = bytearray(
    [36, 78, 109, 138, 86, 172, 135, 145, 36, 67, 45, 139, 108, 188, 162, 196]
)
TUYA_PASSWORD_IV = bytearray(
    [119, 36, 86, 242, 167, 102, 76, 243, 57, 44, 53, 151, 233, 62, 87, 71]
)

TUYA_PASSWORD_INNER_CIPHER = Cipher(
    algorithms.AES(TUYA_PASSWORD_KEY),
    modes.CBC(TUYA_PASSWORD_IV),
    backend=openssl_backend,
)

class TuyaAPISession:
    username = None
    country_code = None
    session_id = None

    def __init__(self, username, country_code):
        self.session = requests.session()
        self.session.headers = DEFAULT_TUYA_HEADERS.copy()
        self.default_query_params = DEFAULT_TUYA_QUERY_PARAMS.copy()
        self.default_query_params[
            "deviceId"
        ] = self.device_id = self.generate_new_device_id()

        self.username = username
        self.country_code = country_code

    @staticmethod
    def generate_new_device_id():
        """
        In the Eufy Android app this is generated as follows:
    private static String getRemoteDeviceID(Context paramContext) {
        StringBuilder stringBuilder1 = new StringBuilder();
        stringBuilder1.append(Build.BRAND);
        stringBuilder1.append(Build.MODEL);
        String str1 = MD5Util.md5AsBase64(stringBuilder1.toString());
        StringBuilder stringBuilder2 = new StringBuilder();
        stringBuilder2.append(getAndroidId(paramContext));
        stringBuilder2.append(getSerialNum());
        String str2 = MD5Util.md5AsBase64(stringBuilder2.toString());
        StringBuilder stringBuilder3 = new StringBuilder();
        stringBuilder3.append(getImei(paramContext));
        stringBuilder3.append(getImsi(paramContext));
        String str3 = MD5Util.md5AsBase64(stringBuilder3.toString());
        StringBuilder stringBuilder4 = new StringBuilder();
        stringBuilder4.append(str1.substring(4, 16));
        stringBuilder4.append(str2.substring(8, 24));
        stringBuilder4.append(str3.substring(16));
        return stringBuilder4.toString();
      }
    ```

    In short, we should be able to get away with a random 44-char string,
    though the first 12 characters of the resulting value are sourced from "str1" which
    is a hash of the device's brand & model, and as such could be predictable and may need to be faked
    to a popular device's make/model to make detection & blocking harder (as they'd block legitimate devices too).
    """
    expected_length = 44
    base64_characters = (
        string.ascii_letters + string.digits
    )  # TODO: is this correct?

    device_id_dependent_part = (
        "8534c8ec0ed0"  # from Google Pixel in an Android Virtual Device
    )
    return device_id_dependent_part + "".join(
        (
            random.choice(base64_characters)
            for _ in range(expected_length - len(device_id_dependent_part))
        )
    )

@staticmethod
def encode_post_data(data: dict) -> str:
    # Note: the rest of the code relies on empty dicts being encoded as a blank string as opposed to "{}"
    return json.dumps(data, separators=(",", ":")) if data else ""

@staticmethod
def get_signature(query_params: dict, encoded_post_data: str):
    """
    Tuya's proprietary request signature algorithm.

    This relies on HMAC'ing specific query parameters (defined in SIGNATURE_RELEVANT_PARAMETERS)
    as well as the data, if any. If data is present, it is included in the parameters to be hashed
    as "postData" and then MD5 & the resulting hash shuffled, where as query parameter values
    are passed through as-is.

    The HMAC key is derived from the app secret value "TUYA_SMART_SECRET" in the Android app's metadata
    as well as a value obfuscated in an innocent-looking bitmap image in the app's assets. There is also
    provision for the key to include the app's signing certificate, but in the Eufy build it just defaults to "A".
    """
    query_params = query_params.copy()

    if encoded_post_data:
        query_params["postData"] = encoded_post_data

    sorted_pairs = sorted(query_params.items())
    filtered_pairs = filter(
        lambda p: p[0] and p[0] in SIGNATURE_RELEVANT_PARAMETERS, sorted_pairs
    )
    mapped_pairs = map(
        # postData is pre-emptively hashed (for performance reasons?), everything else is included as-is
        lambda p: p[0] + "=" + (shuffled_md5(p[1]) if p[0] == "postData" else p[1]),
        filtered_pairs,
    )

    message = "||".join(mapped_pairs)
    return hmac.HMAC(
        key=EUFY_HMAC_KEY, msg=message.encode("utf-8"), digestmod=sha256
    ).hexdigest()

def _request(
    self,
    action: str,
    version="1.0",
    data: dict = None,
    query_params: dict = None,
    _requires_session=True,
):
    if not self.session_id and _requires_session:
        self.acquire_session()

    current_time = time.time()
    request_id = uuid.uuid4()

    extra_query_params = {
        "time": str(int(current_time)),
        "requestId": str(request_id),
        "a": action,
        "v": version,
        **(query_params or {}),
    }

    query_params = {**self.default_query_params, **extra_query_params}
    encoded_post_data = self.encode_post_data(data)

    resp = self.session.post(
        TUYA_ENDPOINT,
        params={
            **query_params,
            "sign": self.get_signature(query_params, encoded_post_data),
        },
        # why do they send JSON as a single form-encoded value instead of just putting it directly in the body?
        # they spent more time implementing the stupid request signature system than actually designing a good API
        data={"postData": encoded_post_data} if encoded_post_data else None,
    )

    resp.raise_for_status()
    data = resp.json()

    return data["result"]

def request_token(self, username, country_code):
    return self._request(
        action="tuya.m.user.uid.token.create",
        data={"uid": username, "countryCode": country_code},
        _requires_session=False,
    )

def determine_password(self, username: str):
    new_uid = username
    password_uid = new_uid.zfill(16)

    encryptor = TUYA_PASSWORD_INNER_CIPHER.encryptor()
    encrypted_uid = encryptor.update(password_uid.encode("utf8"))
    encrypted_uid += encryptor.finalize()

    # from looking into the Android app the password now appears to be MD5-hashed
    return md5(encrypted_uid.hex().upper().encode("utf-8")).hexdigest()

def request_session(self, username, country_code):
    password = self.determine_password(username)
    token_response = self.request_token(username, country_code)

    encrypted_password = unpadded_rsa(
        key_exponent=int(token_response["exponent"]),
        key_n=int(token_response["publicKey"]),
        plaintext=password.encode("utf-8"),
    )

    data = {
        "uid": username,
        "createGroup": True,
        "ifencrypt": 1,
        "passwd": encrypted_password.hex(),
        "countryCode": country_code,
        "options": '{"group": 1}',
        "token": token_response["token"],
    }

    session_response = self._request(
        action="tuya.m.user.uid.password.login.reg",
        data=data,
        _requires_session=False,
    )

    return session_response

def acquire_session(self):
    session_response = self.request_session(self.username, self.country_code)
    self.session_id = self.default_query_params["sid"] = session_response["sid"]

def list_homes(self):
    return self._request(action="tuya.m.location.list", version="2.1")

def list_devices(self, home_id: str):
    return self._request(
        action="tuya.m.my.group.device.list",
        version="1.0",
        query_params={"gid": home_id},
    )

if name == "main": eufy_client = EufyHomeSession(email=sys.argv[1], password=sys.argv[2]) user_info = eufy_client.get_user_info()

tuya_client = TuyaAPISession(
    username=f'eh-{user_info["id"]}', country_code=user_info["phone_code"]
)

for home in tuya_client.list_homes():
    print("Home:", home["groupId"])

    for device in tuya_client.list_devices(home["groupId"]):
        print(
            f"Device: {device['name']}, device ID {device['devId']}, local key {device['localKey']}"
        )

code reviewed and can confirm functionality. Andre, this is worth a separate repository!

Rjevski commented 2 years ago

@cvbraeuer I’ve refactored it a bit and put it on GitLab: https://gitlab.com/Rjevski/eufy-device-id-and-local-key-grabber

cvbraeuer commented 2 years ago

@Rjevski excellent, thank you for your hard work!

resain commented 2 years ago

Cloned the repository today, and can also confirm this works out of the box. Very useful tool. Thanks

lvcabral commented 2 years ago

Hi guys, I'm trying to use the code @Rjevski published, but getting an issue (see traceback below), it is something related to the fact the user_id that I get has 40 characters and with 'eh-' becomes 43, that is not multiple of 16. I trucated to 16 and the error does not happen, but of course the authentication fails.

Traceback (most recent call last):
  File "/usr/lib/python3.8/runpy.py", line 194, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/usr/lib/python3.8/runpy.py", line 87, in _run_code
    exec(code, run_globals)
  File "/home/lvcabral/projects/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/__main__.py", line 10, in <module>
    for home in tuya_client.list_homes():
  File "/home/lvcabral/projects/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/clients.py", line 309, in list_homes
    return self._request(action="tuya.m.location.list", version="2.1")
  File "/home/lvcabral/projects/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/clients.py", line 231, in _request
    self.acquire_session()
  File "/home/lvcabral/projects/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/clients.py", line 305, in acquire_session
    session_response = self.request_session(self.username, self.country_code)
  File "/home/lvcabral/projects/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/clients.py", line 279, in request_session
    password = self.determine_password(username)
  File "/home/lvcabral/projects/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/clients.py", line 273, in determine_password
    encrypted_uid += encryptor.finalize()
  File "/home/lvcabral/.local/lib/python3.8/site-packages/cryptography/hazmat/primitives/ciphers/base.py", line 140, in finalize
    data = self._ctx.finalize()
  File "/home/lvcabral/.local/lib/python3.8/site-packages/cryptography/hazmat/backends/openssl/ciphers.py", line 215, in finalize
    raise ValueError(
ValueError: The length of the provided data is not a multiple of the block length.
Rjevski commented 2 years ago

@lvcabral Does this work with the old version of the Eufy Android app someone recommended above? My reverse-engineering was based on it, I wonder if your account uses a newer auth mechanism that just wouldn't be supported by that app to begin with and would explain why my code doesn't work either.

lvcabral commented 2 years ago

I guess they changed something on the backend, I can login to the old app, but the device always shows as offline, in the same Android tablet, if I upgrade to the current app version, the device correctly shows as online. I managed to add the vacuum robot to the Tuya Smart app (and get the local key on their iot portal) but in the app the robot control panel is partially in Chinese and not easy to control (seems to me Eufy customized their app and left the default device panel on Tuya as the development template).

Rjevski commented 2 years ago

Just wondering, how did you add the vacuum to the Tuya app? If there's a way to do that (and then use the Tuya developer portal to control the device and/or get the local key) then it's probably better for everyone involved to just do that rather than reverse-engineering the Eufy app and keeping up with their updates.

lvcabral commented 2 years ago

It works with both Tuya Smart and Smart Life, and integrates with Home Assistant, the trade off is that the app panel, compared to Eufy one, is really bad (with Chinese options) and the Amazon Alexa integration is not enabled (probably Eufy did it via their own backend) so I would prefer to have it on Eufy app. Will keep investigating.

lvcabral commented 2 years ago

I have managed to use the old method (using the 2.4.0 APK) by creating a new account (and pairing the robot) using the old app, I suspect the creation of an account using newer apps is using a different type of ID and switching something on the backend that the old app (2.4.0) does not recognize. I created a new account with a different e-mail and it showed the local key. After that I managed to login on my iPhone with the latest app and it works. So I do recommend to keep the old app on an android device or VM.

apexad commented 2 years ago

@cvbraeuer I’ve refactored it a bit and put it on GitLab: https://gitlab.com/Rjevski/eufy-device-id-and-local-key-grabber

So, btw, your script gave me the following error:

Traceback (most recent call last):
  File "/usr/local/Cellar/python@3.9/3.9.10/Frameworks/Python.framework/Versions/3.9/lib/python3.9/runpy.py", line 197, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/usr/local/Cellar/python@3.9/3.9.10/Frameworks/Python.framework/Versions/3.9/lib/python3.9/runpy.py", line 87, in _run_code
    exec(code, run_globals)
  File "/Users/alex.martin/git/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/__main__.py", line 10, in <module>
    for home in tuya_client.list_homes():
  File "/Users/alex.martin/git/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/clients.py", line 311, in list_homes
    return self._request(action="tuya.m.location.list", version="2.1")
  File "/Users/alex.martin/git/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/clients.py", line 260, in _request
    return data["result"]
KeyError: 'result'

after a bit of troubleshooting I noticed it was this error:

{'t': 1645166510522, 'success': False, 'errorCode': 'USER_SESSION_INVALID', 'status': 'error', 'errorMsg': 'Session has expired, please log in again'}

Printing the results of all requests I noticed in the prior request, the following was part of it

{
// removed some
'mobileApiUrl': 'https://a1.tuyaus.com',
// removed some
'timezoneId': 'America/Phoenix'
// removed some
}

so a quick change in constants.py:

-TIMEZONE = "Europe/London"
+TIMEZONE = "America/Phoenix"

 # from Eufy Home Android app
@@ -16,7 +16,7 @@ TUYA_CLIENT_ID = "yx5v9uc3ef9wg3v9atje"

 # for other regions you may need to change this?
 # TODO: is this somehow returned by the Eufy API, and if so, can we set this automatically?
-TUYA_ENDPOINT = "https://a1.tuyaeu.com/api.json"
+TUYA_ENDPOINT = "https://a1.tuyaus.com/api.json"

..and boom! it worked.

Device: Red Robot, device ID <hidden>, local key <hidden>

...so you probably should update your script to automatically set those values correctly ('registered_region' is sent early on by Eufy Home session setup and then the values that corresponds to were in the first post of this issue).

Otherwise, just giving a heads up that I'll be attempting to re-write your code into NodeJS so I can implement it into a re-write of https://github.com/apexad/homebridge-eufy-robovac

Rjevski commented 2 years ago

Feel free to improve and/or fork it. Unfortunately I don't have the time to work on this further but glad to hear the community is still getting some use out of it.

On 18 Feb 2022, at 07:04, Alex Martin @.***> wrote:

@cvbraeuer I’ve refactored it a bit and put it on GitLab: https://gitlab.com/Rjevski/eufy-device-id-and-local-key-grabber

So, btw, your script gave me the following error:

Traceback (most recent call last): File @.***/3.9.10/Frameworks/Python.framework/Versions/3.9/lib/python3.9/runpy.py", line 197, in _run_module_as_main

return _run_code(code, main_globals, None, File @.***/3.9.10/Frameworks/Python.framework/Versions/3.9/lib/python3.9/runpy.py", line 87, in _run_code exec(code, run_globals) File "/Users/alex.martin/git/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/main.py", line 10, in

for home in tuya_client.list_homes (): File "/Users/alex.martin/git/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/clients.py", line 311, in list_homes

return self._request(action="tuya.m.location.list", version="2.1" ) File "/Users/alex.martin/git/eufy-device-id-and-local-key-grabber/eufy_local_id_grabber/clients.py", line 260, in _request

return data["result" ] KeyError: 'result' after a bit of troubleshooting I noticed it was this error:

{'t': 1645166510522, 'success': False, 'errorCode': 'USER_SESSION_INVALID', 'status': 'error', 'errorMsg': 'Session has expired, please log in again'} Printing the results of all requests I noticed in the prior request, the following was part of it

{ // removed some 'mobileApiUrl': 'https://a1.tuyaus.com', // removed some 'timezoneId': 'America/Phoenix' // removed some } so a quick change in constants.py:

-TIMEZONE = "Europe/London" +TIMEZONE = "America/Phoenix"

from Eufy Home Android app

@@ -16,7 +16,7 @@ TUYA_CLIENT_ID = "yx5v9uc3ef9wg3v9atje"

for other regions you may need to change this?

TODO: is this somehow returned by the Eufy API, and if so, can we set this automatically?

-TUYA_ENDPOINT = "https://a1.tuyaeu.com/api.json" +TUYA_ENDPOINT = "https://a1.tuyaus.com/api.json" ..and boom! it worked.

Device: Red Robot, device ID , local key ...so you probably should update your script to automatically set those values correctly.

Otherwise, just giving a heads up that I'll be attempting to re-write your code into NodeJS so I can implement it into a re-write of https://github.com/apexad/homebridge-eufy-robovac

— Reply to this email directly, view it on GitHub, or unsubscribe. Triage notifications on the go with GitHub Mobile for iOS or Android. You are receiving this because you were mentioned.

ravredd commented 2 years ago

I used this tool and got deviceid and local key. Can this just be added? Sorry didn't have time to read all the issues out there on this. https://gitlab.com/Rjevski/eufy-device-id-and-local-key-grabber

danielgomezrico commented 10 months ago

This repository is down :( anyone have a fork of it?

Rjevski commented 10 months ago

See https://github.com/Rjevski/eufy-clean-local-key-grabber

Sent from my iPhone

On 10 Aug 2023, at 20:00, Daniel Gomez @.***> wrote:



This repository is down :( anyone have a fork of it?

— Reply to this email directly, view it on GitHubhttps://github.com/mitchellrj/eufy_robovac/issues/1#issuecomment-1673667285, or unsubscribehttps://github.com/notifications/unsubscribe-auth/AK4C6PURPRUNDTXC6EYUEXDXUUOULANCNFSM4HHQTUVA. You are receiving this because you were mentioned.Message ID: @.***>