JurajNyiri / pytapo

Python library for communication with Tapo Cameras
MIT License
292 stars 60 forks source link

Add provisioning methods #24

Open majkrzak opened 2 years ago

majkrzak commented 2 years ago

I'm currently struggling with process of provisioning the Tapo C100 camera.

When camera is in factory state it sets the unprotected AP named Tapo_Camera_XXXX where XXXX is some hex value. After connecting the dhcp server on camera assign the 192.168.191.100 ip address, while reporting the gateway to be 192.168.191.1.

I assume it hosts the same API which is partially implemented in this library, as when querying the https server on this address it returns very similar responses.

Calling the login no matter the params, returns some stok result:

~> curl -d '{"method": "login", "params":{}}' https://192.168.191.1/ -k
{"error_code": 0, "result" : { "stok": "829dd315d803fee540f127ad0e86f54c", "user_group": "root"}}⏎ 

I found out that there should be something like onboarding module, witch scan, connect and get_connect_status.

By comparing this to other methods implemented in this library, I assume it should be called somehow like:

~> curl -d '{"method": "do", "onboarding":{"scan":{}}}' https://192.168.191.1/stok=829dd315d803fee540f127ad0e86f54c/ds -k
{ "error_code": -40106 }⏎ 

Sadly this seems not to work.

Sadly I got stuck at this point, If you got any suggestions or question please ask.

I'll try to push it somehow forward and extend this library with provisioning methods.

JurajNyiri commented 1 year ago

Understanding error code

Correct call:

return self.performRequest(
            {"method": "get", "device_info": {"name": ["basic_info"]}}
        )

-40106 returned for both:

return self.performRequest(
            {"method": "get", "device_infoBAD": {"name": ["basic_info"]}}
        )
return self.performRequest(
            {"method": "get", "device_info": {"name": ["basic_infoBAD"]}}
        )

Following depau docs

Following https://md.depau.eu/s/r1Ys_oWoP#All-command-types and above structure:

self.performRequest(
                {"method": "get", "on_boarding": {"name": ["get_connect_status"]}}
            )

should work but it still returns -40106. I did test this on already provisioned camera though. Same happens with

self.performRequest(
                {"method": "get", "hard_disk_manage": {"name": ["hard_disk_info"]}}
            )

But for example below works:

print(self.performRequest({"method": "get", "system": {"name": ["sys"]}}))
print(self.performRequest({"method": "get", "system": {"name": ["basic"]}}))

BUT, user_id no longer works and returns 40106.

print(self.performRequest({"method": "get", "system": {"name": ["user_id"]}}))
liamjack commented 1 year ago

@majkrzak Here's a bit of documentation that may help, it required quite a bit of reverse engineering of the Android App to fully understand that authentication is not required, but it's now possible (with a few unauthenticated API requests) to provision a camera without going through the Tapo app !

Onboarding

In this mode, the camera's LED will alternate between green and red.

The camera creates an unsecured WiFi network such as Tapo_Cam_87D1 (87D1 being the last 4 characters of the camera's MAC address)

Once connected to this WiFi network, the camera provides a dynamic IP in the 192.168.191.0/24 subnet and the camera is accessible via 192.168.191.1

Note: It is possible to log in without any parameters or using admin:admin, but it doesn't really matter since authentication is not required for the onboarding requests.

Scan WiFi networks

Request

POST https://192.168.191.1/

{
    "method": "scanApList",
    "params": {
        "onboarding": {
            "scan": "null"
        }
    }
}

Response

{
  "error_code": 0,
  "result": {
    "onboarding": {
      "scan": {
        "wpa3_supported": "false",
        "ap_list": [
          {
            "ssid": "IOT-ROUTEUR-5246",
            "bssid": "A5-6E-51-61-54-53",
            "auth": 3,
            "encryption": 2,
            "rssi": 2
          },
          {
            "ssid": "SFR WiFi Mobile",
            "bssid": "72-CE-7D-D9-D8-8D",
            "auth": 5,
            "encryption": 2,
            "rssi": 0
          },
          {
            "ssid": "SFR WiFi FON",
            "bssid": "72-CE-7D-D9-D8-8F",
            "auth": 0,
            "encryption": 0,
            "rssi": 0
          },
          {
            "ssid": "FreeWifi_secure",
            "bssid": "00-24-D4-58-CB-45",
            "auth": 5,
            "encryption": 3,
            "rssi": 0
          }
        ]
      }
    }
  }
}

Connect to a WiFi network

Request

POST https://192.168.191.1/

{
    "method": "connectAp",
    "params": {
        "onboarding": {
            "connect": {
                "ssid": "IOT-ROUTEUR-5246",
                "bssid": "A5-6E-51-61-54-53",
                "auth": 3,
                "encryption": 2,
                "rssi": 2,
                "password": "JfHLxfTdB+evKq5Pvlcuth2MgVlpw2Z++yEG/O0mk/Otcydno5ujFump0NNa+/dxOiN6D9m6JvpkRhqVY9VafrTkTIOFLesVwRbnzgczl90yS9gjjlyevkCrhIdM+x8OHERMB/NSeZUB6hLLY3IGwcAjVi3Ort13fxV/LovZ1qs="
            }
        }
    }
}

You may be wondering where that password comes from, it's encrypted with a 1024 bit RSA public key found in the decompiled Tapo APK (represented as Java RSAPublicKeySpec):

MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC4D6i0oD/Ga5qb//RfSe8MrPVI
rMIGecCxkcGWGj9kxxk74qQNq8XUuXoy2PczQ30BpiRHrlkbtBEPeWLpq85tfubT
UjhBz1NPNvWrC88uaYVGvzNpgzZOqDC35961uPTuvdUa8vztcUQjEZy16WbmetRj
URFIiWJgFCmemyYVbQIDAQAB

So using a free online tool (such as https://www.devglan.com/online-tools/rsa-encryption-decryption) you should be able to encrypt for example MyPassword and obtain JcRz1lFfpeaa2FPo+tcCVZ6jtpEiLiXCdrb6vk85UXUEfUuVS7Zbh6PzDiWUtvCEMGZm7UCfIpYptnPT5y5jRsKr+C3v8t+jDfGwzW3+1DcnP+Jd/eo1Omfk+tkV0MlLKV+ur16/rzbWfAwDqGnEpwWQJ/u4T8VSJjyZGM+xt3o=

Response

{
  "error_code": 0,
  "result": {
    "onboarding": {
      "connect": {
        "connect_time": 60000,
        "mac": "1c-61-b4-d5-87-d1",
        "support_ap": "true"
      }
    }
  }
}

Get connect status

Request

POST https://192.168.191.1/

{
    "method": "getConnectStatus",
    "params": {
        "onboarding": {
            "get_connect_status": "null"
        }
    }
}

Response

{
  "error_code": 0,
  "result": {
    "onboarding": {
      "get_connect_status": {
        "status": 0
      }
    }
  }
}

Note

Once onboarding is performed and the camera is connected to the desired WiFi network, the onboarding methods will be unavailable and return the error code -40101.

Once the camera is configured, the only way to change WiFi network appears to require a hard reset, returning the camera to factory settings and back into onboarding mode.

ahoy commented 1 year ago

I've been able to get the onboarding functions to work, and the cam connects to wifi. In this mode, you can also set the "third user" and admin/cloud passwords. There seems to be another message that gets sent to the camera that gets it out of "onboarding" mode, but I haven't been able to figure it out. Knowing this, we should be able to get these cameras provisioned without the app.

jp-0 commented 1 year ago

Hi @ahoy

Sorry, what do you mean by getting it out of onboarding mode?

Does the device not exit onboarding once connectAP is successful?

The following are the steps I have used to set up the device for RTSP streaming without requiring the app or internet, listing the method names for brevity, but let me know if you need additional detail on the params and I can provide those:

This is not the minimal set of steps (e.g. setRecordPlan can be ignored), but at this stage I can then connect via VLC using the third account details I set in the last step.

majkrzak commented 1 year ago

@jp-0 providing examples will be very useful. Currently, with commands from @liamjack I managed to force camera to connect my wifi, I'm able to connect to it with pytapo regardless credentials I provide, but then trying to use the Home assistant Tapo integration or the onvif it fails. I guess, I'm missing the changeAdminPassword or changeThirdAccount part.

jp-0 commented 1 year ago

hey @majkrzak, see below some detail on the two requests you mentioned.

changeThirdAccount can also be used at a later stage to update the password but after it is initially set, it will require an additional authentication at which point the steps then should be verifyThirdAccount before changeThirdAccount. The first time only changeThirdAccount is required. The below are all POST to /{stok}/ds (e.g. /829dd315d803fee540f127ad0e86f54c/ds)


"changeAdminPassword"
"user_management": {
    "change_admin_password": {
        "secname": "root",
        "passwd": password_md5,  # uppercase of the md5 hash of your password
        "old_passwd": password,
        "ciphertext": password_rsa,  # using the same public key extracted from the app, as mentioned in another comment
        "username": "admin"
    }
}
"changeThirdAccount"
"user_management": {
    "change_third_account": {
    "secname": "third_account",
        "passwd": new_third_account_password_md5,  # uppercase of the md5 hash of your password
        "old_passwd": "",
        "ciphertext": new_third_account_password_rsa,  # using the same public key extracted from the app, as mentioned in another comment
        "username": new_username,
    }
}
"verifyThirdAccount"
"user_management": {
    "verify_third_account": {
        "secname": "third_account",
        "passwd": current_third_account_password_md5,  # uppercase of the md5 hash of your password
        "old_passwd": "",
        "ciphertext": current_third_account_password_rsa,  # using the same public key extracted from the app, as mentioned in another comment
        "username": current_username,
    }
}

It may be you need to go via the multipleRequest, I've noticed sometimes a portion of the commands fail otherwise - although not tested that fully.

{"method": "multipleRequest", "params": {"requests": [{"method": method, "params": params}]}}
jaryl commented 11 months ago

@jp-0 @ahoy I've already successfully run connectAp but I am having difficulty running changeAdminPassword, with this request being sent:

{ 
    "method": "changeAdminPassword",
    "params": {
        "user_management": {
            "change_admin_password": {
                "secname": "root",
                "passwd": "...",
                "old_passwd": "slprealtek",
                "ciphertext": "...",
                "username": "admin"
            }
        }
    }
}

I am getting these response:

{
  "error_code": -40401,
  "result": {
    "data": {
      "code": -40405,
      "encrypt_type": [
        "1",
        "2"
      ],
      "key": "...",
      "nonce": "..."
    }
  }
}

Is there something I am doing wrong? Also, is there also some reference for what the error codes -40401 and -40405 mean?

JayFoxRox commented 11 months ago

How to set the IP if my AP doesn't have DHCP? I only see getDeviceIpAddress in the command list 🤔

Also, where do I find info how to run the commands mentioned above? What is the respective payload? @jp-0

Edit: I'm also not sure how to set a username password. I'm also not sure what the current username/password is

JayFoxRox commented 11 months ago

I have received my C220 now, but I'm not sure how to change the password. For now, I've set up a custom DHCP server for the camera. I'd still like to know how to give it a static network config.

Because I don't know the current username/password, I can't use my camera at all, because I'm unable to access any video stream. (Pan/Tilting the camera works fine, though, and any password is accepted via pytapo as long as username is admin)

I don't want to use the mobile app / bind my device to the cloud, so I'd prefer it someone can document what those different accounts are used for (how does the app use them / which account should be used for routine tasks).

I also couldn't figure out how to do the steps in https://github.com/JurajNyiri/pytapo/issues/24#issuecomment-1575639582:

Regardless I went ahead with a firmware upgrade as documented in https://github.com/DrmnSamoLiu/Tapo_Camera_Firmware/issues/9

JayFoxRox commented 11 months ago

Finally got it working

Also, is there also some reference for what the error codes -40401 and -40405 mean?

I've actually reverse-engineered this now. I believe 40xxx are basically HTTP status codes, so 401 = unauthorized and 405 = not allowed. The 60xxx codes are for each command. I also got some and only through RE I was able to find out some restrictions on certin arguments (example: username must be at least 6 symbols long).

Some observations:

Excuse the poor following code style, I've been trying to figure this out for way too long and need some sleep now. I've used this set of steps after resetting the camera and joining its network:

import base64
from hashlib import md5
import json
from pytapo import Tapo

user = "admin"
password = "admin" # This password only works if you never used `changeAdminPassword` before
host = "192.168.191.1"

tapo = Tapo(host, user, password, printDebugInformation=True, redactConfidentialInformation=False)

def encryptPassphrase(passphrase):
    public_pem_data = b"""-----BEGIN RSA PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC4D6i0oD/Ga5qb//RfSe8MrPVI
rMIGecCxkcGWGj9kxxk74qQNq8XUuXoy2PczQ30BpiRHrlkbtBEPeWLpq85tfubT
UjhBz1NPNvWrC88uaYVGvzNpgzZOqDC35961uPTuvdUa8vztcUQjEZy16WbmetRj
URFIiWJgFCmemyYVbQIDAQAB
-----END RSA PUBLIC KEY-----"""

    public_key = load_pem_public_key(public_pem_data)

    ciphertext = public_key.encrypt(passphrase, padding.PKCS1v15())
    return ciphertext

def multipleRequests(requests):
    return {
        "method": "multipleRequest",
        "params": {
            "requests": requests
        }
    }

# Work around login not working
def anonPerformRequest(req):
    url = tapo.getHostURL()
    res = tapo.request(
                "POST",
                url,
                data=json.dumps(req),
                headers=tapo.headers,
                verify=False,
            )
    return res.json()

# Target values
third_account_username = "tapousername" # must be at least 6 symbols (!)
third_account_password = "tapopassword"
admin_password = "adminpassword"

# Change third user account
if True:

    passwd = third_account_password
    passwd_md5 = md5(passwd.encode('utf-8')).hexdigest().upper()
    passwd_rsa = base64.b64encode(encryptPassphrase(passwd.encode('utf-8'))).decode('utf-8')

    changeThirdAccount = {
        "method": "changeThirdAccount",
        "params": {
            "user_management": {
                "change_third_account": {
                    "secname": "third_account",
                    "passwd": passwd_md5,  # uppercase of the md5 hash of your password
                    "old_passwd": "",
                    "ciphertext": passwd_rsa,  # using the same public key extracted from the app, as mentioned in another comment
                    "username": third_account_username, 
                }
            }
        }
    }
    res = anonPerformRequest(multipleRequests([changeThirdAccount]))
    print('changeThirdAccount', res)

    verifyThirdAccount = {
        "method": "verifyThirdAccount",
        "params": {
            "user_management": {
                "verify_third_account": {
                    "secname": "third_account",
                    "passwd": passwd_md5,  # uppercase of the md5 hash of your password
                    "old_passwd": "",
                    "ciphertext": passwd_rsa,  # using the same public key extracted from the app, as mentioned in another comment
                    "username": third_account_username,
                }
            }
        }
    }

# Change admin password (the above must have ran before)
if False:

    # Factory password (!)
    passwd = admin_password
    passwd_md5 = md5(passwd.encode('utf-8')).hexdigest().upper()
    passwd_rsa = base64.b64encode(encryptPassphrase(passwd.encode('utf-8'))).decode('utf-8')

    changeAdminPassword = {
        "method": "changeAdminPassword",
        "params": {
            "user_management": {
                "change_admin_password": {
                    "secname": "root",
                    "passwd": passwd_md5,
                    "old_passwd": md5(password.encode('utf-8')).hexdigest().upper(), # old password as md5 encoded
                    "ciphertext": passwd_rsa,
                    "username": "admin" # must be "admin" (?)
                }
            }
        }
    }
    res = anonPerformRequest(multipleRequests([verifyThirdAccount, changeAdminPassword]))
    print('changeAdminPassword', res)

After that, I did the connectAp.

Following that, I did:

ffplay 'rtsp://tapousername:tapopassword@192.168.179.104/stream1' -rtsp_transport tcp

Connecting through admin:adminpassword did not work.

The UDP rtsp was really unstable for me but that might also be related to my bridge which blocks the camera from the internet. I still have to figure out how to do two-way audio though.


I have a pretty good understanding about all involved tools (based on my RE of the C200 firmware, even though I own a C220):

I plan to document my findings as I've also found some potential security issues (I plan responsible disclosure here if tplink has that). There are also a bunch of yet-to-be-documented commands. It should also be possible to upgrade the firmware without cloud-access, too - the endpoint for that is also exposed via uhttpd.

I also made my own client for the tplink cloud API (device-facing, not user-facing like other projects) but it still has some issues. My plan is to discover firmware URLs.

That way I should be able to get the camera completely cloudless, including the option to update.

jp-0 commented 10 months ago

hi @JayFoxRox The steps I mention are from a traffic capture between the android application and camera on setup. Some of the steps probably not strictly necessary, but it covers what the application would do. The third account details are what I use for rtsp connections.

Below some hastily copied excerpts I have which may help you on the items you mentioned - although you have addressed some in later post. Unfortunately not much time at the moment for this project, but I have enumerated all the paths in the application (for my camera at least) so have a lot more traffic / request captures to review whenever I get some more time (e.g. setting the 'active zones' via getLinecrossingDetectionConfig, disabling the OSD setOsd, etc).

I also couldn't figure out how to do the steps in #24 (comment):

  • ~scanApList~ - Done, trivial and documented above
  • setLanguage - Not sure what options or what parameters
  • changeAdminPassword - While some fields are documented, the full structure / POST payload hasn't been shown yet and I can't seem to make it work. I also don't know the current password and I'm not sure if each fields contain the old or the new password
  • ~setMediaEncrypt~ - Why is this part of the setup? I've done this though
  • setTimezone - I'm not sure what options or what parameters
  • setRecordPlan - Not sure what options or what parameters
  • ~connectAp~ - Done, trivial and documented above
  • setDeviceAlias - Not sure what options or what parameters
  • changeThirdAccount - Not sure what options or what parameters (also not sure what this account is used for)

setLanguage:

{
    "method": "setLanguage",
    "params": {
        "language": "EN"
    }
}

setTimezone:

{
                "method": "setTimezone",
                "params": {
                    "system": {
                        "basic": {
                            "timing_mode": "ntp",
                            "timezone": "UTC-00:00",
                            "zone_id": "UTC",
                        }
                    }
                },
            },

setRecordPlan:

            {
                "method": "setRecordPlan",
                "params": {
                    "record_plan": {
                        "chn1_channel": {
                            "enabled": "on",
                            "friday": '["0000-2400:2"]',
                            "monday": '["0000-2400:2"]',
                            "saturday": '["0000-2400:2"]',
                            "sunday": '["0000-2400:2"]',
                            "thursday": '["0000-2400:2"]',
                            "tuesday": '["0000-2400:2"]',
                            "wednesday": '["0000-2400:2"]',
                        }
                    }
                },
            },

setDeviceAlias:

            {
                "method": "setDeviceAlias",
                "params": {"system": {"sys": {"dev_alias": "mycamera"}}},
            }

changeThirdAccount:

                    {
                        "method": "changeThirdAccount",
                        "params": {
                            "user_management": {
                                "change_third_account": {
                                "secname": "third_account",
                                    "passwd": new_third_account_password_md5,
                                    "old_passwd": "",
                                    "ciphertext": new_third_account_password_rsa,
                                    "username": new_username,
                                }
                            }
                        },
                    }

verifyThirdAccount:

                        {
                            "method": "verifyThirdAccount",
                            "params": {
                                "user_management": {
                                    "verify_third_account": {
                                        "secname": "third_account",
                                        "passwd": current_third_account_password_md5,
                                        "old_passwd": "",
                                        "ciphertext": current_third_account_password_rsa,
                                        "username": current_username,
                                    }
                                }
                            },
                        }
jp-0 commented 10 months ago

@JayFoxRox have made some of my own stuff available here: https://github.com/jp-0/tapo-camera (I guess be careful with the provisioning as it does run changeAdminPassword) You should be able to use the provision-camera command Also have a few other interactions with the camera detailed in cam.py. There are plenty yet that I have not got to, however.