Reverse Engineering TP-Link TAPO

Plain HTTP and own encryption method.

The TAPO app communicates using two methods. Bluetooth and HTTP. Bluetooth is used to connect with unpaired devices (Exchange wifi ssid&psk etc). HTTP is used for every other request after the initial pairing process (get/set plug state and settings, update firmware, etc).

My goal

Be able to turn my plug on/off using an HTTP request.

HTTP requests

TAPO App sends all the requets to the http://<ip-of-the-tapo-device>/app.


The app sends two (session init) requests on the app start, the first one is the handshake request.


  "method": "handshake",
  "params": {
    "key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiHkY5laTugGN1Hf/sBHiiw6mnnkohmvVHHHGJqwRx59RjQaL/SPBoLpeNRgN3B/uykzYTLUVMpTcWSZHsS6FfhdoOkJ1B6nit6nheIfltbP99uJduP1JQ44S9dqUr73w++Lpl6TKrzK3KOc5z/vc9xmqiKK6PYbFZu2evCsL19wIDAQAB-----END PUBLIC KEY-----\n"
  "requestTimeMils": 0

The fields are self-explanatory, this request sends a public key generated by the app to the TAPO device.

TAPO App implementation - handshake generation

After recompiling the Android TAPO app we can see how this public key is generated:

public String getPublicKey() {
    /* Not relevant code, omitted */
    return "-----BEGIN PUBLIC KEY-----\n" + this.f20965b.get(0) + "-----END PUBLIC KEY-----\n";

f20965b is a HashMap containing the public and the private key (i=0 is the public key and i=1 is the private key). Futher research brang me to the function where the keys were generated:

public void mo35029c() {
    KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
    instance.initialize(1024, new SecureRandom());
    KeyPair generateKeyPair = instance.generateKeyPair();
    String str = new String(Base64.encode(((RSAPublicKey) generateKeyPair.getPublic()).getEncoded(), 0));
    String str2 = new String(Base64.encode(((RSAPrivateKey) generateKeyPair.getPrivate()).getEncoded(), 0));
    this.f20965b.put(0, str);
    this.f20965b.put(1, str2);

It's an RSA 1024 bit key pair which is Base64 encoded. After (re)implementing this function in java, I'm able to generate my own (valid) key pair.


Ok so back to the handshake request. After sending it to the TAPO device we will get (if everything goes well) the following response body:


and an Set-Cookie header with the TP_SESSIONID: TP_SESSIONID=D31BB81A0B0A3...EF0A790A150AD60A;TIMEOUT=1440 (Which is needed for the next requests)

As you can see the handshake request lasts for 24 hours.

TAPO App implementation - decryption of the key

TAPO app decrypts device's key in this function, I've (re)implemented it like this:

public void mo35024a(String tapokey) {
    byte[] decode = KspB64.decode(key.getBytes("UTF-8"));
    byte[] decode2 = KspB64.decode(keyPair.getPrivateKey());
    Cipher instance = Cipher.getInstance("RSA/ECB/PKCS1Padding");
    instance.init(2, (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(decode2)));
    byte[] doFinal = instance.doFinal(decode);
    byte[] bArr = new byte[16];
    byte[] bArr2 = new byte[16];
    System.arraycopy(doFinal, 0, bArr, 0, 16);
    System.arraycopy(doFinal, 16, bArr2, 0, 16);
    return new C658a(bArr, bArr2);

This block of code generates Cipher with RSA/NONE/PKCS1Padding and then manipulates and executes more methods.

On the last line of this function a C6586a object is defined:

public class C658a {

    Cipher f21776a_enc;
    Cipher f21777b_dec;

    public C658a(byte[] bArr, byte[] bArr2) throws Exception {
        SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, "AES");
        IvParameterSpec ivParameterSpec = new IvParameterSpec(bArr2);
        f21776a_enc = Cipher.getInstance("AES/CBC/PKCS7Padding");
        f21776a_enc.init(1, secretKeySpec, ivParameterSpec);
        f21777b_dec = Cipher.getInstance("AES/CBC/PKCS7Padding");
        f21777b_dec.init(2, secretKeySpec, ivParameterSpec);


    /* more code */

Here we can see that the bArr is a secret key spec for the AES. And the bArr2 is the iv.

After this constructor there are two functions which are handling the encryption and the decryption of the param content:

public String mo38009b_enc(String str) throws Exception {
    byte[] doFinal;
    doFinal = this.f21776a_enc.doFinal(str.getBytes());
    String encrypted = KspB64.encodeToString(doFinal);
    return encrypted.replace("\r\n","");

public String mo38006a_dec(String str) throws Exception{
    byte[] doFinal;
    doFinal = this.f21777b_dec.doFinal(KspB64.decode(str.getBytes("utf-8")));
    return new String(doFinal);

With these two functions we can encrypt the requests we will send to the TAPO device and decrypt the responses!

First securePassthrough

The second request sent to the device is a universal request with a method: securePassthrough. It will be later used for every other request like changing the plug state, getting info, etc.

The request body from the TAPO App looks like this:

  "method": "securePassthrough",
  "params": {
    "request": "vQewGPIlmr3G2l8uL0O3Yjnxc6dKUAMBzOA4xGwJe81N4iYrFzEEoLxY2Jxr5qxQ5uE84gMgQVHJ\nT174Z6z1/lDglp0FOtcFdXw6lUsvj5hcgjpHjaD+6CxcA5z1XF4xyfDJIIBcb5eJ+ZCyiw9wO+WN\nNnBg5SH6Lmq06+AzbP8I6R6X8SgrEt2OUjclJWnuYjJlxffwFD243VU30fKhjMthzGo0+UU+bXgA\nEE/LITY=\n"

As you can see, everything is readable but the params.request. As we know, it is encrypted using mo38009b_enc from the C658a object, created a step before. The plain params.request looks like this:

  "method": "login_device",
  "params": {
    "password": "ITcyNjU....",
    "username": "MzhhNTk2NT..."
  "requestTimeMils": 0

So we can see that the second request is the authorization attempt with the TP-Link credentials.

TAPO App implementation - encoding the login data

As you can see the password and the username are not in plain text. They are encoded. Both in different ways.

Password is encoded using B64 encode and the username uses MessageDigest of SHA1:

public static String shaDigestUsername(String str) throws NoSuchAlgorithmException {
    byte[] bArr = str.getBytes();
    byte[] digest = MessageDigest.getInstance("SHA1").digest(bArr);

    StringBuilder sb = new StringBuilder();
    for(byte b : digest){
        String hexString = Integer.toHexString(b & 255);
        if(hexString.length() == 1){
        } else {
    return sb.toString();

And after this username is also B64 encoded.


  "error_code": 0,
  "result": {
    "response": "zQMfnu0DQcB9xaJ9srqWVqbxC/2vuKnDT4jyFVqKyCb4GBas06djUCchwdwbp8iFr9Z5gFtrMmy/SHVjKl3eruAqe+vzVtgQtWUjeVrhSyE="

This response can be decrypted and after decryption, we will see:

  "error_code": 0,
  "result": {
    "token": "E0AA81A79277AA712...BF127322B523"

And there it is, the next token which will be needed for the next requests. At this point, we are authenticated to do other requests.

My goal implementation

After sniffing the first, two requests we can sniff the last one, the bulb state request. It looks like this, decrypted:

  "method": "set_device_info",
  "params": {
    "device_on": false
  "requestTimeMils": 1602840338865,
  "terminalUUID": "88-54-DE-AD-52-E1"

Most of the fields are self-explanatory, terminalUUID is the MAC Address of the plug.

This params.request can be inserted into request with method securePassthrough.


So with this information, it is possible to make a library to control TP-Link TAPO devices.

For now, I've only implemented set_device_info, but at this point, I can just sniff every other request the app makes and decrypt it and finally implement it.

I will post PoC written in Java later.


To decrypt unknown requests (without RE) you will need to patch mo35029c, so it puts your own private & public key. 2023